// @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 { 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"; 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; 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: ( ), STUDIO: , }, "task-8-test-build", routeCodecs, ), { initialEntries: [initialEntry] }, ); const techLog = createTechLogFeatureInstalledInput().input; const view = render( , ); 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 source production plain 404 for %s", async (routeId, path) => { const { container, router } = renderDocumentRoute(routeId, path); expect(router.state.location.pathname).toBe(path); expect(await screen.findByText("Not Found", { exact: true })).toBeVisible(); expect(screen.queryByRole("heading", { name: "화면을 표시하지 못했습니다." })).not.toBeInTheDocument(); expect(container.querySelector(".site-frame")).toBeNull(); }); });