Files
tech-log-frontend/tests/features/tech-log/public-document-screens.test.tsx
T
DongHyeonka 11c2713139 feat: let Studio create the topics and projects publishing requires
Publishing needs a topic and nothing could create one. The backend now owns
that surface; this is its consumer — the management contract vendored, a
gateway over its nine operations, and one Studio screen that lists, creates,
and deletes topics and projects.

The screen adds no CSS. It reuses the classes the working-copy list already
uses, so it inherits Studio's spacing, type, and colour rather than growing a
second visual vocabulary beside them. Scope stops at list/create/delete:
renaming, phase changes, and visibility are implemented in the backend and
declared in the contract, but their screens are a separate design.

Two real defects surfaced while making the public port async, and both would
have shipped:

The search page and the header search dialog shared a query key. With an empty
query, `["tech-log","search",""]` was identical for both, so react-query
handed one surface the other's cache — different shapes — and the page died
reading a field that was not there. Keys now name the surface.

The explore filter's selects are uncontrolled and read `defaultValue`, which
React applies once. Their options arrive later now, so the first render had
nothing to match and the value stayed empty: a topic in the URL no longer
showed as selected. The form key includes whether the catalog has arrived, so
it remounts with the options present. Controlled inputs would be the other
answer, but this form submits to build a URL — the URL owns the value.

The route brought its own bookkeeping: a build chunk, a manual accessibility
evidence file, and the CI artifact baseline that counts them. The gate pins a
digest of its own shape precisely so a new route cannot slip in without that
count being reviewed.

Test harnesses that render public screens now assemble the query providers and
await the settled paint, because the screens they render became async.
2026-08-20 23:40:15 +09:00

357 lines
14 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();
expect(within(main).getByText(evidence, { exact: false })).toBeVisible();
expect(within(main).getByRole("navigation", { name: "문서 경로" })).toHaveTextContent(
`${kind}/${topic}/${project}`,
);
if (path === "/cases/collection-fetch-join-pagination") {
expect(within(main).getByText(`게시 ${published} · 마지막 검증 2026.08.11`)).toBeVisible();
} 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 generic Case markup 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("shell", "public-document-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();
});
});