Files
tech-log-frontend/tests/features/tech-log/studio-save-navigation.test.tsx
T
DongHyeonka 3b641906b8 feat: select the TechLog Studio adapter from runtime configuration
Adds TECH_LOG_STUDIO_SOURCE (MOCK | HTTP, default MOCK) to the V2 runtime
config schema so a build can switch createTechLogFeatureInstalledInput
between the mock and HTTP Studio gateways without a rebuild. V1 documents
predate the key and always normalize to MOCK. The HTTP gateway is
constructed with only { operations } per Task 4's actual signature -
no CSRF provider is wired here; that lands with attachCredentials at a
later composition-root task.

Updates every existing Studio test call site to the new required
createTechLogFeatureInstalledInput(context) signature via a shared
tests/helpers/studio-install-context.ts MOCK fixture, so the whole
existing Studio suite keeps exercising the mock adapter unchanged.
2026-08-18 01:57:15 +09:00

301 lines
12 KiB
TypeScript

// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useEffect } from "react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, 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 {
WorkingCopy,
WorkingCopyInput,
WorkingCopyDetail,
} from "../../../src/features/tech-log/contracts/studio/contract.ts";
import { DocumentEditorScreen } from "../../../src/features/tech-log/presentation/studio/components/document-editor-screen.tsx";
import { GuardedStudioLink } from "../../../src/features/tech-log/presentation/studio/components/guarded-studio-link.tsx";
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
import {
useStudio,
useStudioEditorSession,
} from "../../../src/features/tech-log/presentation/studio/use-studio.ts";
const originalShowModal = HTMLDialogElement.prototype.showModal;
const originalClose = HTMLDialogElement.prototype.close;
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 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 };
}
function inputOf(document: WorkingCopy): WorkingCopyInput {
const { id, version, updatedAt, ...input } = document;
void id;
void version;
void updatedAt;
return input;
}
function Announcement() {
const { requestAnnouncement } = useStudio();
return <p data-testid="announcement" aria-live="polite">{requestAnnouncement}</p>;
}
function renderEditor(documentId: string, gateway: StudioGateway) {
return render(
<MemoryRouter initialEntries={[`/studio/documents/${documentId}/edit`]}>
<StudioProvider createGateway={() => gateway}>
<DocumentEditorScreen documentId={documentId} />
<Announcement />
</StudioProvider>
</MemoryRouter>,
);
}
function DirtyNavigationProbe({ saved }: { saved: WorkingCopy }) {
const { begin, updateDraft } = useStudioEditorSession();
useEffect(() => {
begin(saved, inputOf(saved));
updateDraft({ ...inputOf(saved), title: "바뀐 제목" });
}, [begin, saved, updateDraft]);
return <GuardedStudioLink href="/studio/documents">작업본</GuardedStudioLink>;
}
async function getSavedDocument(gateway: StudioGateway) {
return (await gateway.getDocument(FIXTURE_IDS.redisAdapterCase)).document;
}
describe("TechLog Studio save workflow", () => {
it("shows save pending and success states and creates a fresh idempotency key for each command", async () => {
const user = userEvent.setup();
const base = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
const first = deferred<void>();
const keys: string[] = [];
let calls = 0;
const gateway = {
...base,
saveDocument(...args: Parameters<StudioGateway["saveDocument"]>) {
keys.push(args[2].idempotencyKey);
calls += 1;
return calls === 1
? first.promise.then(() => base.saveDocument(...args))
: base.saveDocument(...args);
},
} satisfies StudioGateway;
renderEditor(FIXTURE_IDS.redisAdapterCase, gateway);
await user.clear(await screen.findByLabelText("제목"));
await user.type(screen.getByLabelText("제목"), "첫 저장 제목");
await user.click(screen.getByRole("button", { name: "저장" }));
expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent("저장 중…");
expect(screen.getByRole("button", { name: "저장 중…" })).toBeDisabled();
first.resolve();
await waitFor(() => expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent("저장됨"));
expect(screen.getByTestId("announcement")).toHaveTextContent(
"버전 5으로 저장했습니다.",
);
await user.clear(screen.getByLabelText("제목"));
await user.type(screen.getByLabelText("제목"), "두 번째 저장 제목");
await user.click(screen.getByRole("button", { name: "저장" }));
await waitFor(() => expect(keys).toHaveLength(2));
expect(keys[0]).toBeTruthy();
expect(keys[1]).toBeTruthy();
expect(keys[1]).not.toBe(keys[0]);
});
it("keeps the local draft and disables overwrite after a revision conflict", async () => {
const user = userEvent.setup();
const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
renderEditor(FIXTURE_IDS.conflictCase, gateway);
await user.clear(await screen.findByLabelText("제목"));
await user.type(screen.getByLabelText("제목"), "내 충돌 초안");
await user.click(screen.getByRole("button", { name: "저장" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.",
);
expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent("저장 충돌");
expect(screen.getByLabelText("제목")).toHaveValue("내 충돌 초안");
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
});
it("surfaces a save error and retries with a new user-command key without losing input", async () => {
const user = userEvent.setup();
const base = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
const keys: string[] = [];
const retryable = new StudioGatewayError({
type: "https://techlog.local/problems/studio-unavailable",
title: "STUDIO_UNAVAILABLE",
status: 503,
detail: "Studio가 잠시 응답하지 않습니다.",
code: "STUDIO_UNAVAILABLE",
retryable: true,
});
const saveDocument = vi
.fn<StudioGateway["saveDocument"]>()
.mockImplementationOnce((_id, _command, options) => {
keys.push(options.idempotencyKey);
return Promise.reject(retryable);
})
.mockImplementation((...args) => {
keys.push(args[2].idempotencyKey);
return base.saveDocument(...args);
});
renderEditor(FIXTURE_IDS.redisAdapterCase, { ...base, saveDocument });
await user.clear(await screen.findByLabelText("제목"));
await user.type(screen.getByLabelText("제목"), "오류 뒤에도 남는 초안");
await user.click(screen.getByRole("button", { name: "저장" }));
await waitFor(() => expect(screen.getByTestId("announcement")).toHaveTextContent(
"Studio가 잠시 응답하지 않습니다.",
));
expect(screen.getByLabelText("제목")).toHaveValue("오류 뒤에도 남는 초안");
expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent("저장되지 않음");
await user.click(screen.getByRole("button", { name: "저장" }));
await waitFor(() => expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent("저장됨"));
expect(saveDocument).toHaveBeenCalledTimes(2);
expect(keys[1]).not.toBe(keys[0]);
});
});
describe("TechLog Studio dirty navigation", () => {
it("opens the source modal dialog, protects browser unload, and restores focus when staying", async () => {
const user = userEvent.setup();
const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
const destinations: string[] = [];
const saved = await getSavedDocument(gateway);
render(
<MemoryRouter initialEntries={[`/studio/documents/${saved.id}/edit`]}>
<StudioProvider createGateway={() => gateway} navigate={(href) => destinations.push(href)}>
<DirtyNavigationProbe saved={saved} />
</StudioProvider>
</MemoryRouter>,
);
await waitFor(() => {
const unload = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(unload);
expect(unload.defaultPrevented).toBe(true);
});
const trigger = screen.getByRole("link", { name: "작업본" });
trigger.focus();
await user.click(trigger);
const dialog = screen.getByRole("dialog", { name: "저장하지 않은 변경" });
expect(dialog).toHaveAttribute("open");
expect(screen.getByRole("button", { name: "이 페이지에 머무르기" })).toHaveFocus();
expect(screen.getAllByRole("button").map((button) => button.textContent)).toEqual([
"이 페이지에 머무르기",
"변경 버리기",
"저장 후 이동",
]);
await user.click(screen.getByRole("button", { name: "이 페이지에 머무르기" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(trigger).toHaveFocus();
expect(destinations).toEqual([]);
});
it("discards the draft and follows the pending internal destination", async () => {
const user = userEvent.setup();
const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
const destinations: string[] = [];
const saved = await getSavedDocument(gateway);
render(
<MemoryRouter initialEntries={[`/studio/documents/${saved.id}/edit`]}>
<StudioProvider createGateway={() => gateway} navigate={(href) => destinations.push(href)}>
<DirtyNavigationProbe saved={saved} />
</StudioProvider>
</MemoryRouter>,
);
await user.click(screen.getByRole("link", { name: "작업본" }));
await user.click(screen.getByRole("button", { name: "변경 버리기" }));
expect(destinations).toEqual(["/studio/documents"]);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
const unload = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(unload);
expect(unload.defaultPrevented).toBe(false);
});
it("saves the draft before following the pending destination", async () => {
const user = userEvent.setup();
const base = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
const pending = deferred<WorkingCopyDetail>();
let key = "";
const gateway = {
...base,
saveDocument(...args: Parameters<StudioGateway["saveDocument"]>) {
key = args[2].idempotencyKey;
return pending.promise;
},
} satisfies StudioGateway;
const destinations: string[] = [];
const saved = await getSavedDocument(base);
render(
<MemoryRouter initialEntries={[`/studio/documents/${saved.id}/edit`]}>
<StudioProvider createGateway={() => gateway} navigate={(href) => destinations.push(href)}>
<DirtyNavigationProbe saved={saved} />
</StudioProvider>
</MemoryRouter>,
);
await user.click(screen.getByRole("link", { name: "작업본" }));
await user.click(screen.getByRole("button", { name: "저장 후 이동" }));
expect(screen.getByRole("button", { name: "저장 중" })).toBeDisabled();
expect(destinations).toEqual([]);
pending.resolve({
...(await base.getDocument(saved.id)),
document: { ...saved, version: saved.version + 1, title: "바뀐 제목" },
});
await waitFor(() => expect(destinations).toEqual(["/studio/documents"]));
expect(key).toBeTruthy();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});