feat: port TechLog document screens
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
// @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 {
|
||||
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 { 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 NotFoundPage from "../../../src/presentation/pages/not-found-page.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";
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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().input;
|
||||
const view = render(
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
featureInputs: { "tech-log": techLog },
|
||||
})}
|
||||
>
|
||||
<RouterProvider router={router} />
|
||||
</ApplicationProvider>,
|
||||
);
|
||||
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",
|
||||
({ routeId, path, title, kind, topic, project, published, evidence, relations }) => {
|
||||
const { container } = 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", () => {
|
||||
const { container } = 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", () => {
|
||||
const generic = 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 = 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", () => {
|
||||
const reference = 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();
|
||||
|
||||
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", (slug, title, description, count) => {
|
||||
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", () => {
|
||||
const { container } = 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"],
|
||||
])("uses the registered in-shell Public not-found for %s", (routeId, path) => {
|
||||
const { container, router } = renderDocumentRoute(routeId, path);
|
||||
|
||||
expect(router.state.location.pathname).toBe(path);
|
||||
expect(screen.getByRole("heading", { name: "페이지를 찾을 수 없습니다." })).toBeVisible();
|
||||
expect(screen.queryByRole("heading", { name: "화면을 표시하지 못했습니다." })).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".site-frame")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user