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.
181 lines
7.1 KiB
TypeScript
181 lines
7.1 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import type { ReactNode } from "react";
|
|
import { MemoryRouter, useLocation, useNavigate } from "react-router-dom";
|
|
import { describe, expect, it } 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 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 { DocumentEditorScreen } from "../../../src/features/tech-log/presentation/studio/components/document-editor-screen.tsx";
|
|
import { NewDocumentForm } from "../../../src/features/tech-log/presentation/studio/components/new-document-form.tsx";
|
|
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
|
|
|
|
type DecisionInput = Extract<WorkingCopyInput, { kind: "PROJECT_DECISION" }>;
|
|
|
|
function decisionInput(): DecisionInput {
|
|
return {
|
|
kind: "PROJECT_DECISION",
|
|
title: "목록 페이징과 컬렉션 로딩을 분리합니다",
|
|
slug: "feed-pagination-boundary",
|
|
summary: "부모 페이지를 먼저 고정하고 연관 컬렉션을 별도로 조회합니다.",
|
|
topicId: FIXTURE_IDS.topicJpa,
|
|
projectId: FIXTURE_IDS.projectBackend,
|
|
relations: [
|
|
{
|
|
id: null,
|
|
targetId: FIXTURE_IDS.fetchJoinCase,
|
|
reason: "판단 근거",
|
|
order: 0,
|
|
},
|
|
],
|
|
decisionStatus: "ADOPTED",
|
|
decidedOn: "2026-08-11",
|
|
statement: "목록 조회와 컬렉션 로딩을 서로 다른 단계로 수행합니다.",
|
|
rationale: "Fetch Join이 DB LIMIT를 제거하는 문제를 피합니다.",
|
|
consequences: [
|
|
{
|
|
id: "99999999-9999-4999-8999-999999999991",
|
|
text: "목록 조회는 한 번의 SQL로 끝나지 않습니다.",
|
|
order: 0,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function RouterStudioProvider({
|
|
gateway,
|
|
children,
|
|
}: Readonly<{ gateway: StudioGateway; children: ReactNode }>) {
|
|
const navigate = useNavigate();
|
|
return (
|
|
<StudioProvider
|
|
createGateway={() => gateway}
|
|
navigate={(href) => { void navigate(href); }}
|
|
>
|
|
{children}
|
|
</StudioProvider>
|
|
);
|
|
}
|
|
|
|
function LocationProbe() {
|
|
return <output aria-label="현재 경로">{useLocation().pathname}</output>;
|
|
}
|
|
|
|
describe("TechLog Studio project decision authoring", () => {
|
|
it("creates Decision as the fourth working-copy type", async () => {
|
|
const user = userEvent.setup();
|
|
const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
|
|
render(
|
|
<MemoryRouter initialEntries={["/studio/documents/new"]}>
|
|
<RouterStudioProvider gateway={gateway}>
|
|
<NewDocumentForm />
|
|
<LocationProbe />
|
|
</RouterStudioProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
await user.click(screen.getByRole("radio", { name: /Decision/ }));
|
|
await user.click(screen.getByRole("button", { name: "작업본 만들기" }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText("현재 경로")).toHaveTextContent(
|
|
/^\/studio\/documents\/[0-9a-f-]+\/edit$/,
|
|
);
|
|
});
|
|
expect((await gateway.listDocuments({ kind: "PROJECT_DECISION" })).items)
|
|
.toHaveLength(1);
|
|
});
|
|
|
|
it("validates, previews, and publishes a project-scoped Decision", async () => {
|
|
const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
|
|
const document = await gateway.createDocument(decisionInput(), {
|
|
idempotencyKey: "create-project-decision",
|
|
});
|
|
const validation = await gateway.validateDocument(
|
|
document.id,
|
|
{ expectedVersion: document.version },
|
|
{ idempotencyKey: "validate-project-decision" },
|
|
);
|
|
expect(validation).toMatchObject({ status: "VALID", issues: [] });
|
|
|
|
const preview = await gateway.createPreview(
|
|
document.id,
|
|
{
|
|
expectedVersion: document.version,
|
|
validationId: validation.validationId,
|
|
},
|
|
{ idempotencyKey: "preview-project-decision" },
|
|
);
|
|
expect(preview.renderModel).toMatchObject({
|
|
kind: "PROJECT_DECISION",
|
|
status: "ADOPTED",
|
|
publicPath: "/projects/backend-skeleton/decisions#feed-pagination-boundary",
|
|
});
|
|
|
|
const published = await gateway.publishDocument(
|
|
document.id,
|
|
{
|
|
expectedVersion: document.version,
|
|
validationId: validation.validationId,
|
|
previewId: preview.previewId,
|
|
acknowledgedWarningCodes: [],
|
|
},
|
|
{ idempotencyKey: "publish-project-decision" },
|
|
);
|
|
expect(published.publication.publicPath).toBe(
|
|
"/projects/backend-skeleton/decisions#feed-pagination-boundary",
|
|
);
|
|
});
|
|
|
|
it("edits Decision fields and renders the project decision card preview", async () => {
|
|
const user = userEvent.setup();
|
|
const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
|
|
const document = await gateway.createDocument(decisionInput(), {
|
|
idempotencyKey: "edit-project-decision",
|
|
});
|
|
render(
|
|
<MemoryRouter initialEntries={[`/studio/documents/${document.id}/edit`]}>
|
|
<StudioProvider createGateway={() => gateway}>
|
|
<DocumentEditorScreen documentId={document.id} />
|
|
</StudioProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
expect(await screen.findByLabelText("결정 상태")).toHaveValue("ADOPTED");
|
|
expect(screen.getByLabelText("결정일")).toHaveValue("2026-08-11");
|
|
expect(screen.getByLabelText("결정문")).toHaveValue(
|
|
"목록 조회와 컬렉션 로딩을 서로 다른 단계로 수행합니다.",
|
|
);
|
|
expect(screen.getByLabelText("판단 이유")).toHaveValue(
|
|
"Fetch Join이 DB LIMIT를 제거하는 문제를 피합니다.",
|
|
);
|
|
expect(screen.getByLabelText("영향 1")).toHaveValue(
|
|
"목록 조회는 한 번의 SQL로 끝나지 않습니다.",
|
|
);
|
|
expect(screen.getByLabelText("근거 1 대상")).toHaveValue(
|
|
FIXTURE_IDS.fetchJoinCase,
|
|
);
|
|
expect(screen.getByRole("complementary", { name: "작업 상태" }))
|
|
.toHaveTextContent("종류Decision");
|
|
|
|
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
|
|
const preview = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
|
|
expect(
|
|
within(preview).getByRole("heading", {
|
|
name: "목록 페이징과 컬렉션 로딩을 분리합니다",
|
|
}),
|
|
).toBeVisible();
|
|
expect(within(preview).getByText("판단 이유")).toBeVisible();
|
|
expect(within(preview).getByText("영향")).toBeVisible();
|
|
expect(within(preview).getByText("근거 기록")).toBeVisible();
|
|
expect(within(preview).getByRole("link", {
|
|
name: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
|
})).toHaveAttribute("href", "/cases/collection-fetch-join-pagination");
|
|
});
|
|
});
|