|
|
|
@@ -0,0 +1,302 @@
|
|
|
|
|
// @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 { 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";
|
|
|
|
|
|
|
|
|
|
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().input.createStudioGateway(),
|
|
|
|
|
navigate: (href: string) => void = () => undefined,
|
|
|
|
|
) {
|
|
|
|
|
return render(
|
|
|
|
|
<MemoryRouter initialEntries={["/studio"]}>
|
|
|
|
|
<StudioProvider 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().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().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().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().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",
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|