diff --git a/src/features/tech-log/presentation/public/components/case-document-page.tsx b/src/features/tech-log/presentation/public/components/case-document-page.tsx new file mode 100644 index 0000000..b204eca --- /dev/null +++ b/src/features/tech-log/presentation/public/components/case-document-page.tsx @@ -0,0 +1,184 @@ +import type { CaseRecord } from "../../../application/ports/public-content-queries.ts"; +import type { components } from "../../../contracts/studio/generated.ts"; +import { parseCaseContent } from "../../../domain/content-format/parse-case-content.ts"; +import type { EvidenceAsset } from "../../../domain/public-render-content.ts"; +import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx"; +import { publicRenderModelBase } from "./public-document-header.tsx"; + +const failedQueryCode = `@Query(""" + select distinct fi + from FeedItem fi + join fetch fi.user + join fetch fi.page + left join fetch fi.highlights h + where fi.visibility = :visibility + order by fi.firstHighlightedAt desc, fi.id desc + """) +List findFeed( + @Param("visibility") Visibility visibility, + Pageable pageable +);`; + +const splitQueryCode = `Page page = feedItemQuery.findPage( + Visibility.PUBLIC, + PageRequest.of(0, 20, Sort.by( + Sort.Order.desc("firstHighlightedAt"), + Sort.Order.desc("id") + )) +); + +List feedItemIds = page.getContent().stream() + .map(FeedItemRow::id) + .toList(); + +Map> highlights = + highlightQuery.findLatestByFeedItemIds(feedItemIds, 3);`; + +const fetchJoinBody = `## 문제를 고정하기 {#fix-the-problem} + +피드 목록에는 FeedItem과 작성자, 페이지, 하이라이트, 멘션이 함께 필요했다. 화면은 공개된 FeedItem을 최초 하이라이트 시각의 역순으로 20개씩 보여주고, 각 항목에는 최신 하이라이트를 최대 3개까지 붙인다. + +처음에는 한 번의 쿼리로 필요한 연관 데이터를 가져오면 N+1을 없앨 수 있다고 판단했다. \`user\`, \`page\`, \`highlights\`를 Fetch Join하고 \`PageRequest.of(0, 20)\`을 넘겼다. 반환값만 보면 기대한 20개가 나왔다. 이 결과만 확인하면 페이징도 동작하고 N+1도 사라진 것처럼 보인다. + +문제는 20개를 어디에서 잘랐는지였다. Hibernate 로그에는 다음 경고가 남았다. + +\`\`\`text label="Hibernate 경고" +firstResult/maxResults specified with collection fetch; applying in memory +\`\`\` + +데이터베이스가 20개를 고른 것이 아니었다. Join 결과를 읽고 Hibernate가 부모 엔티티를 복원한 다음, 메모리에서 FeedItem 20개만 남겼다. + +## 첫 번째 시도: 컬렉션 Fetch Join {#fetch-join-attempt} + +실험에 사용한 조회의 핵심 형태는 다음과 같다. + +\`\`\`java label="실패한 목록 조회" +${failedQueryCode} +\`\`\` + +\`distinct\`는 같은 FeedItem 객체가 결과 목록에 반복되는 문제를 줄인다. 그러나 데이터베이스가 읽는 물리적인 Join 행까지 20개로 줄이지는 않는다. FeedItem 하나에 Highlight가 여러 개면 부모 행이 자식 수만큼 반복된다. 여기에 Mention 같은 두 번째 컬렉션까지 함께 Fetch Join하면 행 수는 곱으로 증가하고, 두 컬렉션이 모두 bag이면 \`MultipleBagFetchException\`도 별도로 발생할 수 있다. + +이 Case에서 확인하려는 문제는 \`MultipleBagFetchException\` 자체가 아니다. 컬렉션 하나만 Fetch Join해도 목록 페이징이 데이터베이스에서 적용되지 않는다는 점이다. 두 문제는 원인과 해결 지점이 다르므로 같은 이름으로 묶지 않았다. + +## 관찰한 값 {#observed-values} + +테스트 데이터는 FeedItem 100개와 Zipf 형태로 편중된 Highlight·Mention으로 구성했다. 소수의 FeedItem에 자식이 몰리도록 해 평균값만으로 문제가 가려지지 않게 했다. + +:::table id="fetch-strategy-observation" caption="Fetch 전략별 페이징 경계 관찰" rowHeaderColumn="1" +| 전략 | 반환 FeedItem | DB LIMIT | 별도로 기록한 관찰값 | +| --- | ---: | :---: | --- | +| Collection Fetch Join + paging API | 20 | :status[없음]{tone="warning"} | Join 결과 행 1,961개 | +| Parent paging + Batch Fetch | 20 | :status[적용]{tone="evidence"} | Hibernate가 로드한 전체 엔티티 1,569개 | +::: + +두 수치는 같은 단위의 전후 비교값이 아니다. \`1,961\`은 Fetch Join 쿼리에서 관찰한 결과 행 수다. \`1,569\`는 Batch Fetch 실험에서 집계한 전체 엔티티 로드 수다. 두 번째 수치가 더 작다는 이유만으로 개선율을 계산하면 안 된다. 이 실험에서 확정할 수 있는 사실은 부모 목록 쿼리에 \`LIMIT 20\`이 적용됐고, 컬렉션을 부모 조회와 분리했다는 점이다. + +:::callout tone="warning" label="주의" +반환된 Java 목록의 크기가 20이라는 사실만으로 데이터베이스 페이징을 확인할 수 없다. 실행 SQL의 LIMIT, 전송 행 수, 로드 엔티티 수를 따로 기록해야 한다. +::: + +## 페이징과 컬렉션 로딩을 분리하기 {#separate-loading} + +최종 구조는 두 단계다. 먼저 목록 정렬에 필요한 부모를 데이터베이스에서 20개로 고정한다. 그다음 현재 페이지의 부모 ID에 대해서만 필요한 컬렉션을 가져온다. + +\`\`\`java label="부모 페이징과 연관 조회 분리" +${splitQueryCode} +\`\`\` + +첫 쿼리는 화면의 기준 목록과 페이지 경계를 책임진다. 두 번째 조회는 그 페이지를 꾸미는 연관 데이터만 책임진다. Hibernate Batch Fetch를 사용한다면 LAZY 컬렉션을 현재 페이지의 부모 키 \`IN (...)\`으로 묶을 수 있다. 최신 3개처럼 부모별 제한이 필요하면 단순 Batch Fetch만으로는 부족하므로 Window Function이나 별도 Projection 쿼리가 필요하다. + +이 구조에서는 \`FeedItemRow\`가 목록의 기본 틀을 가진다. User와 Page처럼 목록에서 바로 비교할 값은 DTO Projection으로 읽는다. Highlight와 Mention은 현재 페이지의 ID 집합을 기준으로 별도 조회해 조립한다. FeedItem 엔티티 전체를 먼저 로드한 뒤 화면 DTO로 바꾸는 방식은 기본 경로로 사용하지 않는다. + +:::evidence key="fetch-strategy-boundary" alt="Fetch Join은 전체 조인 결과를 읽은 뒤 메모리에서 20개를 고르고, Batch Fetch는 부모 20개를 먼저 고른 뒤 해당 ID의 컬렉션만 조회한다." caption="페이징이 적용된 지점이 부모 조회 앞으로 이동한다." zoom="true" +::: + +## 왜 한 번의 쿼리를 포기했는가 {#give-up-one-query} + +쿼리 수만 보면 한 번의 Fetch Join이 가장 단순해 보인다. 목록에서는 쿼리 수보다 페이지 경계가 먼저다. 데이터베이스가 20개 부모를 확정하지 못하면 자식 분포가 바뀔 때마다 읽는 행 수와 메모리 사용량이 흔들린다. 반환 개수는 같아도 비용을 예측할 수 없다. + +분리 조회는 네트워크 왕복을 하나 이상 추가한다. 대신 각 쿼리의 책임과 최대 범위를 설명할 수 있다. + +1. 부모 목록 쿼리는 정렬과 \`LIMIT 20\`을 보장한다. +2. 연관 조회는 부모 ID 20개 안에서만 실행한다. +3. 부모별 최신 3개 제한은 쿼리에서 명시한다. +4. 화면 조립 단계는 누락된 연관 데이터를 빈 목록으로 처리한다. + +이 경계 덕분에 페이지 크기, 연관 데이터 상한, 정렬 인덱스를 각각 검증할 수 있다. + +## 남은 비용과 적용 범위 {#remaining-cost} + +Batch Fetch는 컬렉션 N+1을 줄이지만 필요한 자식만 자동으로 골라 주지는 않는다. 한 FeedItem에 Highlight가 매우 많다면 현재 페이지의 모든 Highlight가 로드될 수 있다. \`최신 3개\`가 계약이면 부모별 제한 쿼리를 별도로 두어야 한다. + +또한 첫 목록 쿼리의 정렬이 느리면 Fetch 전략을 바꿔도 전체 응답은 느리다. \`visibility\`, \`firstHighlightedAt\`, \`id\`의 필터·정렬 순서에 맞는 인덱스와 실행 계획을 따로 확인해야 한다. 이 Case는 컬렉션 로딩 경계만 결정하며 인덱스 설계의 결론을 대신하지 않는다. + +> 컬렉션이 포함된 목록에서는 부모 페이지를 먼저 데이터베이스에서 고정한다. 연관 데이터는 현재 페이지의 부모 키로 제한해 별도 조회한다. 반환 개수만 보지 말고 SQL LIMIT, 전송 행, 로드 엔티티를 각각 측정한다.`; + +function genericCaseBlocks( + record: CaseRecord, +): components["schemas"]["CaseRenderBlock"][] { + return record.sections.flatMap((section) => { + const blocks: components["schemas"]["CaseRenderBlock"][] = [ + { + type: "HEADING", + id: section.id, + level: 2, + content: [{ type: "TEXT", text: section.title }], + }, + ...section.paragraphs.map( + (paragraph): components["schemas"]["CaseRenderBlock"] => ({ + type: "PARAGRAPH", + content: [{ type: "TEXT", text: paragraph }], + }), + ), + ]; + if (section.bullets) { + blocks.push({ + type: "UNORDERED_LIST", + items: section.bullets.map((text, index) => ({ + id: `${section.id}-item-${index + 1}`, + content: [{ type: "TEXT", text }], + })), + }); + } + return blocks; + }); +} + +function resolvePublicEvidenceAsset(key: string): EvidenceAsset { + if (key !== "fetch-strategy-boundary") { + throw new Error(`Unknown local evidence asset: ${key}`); + } + return { + src: "/media/fetch-strategy-boundary.svg", + width: 1080, + height: 420, + triggerLabel: "Fetch Join과 Batch Fetch 비교 다이어그램 크게 보기", + dialogLabel: "Fetch Join과 Batch Fetch의 페이징 경계 확대", + }; +} + +export function CaseDocumentPage({ record }: { record: CaseRecord }) { + const model: components["schemas"]["CasePublicRenderModel"] = { + ...publicRenderModelBase(record), + kind: "CASE", + problem: record.problem, + conclusion: record.conclusion, + environment: record.environment, + reproduction: record.verification, + lastVerifiedOn: record.lastVerifiedLabel.replaceAll(".", "-"), + bodyBlocks: + record.slug === "collection-fetch-join-pagination" + ? parseCaseContent(fetchJoinBody) + : genericCaseBlocks(record), + }; + + return ( + + path === record.path ? record.publishedLabel : undefined + } + /> + ); +} diff --git a/src/features/tech-log/presentation/public/components/public-document-header.tsx b/src/features/tech-log/presentation/public/components/public-document-header.tsx new file mode 100644 index 0000000..e988cad --- /dev/null +++ b/src/features/tech-log/presentation/public/components/public-document-header.tsx @@ -0,0 +1,89 @@ +import { Link } from "react-router-dom"; + +import type { PublicRecord } from "../../../application/ports/public-content-queries.ts"; +import type { components } from "../../../contracts/studio/generated.ts"; + +const kindLabels = { + CASE: "Case", + REFERENCE: "Reference", + QUESTION: "Open Question", +} as const; + +export function PublicDocumentHeader({ record }: { record: PublicRecord }) { + return ( +
+ +

{record.title}

+

{record.summary}

+
+
+
유형
+
{kindLabels[record.kind]}
+
+
+
프로젝트
+
{record.projectTitle}
+
+
+
게시
+
{record.publishedLabel}
+
+
+
+ ); +} + +export function publicRenderModelBase( + record: PublicRecord, +): components["schemas"]["PublicRenderModelBase"] { + return { + kind: record.kind, + slug: record.slug, + title: record.title, + summary: record.summary, + publicPath: record.path, + topic: { + id: `topic-${record.topicSlug}`, + label: record.topic, + publicPath: `/topics/${record.topicSlug}`, + }, + project: { + id: `project-${record.projectSlug}`, + label: record.projectTitle, + publicPath: `/projects/${record.projectSlug}`, + }, + relations: record.relations.map((relation, index) => ({ + id: `public-relation-${index + 1}-${relation.path}`, + targetId: relation.path, + targetKind: relation.path.startsWith("/cases/") + ? "CASE" + : relation.path.startsWith("/references/") + ? "REFERENCE" + : relation.path.startsWith("/questions/") + ? "QUESTION" + : relation.path.includes("/decisions#") + ? "PROJECT_DECISION" + : "PROJECT", + title: relation.title, + publicPath: relation.path, + reason: relation.reason, + order: index + 1, + })), + renderContext: { + generatedAt: record.publishedAt, + dependencyRevision: "public-v1", + }, + }; +} diff --git a/src/features/tech-log/presentation/public/components/public-document-relations.tsx b/src/features/tech-log/presentation/public/components/public-document-relations.tsx new file mode 100644 index 0000000..5490788 --- /dev/null +++ b/src/features/tech-log/presentation/public/components/public-document-relations.tsx @@ -0,0 +1,4 @@ +export { + PublicDocumentRelations, + type PublicRelation, +} from "../../shared/public-render/public-document-relations.tsx"; diff --git a/src/features/tech-log/presentation/public/components/question-document-page.tsx b/src/features/tech-log/presentation/public/components/question-document-page.tsx new file mode 100644 index 0000000..1720192 --- /dev/null +++ b/src/features/tech-log/presentation/public/components/question-document-page.tsx @@ -0,0 +1,67 @@ +import type { QuestionRecord } from "../../../application/ports/public-content-queries.ts"; +import type { components } from "../../../contracts/studio/generated.ts"; +import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx"; +import { publicRenderModelBase } from "./public-document-header.tsx"; + +function orderedText( + record: QuestionRecord, + items: ReadonlyArray, + key: string, +): components["schemas"]["OrderedText"][] { + return items.map((text, index) => ({ + id: `${record.slug}-${key}-${index + 1}`, + text, + order: index + 1, + })); +} + +function questionStatus(record: QuestionRecord): "OPEN" | "RESOLVED" { + if (record.questionStatus === "OPEN" || record.questionStatus === "RESOLVED") { + return record.questionStatus; + } + throw new Error(`Unsupported public Question status: ${record.questionStatus}`); +} + +export function QuestionDocumentPage({ record }: { record: QuestionRecord }) { + const model: components["schemas"]["QuestionPublicRenderModel"] = { + ...publicRenderModelBase(record), + kind: "QUESTION", + status: questionStatus(record), + facts: orderedText(record, record.facts, "fact"), + assumptions: orderedText(record, record.assumptions, "assumption"), + unknowns: orderedText(record, record.unknowns, "unknown"), + constraints: orderedText(record, record.constraints, "constraint"), + options: record.options.map((option, index) => ({ + id: `${record.slug}-option-${index + 1}`, + title: option.title, + description: option.description, + order: index + 1, + })), + nextValidation: record.nextValidation, + resolution: record.resolution + ? { + summary: record.resolution.summary, + evidenceTarget: { + id: record.resolution.path, + label: record.resolution.linkLabel, + publicPath: record.resolution.path, + }, + linkLabel: record.resolution.linkLabel, + } + : null, + }; + + return ( + + path === record.path ? record.publishedLabel : undefined + } + /> + ); +} + +function missingQuestionEvidence(key: string): never { + throw new Error(`Question document cannot render evidence: ${key}`); +} diff --git a/src/features/tech-log/presentation/public/components/reference-document-page.tsx b/src/features/tech-log/presentation/public/components/reference-document-page.tsx new file mode 100644 index 0000000..3d6918f --- /dev/null +++ b/src/features/tech-log/presentation/public/components/reference-document-page.tsx @@ -0,0 +1,48 @@ +import type { ReferenceRecord } from "../../../application/ports/public-content-queries.ts"; +import type { components } from "../../../contracts/studio/generated.ts"; +import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx"; +import { publicRenderModelBase } from "./public-document-header.tsx"; + +function orderedText( + record: ReferenceRecord, + items: ReadonlyArray, + key: string, +): components["schemas"]["OrderedText"][] { + return items.map((text, index) => ({ + id: `${record.slug}-${key}-${index + 1}`, + text, + order: index + 1, + })); +} + +export function ReferenceDocumentPage({ record }: { record: ReferenceRecord }) { + const model: components["schemas"]["ReferencePublicRenderModel"] = { + ...publicRenderModelBase(record), + kind: "REFERENCE", + purpose: record.purpose, + rules: record.rules.map((rule, index) => ({ + id: `${record.slug}-rule-${index + 1}`, + title: rule.title, + body: rule.body, + order: index + 1, + })), + applyWhen: orderedText(record, record.applyWhen, "apply"), + exceptions: orderedText(record, record.exceptions, "exception"), + examples: orderedText(record, record.examples, "example"), + verifiedOn: record.verifiedAt, + }; + + return ( + + path === record.path ? record.publishedLabel : undefined + } + /> + ); +} + +function missingReferenceEvidence(key: string): never { + throw new Error(`Reference document cannot render evidence: ${key}`); +} diff --git a/src/features/tech-log/presentation/public/pages/case-page.tsx b/src/features/tech-log/presentation/public/pages/case-page.tsx new file mode 100644 index 0000000..d1d2cc4 --- /dev/null +++ b/src/features/tech-log/presentation/public/pages/case-page.tsx @@ -0,0 +1,31 @@ +import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; +import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; +import { + RegisteredNotFoundRoute, + useRouteInput, +} from "../../../../../presentation/routes/route-input.tsx"; +import { CaseDocumentPage } from "../components/case-document-page.tsx"; + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export function CasePage() { + const { params, search } = useRouteInput<"TECH_LOG_CASE">(); + const slug = optionalString(params.slug); + const requestedState = optionalString(search.state); + const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); + const record = slug ? publicContent.getRecord("CASE", slug) : undefined; + + if (!record) return ; + + return ( + + ); +} diff --git a/src/features/tech-log/presentation/public/pages/question-page.tsx b/src/features/tech-log/presentation/public/pages/question-page.tsx new file mode 100644 index 0000000..b1afc02 --- /dev/null +++ b/src/features/tech-log/presentation/public/pages/question-page.tsx @@ -0,0 +1,24 @@ +import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; +import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; +import { + RegisteredNotFoundRoute, + useRouteInput, +} from "../../../../../presentation/routes/route-input.tsx"; +import { QuestionDocumentPage } from "../components/question-document-page.tsx"; + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export function QuestionPage() { + const { params } = useRouteInput<"TECH_LOG_QUESTION">(); + const slug = optionalString(params.slug); + const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); + const record = slug ? publicContent.getRecord("QUESTION", slug) : undefined; + + return record ? ( + + ) : ( + + ); +} diff --git a/src/features/tech-log/presentation/public/pages/reference-page.tsx b/src/features/tech-log/presentation/public/pages/reference-page.tsx new file mode 100644 index 0000000..eb80a5d --- /dev/null +++ b/src/features/tech-log/presentation/public/pages/reference-page.tsx @@ -0,0 +1,24 @@ +import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; +import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; +import { + RegisteredNotFoundRoute, + useRouteInput, +} from "../../../../../presentation/routes/route-input.tsx"; +import { ReferenceDocumentPage } from "../components/reference-document-page.tsx"; + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export function ReferencePage() { + const { params } = useRouteInput<"TECH_LOG_REFERENCE">(); + const slug = optionalString(params.slug); + const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); + const record = slug ? publicContent.getRecord("REFERENCE", slug) : undefined; + + return record ? ( + + ) : ( + + ); +} diff --git a/src/features/tech-log/presentation/public/pages/topic-page.tsx b/src/features/tech-log/presentation/public/pages/topic-page.tsx new file mode 100644 index 0000000..cdcbf21 --- /dev/null +++ b/src/features/tech-log/presentation/public/pages/topic-page.tsx @@ -0,0 +1,59 @@ +import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; +import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; +import { + RegisteredNotFoundRoute, + useRouteInput, +} from "../../../../../presentation/routes/route-input.tsx"; +import { PublicRecordList } from "../components/public-record-list.tsx"; + +const topics = { + jpa: { + title: "JPA", + description: "목록 조회, 연관 로딩과 페이지 경계를 함께 검증한 기록입니다.", + }, + authentication: { + title: "Authentication", + description: + "브라우저와 Edge, Resource Server 사이의 인증 책임과 신뢰 경계를 검증한 기록입니다.", + }, + redis: { + title: "Redis", + description: + "애플리케이션 정책과 Redis 저장 명령의 책임 경계를 검증한 기록입니다.", + }, +} as const; + +function topicConfig(value: unknown) { + if ( + value === "jpa" || + value === "authentication" || + value === "redis" + ) { + return topics[value]; + } + return undefined; +} + +export function TopicPage() { + const { params } = useRouteInput<"TECH_LOG_TOPIC">(); + const topic = topicConfig(params.slug); + const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); + + if (!topic) return ; + + const records = publicContent.listRecords({ topic: topic.title }); + return ( +
+
+

Topic

+

{topic.title}

+

{topic.description}

+
+
+

관련 기록

+

{records.length}개의 관련 기록

+
+ +
+ ); +} diff --git a/tests/features/tech-log/public-document-screens.test.tsx b/tests/features/tech-log/public-document-screens.test.tsx new file mode 100644 index 0000000..755a27c --- /dev/null +++ b/tests/features/tech-log/public-document-screens.test.tsx @@ -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; + +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 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(); + }); +});