Files
tech-log-frontend/tests/features/tech-log/studio-publication-flow.test.tsx
DongHyeonka 11c2713139 feat: let Studio create the topics and projects publishing requires
Publishing needs a topic and nothing could create one. The backend now owns
that surface; this is its consumer — the management contract vendored, a
gateway over its nine operations, and one Studio screen that lists, creates,
and deletes topics and projects.

The screen adds no CSS. It reuses the classes the working-copy list already
uses, so it inherits Studio's spacing, type, and colour rather than growing a
second visual vocabulary beside them. Scope stops at list/create/delete:
renaming, phase changes, and visibility are implemented in the backend and
declared in the contract, but their screens are a separate design.

Two real defects surfaced while making the public port async, and both would
have shipped:

The search page and the header search dialog shared a query key. With an empty
query, `["tech-log","search",""]` was identical for both, so react-query
handed one surface the other's cache — different shapes — and the page died
reading a field that was not there. Keys now name the surface.

The explore filter's selects are uncontrolled and read `defaultValue`, which
React applies once. Their options arrive later now, so the first render had
nothing to match and the value stayed empty: a topic in the URL no longer
showed as selected. The form key includes whether the catalog has arrived, so
it remounts with the options present. Controlled inputs would be the other
answer, but this form submits to build a URL — the URL owns the value.

The route brought its own bookkeeping: a build chunk, a manual accessibility
evidence file, and the CI artifact baseline that counts them. The gate pins a
digest of its own shape precisely so a new route cannot slip in without that
count being reviewed.

Test harnesses that render public screens now assemble the query providers and
await the settled paint, because the screens they render became async.
2026-08-20 23:40:15 +09:00

306 lines
12 KiB
TypeScript

// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
import { MOCK_STUDIO_INSTALL_CONTEXT } from "../../helpers/studio-install-context.ts";
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
import { StudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
import type { StudioGateway } from "../../../src/features/tech-log/application/ports/studio-gateway.ts";
import type { WorkingCopyInput } from "../../../src/features/tech-log/contracts/studio/contract.ts";
import { PublicationEventPreviewScreen } from "../../../src/features/tech-log/presentation/studio/components/publication-event-preview-screen.tsx";
import { PublicationList } from "../../../src/features/tech-log/presentation/studio/components/publication-list.tsx";
import { PublishScreen } from "../../../src/features/tech-log/presentation/studio/components/publish-screen.tsx";
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
import { createManagementGatewayStub } from "../../helpers/management-gateway-stub.ts";
const originalShowModal = HTMLDialogElement.prototype.showModal;
const originalClose = HTMLDialogElement.prototype.close;
class NoopIntersectionObserver implements IntersectionObserver {
readonly root = null;
readonly rootMargin = "0px";
readonly scrollMargin = "0px";
readonly thresholds = [0];
disconnect() {}
observe() {}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
unobserve() {}
}
beforeAll(() => {
vi.stubGlobal("IntersectionObserver", NoopIntersectionObserver);
});
afterAll(() => {
vi.unstubAllGlobals();
});
beforeEach(() => {
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
configurable: true,
value(this: HTMLDialogElement) {
this.setAttribute("open", "");
},
});
Object.defineProperty(HTMLDialogElement.prototype, "close", {
configurable: true,
value(this: HTMLDialogElement) {
this.removeAttribute("open");
this.dispatchEvent(new Event("close"));
},
});
});
afterEach(() => {
vi.restoreAllMocks();
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
configurable: true,
value: originalShowModal,
});
Object.defineProperty(HTMLDialogElement.prototype, "close", {
configurable: true,
value: originalClose,
});
});
function renderInStudio(
node: React.ReactNode,
gateway: StudioGateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(),
navigate: (href: string) => void = () => undefined,
) {
return render(
<MemoryRouter initialEntries={["/studio"]}>
<StudioProvider
createManagementGateway={createManagementGatewayStub} createGateway={() => gateway} navigate={navigate}>
{node}
</StudioProvider>
</MemoryRouter>,
);
}
async function warningReadyDocument(gateway: StudioGateway) {
const input: WorkingCopyInput = {
kind: "CASE",
title: "게시 경고 예시",
slug: "publish-warning-example",
summary: "경고 확인 뒤 게시합니다.",
topicId: FIXTURE_IDS.topicJpa,
projectId: null,
relations: [],
problem: "경고가 있습니다.",
conclusion: "확인 뒤 게시합니다.",
environment: "Studio",
reproduction: "Mock",
lastVerifiedOn: "2026-08-14",
bodyMarkdown: "게시할 본문",
};
const document = await gateway.createDocument(input, {
idempotencyKey: "publication-test-create",
});
const validation = await gateway.validateDocument(
document.id,
{ expectedVersion: 1 },
{ idempotencyKey: "publication-test-validation" },
);
await gateway.createPreview(
document.id,
{ expectedVersion: 1, validationId: validation.validationId },
{ idempotencyKey: "publication-test-preview" },
);
return document;
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, resolve, reject };
}
describe("TechLog Studio publication flow", () => {
it("blocks invalid and stale saved versions before a publish command can start", async () => {
const invalid = renderInStudio(
<PublishScreen documentId={FIXTURE_IDS.edgeTokenQuestion} />,
);
expect(await screen.findByText("검증 오류를 먼저 수정해야 합니다")).toBeVisible();
expect(screen.queryByRole("button", { name: "게시" })).not.toBeInTheDocument();
invalid.unmount();
renderInStudio(<PublishScreen documentId={FIXTURE_IDS.fetchJoinCase} />);
expect(await screen.findByText("검증 결과가 현재 버전과 다릅니다")).toBeVisible();
expect(screen.getByRole("link", { name: "다시 검증" })).toHaveAttribute(
"href",
`/studio/documents/${FIXTURE_IDS.fetchJoinCase}/validation`,
);
});
it("publishes a current warning preview only after every warning is acknowledged", async () => {
const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
const document = await warningReadyDocument(gateway);
const destinations: string[] = [];
renderInStudio(
<PublishScreen documentId={document.id} />,
gateway,
(href) => destinations.push(href),
);
const publish = await screen.findByRole("button", { name: "게시" });
expect(publish).toBeDisabled();
await userEvent.click(screen.getByRole("checkbox", { name: /PROJECT_MISSING/ }));
expect(publish).toBeEnabled();
await userEvent.click(publish);
await waitFor(() =>
expect(destinations[0]).toMatch(
/^\/studio\/publications\/[0-9a-f-]+\/preview$/,
),
);
expect(screen.getByRole("status", { name: "" })).toHaveTextContent("게시했습니다.");
expect((await gateway.listPublications({ limit: 100 })).items[0]).toMatchObject({
event: { type: "PUBLISHED", publishedVersion: 1 },
publication: { status: "PUBLISHED", publicPath: "/cases/publish-warning-example" },
});
});
it("keeps the publish pending state, preserves gateway command order, and retries with a new key", async () => {
const user = userEvent.setup();
const base = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
const document = await warningReadyDocument(base);
const first = deferred<never>();
const calls: string[] = [];
const keys: string[] = [];
const publishDocument = vi
.fn<StudioGateway["publishDocument"]>()
.mockImplementationOnce((_id, _command, options) => {
calls.push("publishDocument");
keys.push(options.idempotencyKey);
return first.promise;
})
.mockImplementation((...args) => {
calls.push("publishDocument");
keys.push(args[2].idempotencyKey);
return base.publishDocument(...args);
});
const gateway = {
...base,
getDocument(...args: Parameters<StudioGateway["getDocument"]>) {
calls.push("getDocument");
return base.getDocument(...args);
},
getCurrentPreview(...args: Parameters<StudioGateway["getCurrentPreview"]>) {
calls.push("getCurrentPreview");
return base.getCurrentPreview(...args);
},
publishDocument,
} satisfies StudioGateway;
renderInStudio(<PublishScreen documentId={document.id} />, gateway);
await user.click(await screen.findByRole("checkbox", { name: /PROJECT_MISSING/ }));
await user.click(screen.getByRole("button", { name: "게시" }));
expect(screen.getByRole("button", { name: "게시 중…" })).toBeDisabled();
expect(calls.slice(0, 3)).toEqual([
"getDocument",
"getCurrentPreview",
"publishDocument",
]);
first.reject(new StudioGatewayError({
type: "https://techlog.local/problems/studio-unavailable",
title: "STUDIO_UNAVAILABLE",
status: 503,
detail: "Studio가 잠시 응답하지 않습니다.",
code: "STUDIO_UNAVAILABLE",
retryable: true,
}));
expect(await screen.findByRole("alert")).toHaveTextContent(
"Studio가 잠시 응답하지 않습니다.",
);
await user.click(screen.getByRole("button", { name: "게시" }));
await waitFor(() => expect(publishDocument).toHaveBeenCalledTimes(2));
expect(keys[1]).not.toBe(keys[0]);
});
it("filters publication events and recovers a failed history read", async () => {
const user = userEvent.setup();
const base = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
const listPublications = vi
.fn<StudioGateway["listPublications"]>()
.mockRejectedValueOnce(new Error("offline"))
.mockImplementation((query, options) => base.listPublications(query, options));
renderInStudio(<PublicationList />, { ...base, listPublications });
expect(await screen.findByRole("alert")).toHaveTextContent(
"게시 기록을 불러오지 못했습니다offline",
);
await user.click(screen.getByRole("button", { name: "다시 시도" }));
expect(await screen.findByRole("heading", { name: "Redis Adapter가 도메인 TTL 정책을 소유하지 않는 이유" })).toBeVisible();
await user.selectOptions(screen.getByLabelText("이벤트"), "UNPUBLISHED");
await user.type(screen.getByLabelText("검색"), "Fetch 전략");
await user.click(screen.getByRole("button", { name: "적용" }));
expect(await screen.findByRole("heading", { name: "JPA 목록 조회에서 Fetch 전략을 선택하는 기준" })).toBeVisible();
expect(screen.queryByRole("heading", { name: "Redis Adapter가 도메인 TTL 정책을 소유하지 않는 이유" })).not.toBeInTheDocument();
});
it("unpublishes only the selected current row after the source confirmation", async () => {
const user = userEvent.setup();
const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
renderInStudio(<PublicationList />, gateway);
await user.click(
await screen.findByRole("button", { name: /Redis Adapter.*게시 취소/ }),
);
expect(screen.getByRole("dialog", { name: "게시를 취소할까요?" })).toHaveTextContent(
"Studio 게시 상태를 중단하고 게시 취소 이벤트를 남깁니다.",
);
expect(screen.getByText("작업본과 이전 Snapshot은 보존됩니다.")).toBeVisible();
await user.click(screen.getByRole("button", { name: "게시 취소 확인" }));
await waitFor(() => expect(screen.getAllByText("게시를 취소했습니다.").length).toBeGreaterThanOrEqual(1));
expect(await screen.findAllByRole("link", { name: "게시 취소 전 Snapshot 보기" })).not.toHaveLength(0);
});
it("renders the event's immutable snapshot instead of a newer working copy", async () => {
const view = renderInStudio(
<PublicationEventPreviewScreen publicationEventId={FIXTURE_IDS.fetchPublishedEvent} />,
);
expect(await screen.findByRole("heading", { level: 1, name: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가" })).toBeVisible();
expect(screen.getByText(/반환된 20건 뒤에서 전체 컬렉션이 로드되는 과정/)).toBeVisible();
expect(screen.queryByText("게시 후 본문 측정값을 보완한 저장본입니다.")).not.toBeInTheDocument();
expect(view.container.querySelectorAll("main")).toHaveLength(0);
expect(view.container.querySelector(".public-record-embedded")).toBeInTheDocument();
});
it("keeps unknown publication events inside the Studio not-found screen", async () => {
renderInStudio(
<PublicationEventPreviewScreen publicationEventId="99999999-9999-4999-8999-999999999999" />,
);
expect(await screen.findByRole("heading", { level: 1, name: "게시 기록을 찾을 수 없습니다" })).toBeVisible();
expect(screen.getByRole("link", { name: "게시 기록으로 돌아가기" })).toHaveAttribute(
"href",
"/studio/publications",
);
});
});