fix: 게시된 Open Question 의 공개 상세가 열리게 한다
게시한 질문의 공개 상세가 「요청을 처리하지 못했습니다」만 띄웠다. 게이트웨이가 `points`
를 `{group, items}` 배열로 읽고 `.filter` 를 불렀는데, 계약의 `QuestionPointGroup` 은
`facts`/`assumptions`/`unknowns`/`constraints` 를 키로 갖는 객체다. 객체에는 `.filter` 가
없으니 매핑이 통째로 터졌다.
목록은 이 칸들을 빈 배열로 두고 만들기 때문에 탐색에서는 멀쩡히 보였다. 그래서 "게시했는데
public 에 안 뜬다" 로만 드러났고 어느 층이 깨졌는지는 보이지 않았다. `as` 캐스트가 그
어긋남을 타입 검사에서 가렸다 — 이제 계약의 타입을 그대로 써서 모양이 바뀌면 컴파일이
먼저 막는다.
관계도 같은 종류로 어긋나 있었다. 계약이 주는 이름은 `resultCase`/`producedDecision`/
`derivedReferences` 인데 매퍼는 `derivedCases`/`projectDecisions`/`relatedQuestions` 를
찾고 있었고, 하나도 맞지 않아 이유 자리에 영문 키가 그대로 나왔다. `primaryProject` 는
관계가 아니라 이 질문이 속한 프로젝트이므로 관계 목록에서 뺀다 — 머리말이 이미 보여 준다.
이 사고가 지나간 이유는 HTTP 게이트웨이의 질문 상세 매핑을 지나는 테스트가 없었기
때문이다. 화면 테스트는 정적 픽스처 어댑터를 쓰므로 계약 모양을 한 번도 통과시키지
않는다. 계약 모양 그대로의 응답을 진짜 게이트웨이에 넣고 네 칸이 채워져 나오는지 묻는
테스트를 넣는다 — 되돌려 보면 운영에서 난 것과 같은 `points.filter is not a function`
으로 실패한다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
This commit is contained in:
co-authored by
Claude Opus 5
parent
344a163d84
commit
ab4d822956
@@ -25,6 +25,7 @@ import {
|
|||||||
releaseDetailToRelease,
|
releaseDetailToRelease,
|
||||||
searchItemToEntity,
|
searchItemToEntity,
|
||||||
} from "./public-content-mapping.ts";
|
} from "./public-content-mapping.ts";
|
||||||
|
import type { components } from "../../contracts/public/generated.ts";
|
||||||
import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
|
import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
|
||||||
|
|
||||||
const ROUTE_ID = "TECH_LOG_PUBLIC";
|
const ROUTE_ID = "TECH_LOG_PUBLIC";
|
||||||
@@ -234,13 +235,19 @@ export function createHttpPublicContentGateway(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = (detail.question as Readonly<Record<string, unknown>>) ?? {};
|
const body = (detail.question as Readonly<Record<string, unknown>>) ?? {};
|
||||||
const points = (body.points as readonly Readonly<Record<string, unknown>>[] | undefined) ?? [];
|
/*
|
||||||
const pointsOf = (group: string) =>
|
`points` 는 그룹 이름을 키로 갖는 객체다 — 계약의 `QuestionPointGroup`. 여기서는
|
||||||
Object.freeze(
|
`{group, items}` 배열로 읽으면서 `.filter` 를 불렀고, 객체에는 그런 것이 없으니 상세
|
||||||
points
|
화면이 통째로 「요청을 처리하지 못했습니다」가 됐다. 목록은 이 칸을 비워 두고 만들기
|
||||||
.filter((point) => point.group === group)
|
때문에 탐색에서는 멀쩡히 보였고, 그래서 "게시했는데 안 뜬다" 로만 드러났다.
|
||||||
.flatMap((point) => (point.items as readonly string[] | undefined) ?? []),
|
|
||||||
);
|
`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({
|
return Object.freeze({
|
||||||
...baseOf("QUESTION", slug, {
|
...baseOf("QUESTION", slug, {
|
||||||
title: body.question as string,
|
title: body.question as string,
|
||||||
@@ -249,18 +256,33 @@ export function createHttpPublicContentGateway(
|
|||||||
primaryTopic: body.primaryTopic as never,
|
primaryTopic: body.primaryTopic as never,
|
||||||
primaryProject: body.primaryProject as never,
|
primaryProject: body.primaryProject as never,
|
||||||
publishedAt: body.updatedAt as string,
|
publishedAt: body.updatedAt as string,
|
||||||
relations: flattenRelations(groups, {
|
/*
|
||||||
derivedCases: "이 질문에서 나온 기록",
|
계약이 주는 이름은 `resultCase` / `producedDecision` / `derivedReferences` 다.
|
||||||
projectDecisions: "이 질문이 이끈 결정",
|
여기서는 `derivedCases` / `projectDecisions` / `relatedQuestions` 를 찾고 있었고,
|
||||||
relatedQuestions: "관련 질문",
|
하나도 맞지 않아 이유 자리에 영문 키가 그대로 나왔다.
|
||||||
}),
|
|
||||||
|
`primaryProject` 는 관계가 아니라 이 질문이 속한 프로젝트다 — 머리말이 이미
|
||||||
|
보여 주므로 관계 목록에 넣지 않는다.
|
||||||
|
*/
|
||||||
|
relations: flattenRelations(
|
||||||
|
{
|
||||||
|
resultCase: groups.resultCase,
|
||||||
|
producedDecision: groups.producedDecision,
|
||||||
|
derivedReferences: groups.derivedReferences,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resultCase: "이 질문에서 나온 기록",
|
||||||
|
producedDecision: "이 질문이 이끈 결정",
|
||||||
|
derivedReferences: "이 질문에서 정리된 기준",
|
||||||
|
},
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
kind: "QUESTION",
|
kind: "QUESTION",
|
||||||
questionStatus: (body.status as QuestionRecord["questionStatus"]) ?? "OPEN",
|
questionStatus: (body.status as QuestionRecord["questionStatus"]) ?? "OPEN",
|
||||||
facts: pointsOf("KNOWN_FACT"),
|
facts: pointsOf("facts"),
|
||||||
assumptions: pointsOf("ASSUMPTION"),
|
assumptions: pointsOf("assumptions"),
|
||||||
unknowns: pointsOf("UNRESOLVED"),
|
unknowns: pointsOf("unknowns"),
|
||||||
constraints: pointsOf("CONSTRAINT"),
|
constraints: pointsOf("constraints"),
|
||||||
options: Object.freeze([]),
|
options: Object.freeze([]),
|
||||||
nextValidation: (body.nextVerification as string) ?? "",
|
nextValidation: (body.nextVerification as string) ?? "",
|
||||||
}) as unknown as Extract<PublicRecord, { kind: K }>;
|
}) as unknown as Extract<PublicRecord, { kind: K }>;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user