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); // 질문 상세는 프로젝트를 `relations` 에 담는다 — `question` 에서 찾으면 머리말의 프로젝트 // 칸이 늘 비어 있다. assert.equal(record.projectTitle, "KeyCloak Patterns"); assert.equal(record.projectSlug, "keycloak-patterns"); }); /* 관계의 이유 자리에 영문 키가 그대로 나오고 있었다 — 매퍼가 찾던 그룹 이름이 계약에 없는 것들이었기 때문이다. 그리고 `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", ]); });