// @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"; 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( gateway} navigate={navigate}> {node} , ); } 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() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((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( , ); expect(await screen.findByText("검증 오류를 먼저 수정해야 합니다")).toBeVisible(); expect(screen.queryByRole("button", { name: "게시" })).not.toBeInTheDocument(); invalid.unmount(); renderInStudio(); 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( , 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(); const calls: string[] = []; const keys: string[] = []; const publishDocument = vi .fn() .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) { calls.push("getDocument"); return base.getDocument(...args); }, getCurrentPreview(...args: Parameters) { calls.push("getCurrentPreview"); return base.getCurrentPreview(...args); }, publishDocument, } satisfies StudioGateway; renderInStudio(, 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() .mockRejectedValueOnce(new Error("offline")) .mockImplementation((query, options) => base.listPublications(query, options)); renderInStudio(, { ...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(, 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( , ); 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( , ); expect(await screen.findByRole("heading", { level: 1, name: "게시 기록을 찾을 수 없습니다" })).toBeVisible(); expect(screen.getByRole("link", { name: "게시 기록으로 돌아가기" })).toHaveAttribute( "href", "/studio/publications", ); }); });