Files
tech-log-frontend/tests/features/tech-log/public-document-screens.test.tsx
T
DongHyeonkaandClaude Opus 5 fd73bc88a1 fix: Decision 미리보기의 결정일 요구를 풀고, 화면 테스트가 실제 동작을 다시 말하게 한다
Decision 은 결정일이 없으면 미리보기가 열리지 않았다. 검증은 그것을 경고로만 다루므로
날짜 없이 게시할 수 있는데 렌더 모델이 필수로 요구했다 — 작성자는 "경고라면서 왜 안
되냐"를 만난다. 계약을 nullable 로 열고 화면이 "결정일 미정"이라고 말하게 한다.

한 칸의 실패가 화면을 통째로 날리지 않게 한다. `Promise.all([gateway.foo()])` 은 foo 가
거절하는 것만 잡는다 — 호출이 동기적으로 던지면 배열을 만드는 중에 터져 rejection
handler 를 지나지 못하고, 그러면 홈 focus 한 칸 때문에 대시보드 전체가 빈 화면이 된다.
프로젝트 편집도 같은 모양이라 함께 고친다.

`IntersectionObserver` 가 없는 환경을 견딘다. 목차는 픽스처 Case 하나에서만 쓰여 그런
환경을 만난 적이 없었는데, 모든 Case 가 목차를 받게 되면서 jsdom 에서 문서가 통째로
깨졌다. 없으면 "지금 읽는 절" 표시만 못 할 뿐이다.

픽스처의 최근 기록에서 릴리스를 뺀다. 서버의 `latestEntries` 는 공개 투영에서 고르므로
릴리스가 없고, 홈이 릴리스를 따로 읽어 합친다 — 픽스처가 넣으면 같은 릴리스가 두 번
나온다.

화면 테스트는 `test:unit` 이 아니라 `test:tech-log` 가 돌린다. 그것을 돌리지 않아 위
두 결함과, 라우트 두 개·`--body-copy`·Case 배치 통합·활동 링크 제거처럼 의도한 변경에
고정돼 있던 단언들이 23건 빨간 채로 여러 커밋을 지나갔다. 단언을 실제 동작으로 옮긴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 18:03:11 +09:00

366 lines
15 KiB
TypeScript

// @vitest-environment jsdom
import { render, screen, within } from "@testing-library/react";
import { type ComponentType } from "react";
import {
createMemoryRouter,
Outlet,
RouterProvider,
} 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 {
TECH_LOG_ROUTE_REGISTRY,
TECH_LOG_ROUTE_RUNTIME_CONTRACT,
} from "../../../src/features/tech-log/contracts/tech-log-route-contract.ts";
import { CasePage } from "../../../src/features/tech-log/presentation/public/pages/case-page.tsx";
import { QuestionPage } from "../../../src/features/tech-log/presentation/public/pages/question-page.tsx";
import { ReferencePage } from "../../../src/features/tech-log/presentation/public/pages/reference-page.tsx";
import { TopicPage } from "../../../src/features/tech-log/presentation/public/pages/topic-page.tsx";
import { PublicNotFoundPage as NotFoundPage } from "../../../src/features/tech-log/presentation/public/pages/public-not-found-page.tsx";
import { PublicShell } from "../../../src/features/tech-log/presentation/public/public-shell.tsx";
import { TECH_LOG_ROUTE_CODECS } from "../../../src/features/tech-log/presentation/tech-log-route-codecs.ts";
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
import { createGroupedRouteObjects } from "../../../src/presentation/routes/app-router.tsx";
import { PLATFORM_ROUTE_CODECS } from "../../../src/presentation/routes/platform-route-codecs.ts";
import { createTestApplication } from "../../helpers/create-test-application.ts";
import { renderWithQueryProviders } from "../../helpers/query-providers.tsx";
const routeCodecs = Object.freeze({
...PLATFORM_ROUTE_CODECS,
...TECH_LOG_ROUTE_CODECS,
});
const routeComponents = {
TECH_LOG_CASE: CasePage,
TECH_LOG_REFERENCE: ReferencePage,
TECH_LOG_QUESTION: QuestionPage,
TECH_LOG_TOPIC: TopicPage,
} as const satisfies Record<string, ComponentType>;
type DocumentRouteId = keyof typeof routeComponents;
class NoopIntersectionObserver implements IntersectionObserver {
readonly root = null;
readonly rootMargin = "0px";
readonly scrollMargin = "0px";
readonly thresholds = [0];
disconnect() {}
observe() {}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
unobserve() {}
}
beforeEach(() => {
vi.stubGlobal("IntersectionObserver", NoopIntersectionObserver);
});
afterEach(() => {
vi.unstubAllGlobals();
});
async function renderDocumentRoute(routeId: DocumentRouteId, initialEntry: string) {
const definition = TECH_LOG_ROUTE_REGISTRY[routeId];
const runtime = TECH_LOG_ROUTE_RUNTIME_CONTRACT[routeId];
const Component = routeComponents[routeId];
const router = createMemoryRouter(
createGroupedRouteObjects(
{
[routeId]: definition,
NOT_FOUND: TECH_LOG_ROUTE_REGISTRY.NOT_FOUND,
},
{
[routeId]: { moduleId: runtime.moduleId, Component },
NOT_FOUND: {
moduleId: TECH_LOG_ROUTE_RUNTIME_CONTRACT.NOT_FOUND.moduleId,
Component: NotFoundPage,
},
},
{
PUBLIC: (
<PublicShell>
<Outlet />
</PublicShell>
),
STUDIO: <Outlet />,
},
"task-8-test-build",
routeCodecs,
),
{ initialEntries: [initialEntry] },
);
const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
const view = render(
renderWithQueryProviders(
<ApplicationProvider
application={createTestApplication({
featureInputs: { "tech-log": techLog },
})}
>
<RouterProvider router={router} />
</ApplicationProvider>,
));
// 포트가 async 가 되면서 첫 페인트에는 데이터가 없다. 화면이 정착한 뒤
// 단언하도록 여기서 한 번 기다린다 — 각 테스트에 흩어 놓으면 빠뜨린 곳이 생긴다.
await screen.findByRole("main");
return { ...view, router };
}
describe("TechLog canonical Public documents", () => {
it.each([
{
routeId: "TECH_LOG_CASE" as const,
path: "/cases/collection-fetch-join-pagination",
title: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
kind: "Case",
topic: "JPA",
project: "Backend Skeleton",
published: "2026.08.11",
evidence: "Join 결과 행 1,961개",
relations: [
"/questions/collection-fetch-join-with-pagination",
"/projects/backend-skeleton/decisions#feed-pagination-boundary",
"/references/jpa-list-fetch-strategy",
],
},
{
routeId: "TECH_LOG_CASE" as const,
path: "/cases/redis-adapter-ttl-boundary",
title: "Redis Adapter가 도메인 TTL 정책을 소유하지 않는 이유",
kind: "Case",
topic: "Redis",
project: "Backend Skeleton",
published: "2026.08.07",
evidence: "정책과 저장 명령의 주인을 구분하기",
relations: [
"/projects/backend-skeleton",
"/projects/backend-skeleton/decisions#storage-port-unification",
],
},
{
routeId: "TECH_LOG_REFERENCE" as const,
path: "/references/state-and-nonce-boundary",
title: "Authorization Code Flow에서 state와 nonce의 경계",
kind: "Reference",
topic: "Authentication",
project: "Auth Lab",
published: "2026.08.09",
evidence: "state는 요청과 콜백을 연결합니다",
relations: [
"/questions/validate-edge-token-again",
"/projects/auth-lab",
],
},
{
routeId: "TECH_LOG_REFERENCE" as const,
path: "/references/jpa-list-fetch-strategy",
title: "JPA 목록 조회에서 Fetch 전략을 선택하는 기준",
kind: "Reference",
topic: "JPA",
project: "Backend Skeleton",
published: "2026.08.10",
evidence: "부모 페이지 경계를 먼저 고정합니다",
relations: [
"/cases/collection-fetch-join-pagination",
"/questions/collection-fetch-join-with-pagination",
],
},
{
routeId: "TECH_LOG_QUESTION" as const,
path: "/questions/validate-edge-token-again",
title: "oauth2-proxy가 전달한 토큰을 다시 검증해야 하는가?",
kind: "Open Question",
topic: "Authentication",
project: "Auth Lab",
published: "2026.08.08",
evidence: "토큰 전달안과 신뢰 헤더안을 위협 모델로 비교하고",
relations: [
"/references/state-and-nonce-boundary",
"/projects/auth-lab/activity#edge-trust-boundary",
],
},
{
routeId: "TECH_LOG_QUESTION" as const,
path: "/questions/collection-fetch-join-with-pagination",
title: "컬렉션 Fetch Join을 유지하면서 페이징할 수 있는가?",
kind: "Open Question",
topic: "JPA",
project: "Backend Skeleton",
published: "2026.08.05",
evidence: "해결 과정을 Case로 읽기",
relations: [
"/cases/collection-fetch-join-pagination",
"/references/jpa-list-fetch-strategy",
],
},
])(
"renders $path with exact metadata, evidence, and ordered relations",
async ({ routeId, path, title, kind, topic, project, published, evidence, relations }) => {
const { container } = await renderDocumentRoute(routeId, path);
const main = screen.getByRole("main");
expect(within(main).getByRole("heading", { level: 1, name: title })).toBeVisible();
/*
목차가 본문의 절 제목을 그대로 다시 적으므로 같은 글이 두 자리에 나온다. 여기서 확인하려는
것은 "그 글이 문서에 있는가" 이므로 첫 자리로 충분하다.
*/
const [firstEvidence] = within(main).getAllByText(evidence, { exact: false });
expect(firstEvidence).toBeVisible();
expect(within(main).getByRole("navigation", { name: "문서 경로" })).toHaveTextContent(
`${kind}/${topic}/${project}`,
);
/*
Case 는 한 배치를 쓴다. 예전에는 픽스처 문서 하나만 `.case-meta` 를 받고 나머지 Case 는
`.public-document-header dl` 로 떨어졌으므로 여기도 경로로 갈랐다. 이제 유형으로 가른다.
*/
if (kind === "Case") {
expect(container.querySelector(".case-meta")).toHaveTextContent(`게시 ${published}`);
} else {
const metadata = container.querySelector(".public-document-header dl");
expect(metadata).toHaveTextContent(`유형${kind}`);
expect(metadata).toHaveTextContent(`프로젝트${project}`);
expect(metadata).toHaveTextContent(`게시${published}`);
}
expect(
Array.from(container.querySelectorAll(".document-relations li a"), (link) =>
link.getAttribute("href"),
),
).toEqual(relations);
},
);
it("preserves the specialized Fetch Join layout, TOC, rendered blocks, anchors, and evidence media", async () => {
const { container } = await renderDocumentRoute(
"TECH_LOG_CASE",
"/cases/collection-fetch-join-pagination",
);
const main = screen.getByRole("main");
expect(main).toHaveClass("case-page");
expect(main.querySelector("header.shell.case-header")).not.toBeNull();
expect(screen.getByRole("region", { name: "문제와 결론" })).toHaveTextContent(
"FeedItem 20건을 요청했지만",
);
expect(screen.getByText("Fetch 전략별 페이징 경계 관찰")).toBeVisible();
expect(screen.getByText("firstResult/maxResults specified with collection fetch; applying in memory")).toBeVisible();
expect(screen.getAllByText("MultipleBagFetchException")).toHaveLength(2);
expect(screen.getByRole("link", { name: "관찰한 값 바로가기" })).toHaveAttribute(
"href",
"#observed-values",
);
expect(screen.getAllByRole("navigation", { name: "문서 목차" })).toHaveLength(1);
expect(container.querySelectorAll('[aria-label="코드 복사"]')).toHaveLength(3);
const image = screen.getByRole("img", {
name: "Fetch Join은 전체 조인 결과를 읽은 뒤 메모리에서 20개를 고르고, Batch Fetch는 부모 20개를 먼저 고른 뒤 해당 ID의 컬렉션만 조회한다.",
});
expect(image).toHaveAttribute("src", "/media/fetch-strategy-boundary.svg");
expect(image).toHaveAttribute("width", "1080");
expect(image).toHaveAttribute("height", "420");
expect(image).toHaveAttribute("loading", "lazy");
});
it("uses the one Case layout and omits source relations for the canonical empty state", async () => {
const generic = await renderDocumentRoute(
"TECH_LOG_CASE",
"/cases/redis-adapter-ttl-boundary",
);
expect(screen.getByRole("main")).toHaveClass("case-page");
expect(generic.container.querySelector("#ownership")).toHaveTextContent(
"정책과 저장 명령의 주인을 구분하기",
);
generic.unmount();
const empty = await renderDocumentRoute(
"TECH_LOG_CASE",
"/cases/collection-fetch-join-pagination?state=relations-empty",
);
expect(empty.container.querySelector(".document-relations")).toBeNull();
expect(screen.queryByText("Explicit relations")).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "이 기록의 연결" })).not.toBeInTheDocument();
});
it("preserves Reference rule order and resolved Question evidence and empty copy", async () => {
const reference = await renderDocumentRoute(
"TECH_LOG_REFERENCE",
"/references/state-and-nonce-boundary",
);
expect(
Array.from(reference.container.querySelectorAll(".reference-rules h3"), (heading) =>
heading.textContent,
),
).toEqual([
"state는 요청과 콜백을 연결합니다",
"nonce는 인증 결과와 ID Token을 연결합니다",
"PKCE는 Code를 교환할 클라이언트를 증명합니다",
]);
expect(screen.getByText("마지막 검증 2026.08.09")).toBeVisible();
reference.unmount();
await renderDocumentRoute(
"TECH_LOG_QUESTION",
"/questions/collection-fetch-join-with-pagination",
);
expect(screen.getByText("RESOLVED")).toHaveClass(
"question-status",
"question-status--resolved",
);
expect(screen.getByText("해결 과정에서 남은 미지수가 없습니다.")).toBeVisible();
expect(screen.getByRole("link", { name: "해결 과정을 Case로 읽기" })).toHaveAttribute(
"href",
"/cases/collection-fetch-join-pagination",
);
});
});
describe("TechLog topics and Public not-found routing", () => {
it.each([
["jpa", "JPA", "목록 조회, 연관 로딩과 페이지 경계를 함께 검증한 기록입니다.", "3개의 관련 기록"],
["authentication", "Authentication", "브라우저와 Edge, Resource Server 사이의 인증 책임과 신뢰 경계를 검증한 기록입니다.", "2개의 관련 기록"],
["redis", "Redis", "애플리케이션 정책과 Redis 저장 명령의 책임 경계를 검증한 기록입니다.", "1개의 관련 기록"],
])("aggregates /topics/%s with exact copy and count", async (slug, title, description, count) => {
await renderDocumentRoute("TECH_LOG_TOPIC", `/topics/${slug}`);
expect(screen.getByRole("heading", { level: 1, name: title })).toBeVisible();
expect(screen.getByText(description)).toBeVisible();
expect(screen.getByText(count)).toBeVisible();
});
it("keeps JPA topic records in canonical published order", async () => {
const { container } = await renderDocumentRoute("TECH_LOG_TOPIC", "/topics/jpa");
expect(
Array.from(container.querySelectorAll(".public-record-list > li > a"), (link) =>
link.getAttribute("href"),
),
).toEqual([
"/cases/collection-fetch-join-pagination",
"/references/jpa-list-fetch-strategy",
"/questions/collection-fetch-join-with-pagination",
]);
});
it.each([
["TECH_LOG_CASE" as const, "/cases/not-registered"],
["TECH_LOG_REFERENCE" as const, "/references/not-registered"],
["TECH_LOG_QUESTION" as const, "/questions/not-registered"],
["TECH_LOG_TOPIC" as const, "/topics/not-registered"],
])("keeps the unreachable client fallback accessible for %s", async (routeId, path) => {
const { container, router } = await renderDocumentRoute(routeId, path);
expect(router.state.location.pathname).toBe(path);
expect(
await screen.findByRole("heading", { name: "페이지를 찾을 수 없습니다." }),
).toBeVisible();
expect(screen.queryByRole("heading", { name: "화면을 표시하지 못했습니다." })).not.toBeInTheDocument();
expect(container.querySelector(".site-frame")).not.toBeNull();
});
});