diff --git a/src/features/tech-log/adapters/http/http-public-content-gateway.ts b/src/features/tech-log/adapters/http/http-public-content-gateway.ts index a993965..390c3f7 100644 --- a/src/features/tech-log/adapters/http/http-public-content-gateway.ts +++ b/src/features/tech-log/adapters/http/http-public-content-gateway.ts @@ -25,6 +25,7 @@ import { releaseDetailToRelease, searchItemToEntity, } from "./public-content-mapping.ts"; +import type { components } from "../../contracts/public/generated.ts"; import type { StudioOperationExecutor } from "./http-studio-gateway.ts"; const ROUTE_ID = "TECH_LOG_PUBLIC"; @@ -234,13 +235,19 @@ export function createHttpPublicContentGateway( } const body = (detail.question as Readonly>) ?? {}; - const points = (body.points as readonly Readonly>[] | undefined) ?? []; - const pointsOf = (group: string) => - Object.freeze( - points - .filter((point) => point.group === group) - .flatMap((point) => (point.items as readonly string[] | undefined) ?? []), - ); + /* + `points` 는 그룹 이름을 키로 갖는 객체다 — 계약의 `QuestionPointGroup`. 여기서는 + `{group, items}` 배열로 읽으면서 `.filter` 를 불렀고, 객체에는 그런 것이 없으니 상세 + 화면이 통째로 「요청을 처리하지 못했습니다」가 됐다. 목록은 이 칸을 비워 두고 만들기 + 때문에 탐색에서는 멀쩡히 보였고, 그래서 "게시했는데 안 뜬다" 로만 드러났다. + + `as` 캐스트가 그 어긋남을 타입 검사에서 가렸다. 계약의 타입을 그대로 쓰면 다음에 모양이 + 바뀔 때 컴파일이 먼저 막는다. + */ + type QuestionPoints = components["schemas"]["QuestionPointGroup"]; + const points = body.points as QuestionPoints | undefined; + const pointsOf = (group: keyof QuestionPoints) => + Object.freeze([...(points?.[group] ?? [])].map(String)); return Object.freeze({ ...baseOf("QUESTION", slug, { title: body.question as string, @@ -249,18 +256,33 @@ export function createHttpPublicContentGateway( primaryTopic: body.primaryTopic as never, primaryProject: body.primaryProject as never, publishedAt: body.updatedAt as string, - relations: flattenRelations(groups, { - derivedCases: "이 질문에서 나온 기록", - projectDecisions: "이 질문이 이끈 결정", - relatedQuestions: "관련 질문", - }), + /* + 계약이 주는 이름은 `resultCase` / `producedDecision` / `derivedReferences` 다. + 여기서는 `derivedCases` / `projectDecisions` / `relatedQuestions` 를 찾고 있었고, + 하나도 맞지 않아 이유 자리에 영문 키가 그대로 나왔다. + + `primaryProject` 는 관계가 아니라 이 질문이 속한 프로젝트다 — 머리말이 이미 + 보여 주므로 관계 목록에 넣지 않는다. + */ + relations: flattenRelations( + { + resultCase: groups.resultCase, + producedDecision: groups.producedDecision, + derivedReferences: groups.derivedReferences, + }, + { + resultCase: "이 질문에서 나온 기록", + producedDecision: "이 질문이 이끈 결정", + derivedReferences: "이 질문에서 정리된 기준", + }, + ), }), kind: "QUESTION", questionStatus: (body.status as QuestionRecord["questionStatus"]) ?? "OPEN", - facts: pointsOf("KNOWN_FACT"), - assumptions: pointsOf("ASSUMPTION"), - unknowns: pointsOf("UNRESOLVED"), - constraints: pointsOf("CONSTRAINT"), + facts: pointsOf("facts"), + assumptions: pointsOf("assumptions"), + unknowns: pointsOf("unknowns"), + constraints: pointsOf("constraints"), options: Object.freeze([]), nextValidation: (body.nextVerification as string) ?? "", }) as unknown as Extract; diff --git a/tests/features/tech-log/public-question-mapping.test.ts b/tests/features/tech-log/public-question-mapping.test.ts new file mode 100644 index 0000000..c356841 --- /dev/null +++ b/tests/features/tech-log/public-question-mapping.test.ts @@ -0,0 +1,127 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; + +import type { components } from "../../../src/features/tech-log/contracts/public/generated.ts"; +import { createHttpPublicContentGateway } from "../../../src/features/tech-log/adapters/http/http-public-content-gateway.ts"; + +type QuestionDetail = components["schemas"]["QuestionDetailResponse"]; + +/* + Open Question 을 게시했는데 공개 상세가 「요청을 처리하지 못했습니다」만 띄웠다. + + 게이트웨이가 `points` 를 `{group, items}` 배열로 읽고 `.filter` 를 불렀는데, 계약의 + `QuestionPointGroup` 은 그룹 이름을 키로 갖는 **객체**다. 객체에는 `.filter` 가 없으므로 + 매핑이 통째로 터졌다. `as` 캐스트가 그 어긋남을 타입 검사에서 가렸다. + + 탐색 목록은 이 칸들을 빈 배열로 두고 만들기 때문에 목록에서는 멀쩡히 보였다 — 그래서 + "게시했는데 public 에 안 뜬다" 로만 드러났고, 어느 층이 깨졌는지는 보이지 않았다. + + 그래서 여기서는 계약 모양 그대로의 응답을 진짜 게이트웨이에 통과시키고, 화면이 읽는 네 칸이 + 실제로 채워져 나오는지 묻는다. 모양만 검사하면 이 사고는 다시 지나간다. +*/ +function gatewayReturning(value: unknown) { + return createHttpPublicContentGateway({ + operations: { + execute: () => + Promise.resolve({ + kind: "SUCCESS" as const, + value, + metadata: { status: 200 }, + effect: "NOT_APPLICABLE" as const, + }), + }, + }); +} + +const DETAIL = { + canonicalPath: "/questions/refresh-rotation-replica-contention", + indexable: true, + question: { + question: "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가", + summary: "두 replica가 같은 refresh token으로 동시에 갱신할 수 있다.", + context: "", + importance: "", + status: "OPEN", + nextVerification: "저장소를 공유한 뒤에 재현한다.", + points: { + facts: ["realm은 refresh token rotation과 재사용 허용 0회를 쓴다."], + assumptions: ["운영에서는 replica가 둘 이상이고 저장소를 공유한다."], + unknowns: ["같은 refresh token으로 동시에 갱신하면 어떻게 되는지."], + constraints: ["이미 발급된 access token은 만료 전까지 계속 통한다."], + }, + updates: [], + openedAt: "2026-08-24T00:00:00.000Z", + updatedAt: "2026-08-26T13:41:53.974512Z", + }, + relations: { + primaryProject: { + type: "PROJECT", + title: "KeyCloak Patterns", + path: "/projects/keycloak-patterns", + summary: "Keycloak을 쓰면서 실제로 부딪힌 인증 경계를 기록합니다", + }, + resultCase: { + type: "CASE", + title: "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출", + path: "/cases/split-custody-access-token", + summary: "토큰 교환과 토큰 관리의 책임이 서버로 이전했다.", + }, + derivedReferences: [], + }, +} satisfies QuestionDetail; + +test("a published Open Question maps its four point groups into the screen's fields", async () => { + const gateway = gatewayReturning(DETAIL); + + const record = await gateway.getRecord("QUESTION", "refresh-rotation-replica-contention"); + + assert.ok(record, "게시된 질문은 상세로 돌아와야 한다"); + assert.equal(record.kind, "QUESTION"); + assert.equal(record.title, DETAIL.question.question); + assert.equal(record.questionStatus, "OPEN"); + assert.deepEqual([...record.facts], DETAIL.question.points.facts); + assert.deepEqual([...record.assumptions], DETAIL.question.points.assumptions); + assert.deepEqual([...record.unknowns], DETAIL.question.points.unknowns); + assert.deepEqual([...record.constraints], DETAIL.question.points.constraints); + assert.equal(record.nextValidation, DETAIL.question.nextVerification); +}); + +/* + 관계의 이유 자리에 영문 키가 그대로 나오고 있었다 — 매퍼가 찾던 그룹 이름이 계약에 없는 + 것들이었기 때문이다. 그리고 `primaryProject` 는 관계가 아니라 이 질문이 속한 프로젝트다. +*/ +test("a question's relations read their reason from the contract's own group names", async () => { + const gateway = gatewayReturning(DETAIL); + + const record = await gateway.getRecord("QUESTION", "refresh-rotation-replica-contention"); + + assert.deepEqual( + [...record!.relations].map((relation) => ({ reason: relation.reason, path: relation.path })), + [ + { + reason: "이 질문에서 나온 기록", + path: "/cases/split-custody-access-token", + }, + ], + ); +}); + +/* + 네 그룹은 계약이 필수로 두고 있다. 이름이 하나라도 바뀌면 위 `satisfies` 가 먼저 깨지고, + 화면이 읽는 이름과의 대응도 여기서 끊긴다. +*/ +test("the contract names the four point groups the screen renders", () => { + const points: components["schemas"]["QuestionPointGroup"] = { + facts: [], + assumptions: [], + unknowns: [], + constraints: [], + }; + + assert.deepEqual(Object.keys(points).sort(), [ + "assumptions", + "constraints", + "facts", + "unknowns", + ]); +});