Files
DongHyeonkaandClaude Opus 5 b3aa304975 feat: 프로젝트를 공개할 수 있게 하고, 홈이 무엇을 앞에 둘지 고를 수 있게 한다
공개 화면 다섯 곳이 조용히 비어 있었다. 원인은 하나씩 달랐지만 모두 "값을 채울
방법이 없었다"는 같은 모양이었다.

홈의 "지금 집중하는 것" — `home_focus_config` 는 마이그레이션이 빈 행 하나만
넣어 두었고, 계약에 선언된 `getHomeFocus`/`updateHomeFocus` 는 구현이 없었다.
세 슬롯이 모두 비면 홈은 그 영역을 아예 그리지 않으므로, 운영에서는 한 번도
나타난 적이 없다. Studio 대시보드에 고르는 화면을 둔다.

홈의 "최근 기록" — 화면이 공개된 프로젝트를 하나씩 돌며 타임라인을 조립했다.
그래서 게시한 문서라도 그 프로젝트가 공개되어 있지 않으면 목록에서 통째로
빠졌고, 실제로 릴리스 한 줄만 남았다. 무엇이 최근인지는 공개 투영이 이미 알고
있으므로 그것을 그대로 읽는다. 프로젝트마다 요청을 보내던 N+1 도 사라진다.

프로젝트 공개 — 프로젝트는 `RecordKind` 에 없어 문서 게시 파이프라인을 타지
못하는데, 공개 화면들(프로젝트 목록·프로필의 "현재 프로젝트"·홈 focus)은 전부
`public_resource_projection` 의 PROJECT 행을 가시성 관문으로 쓴다. 그 행을
세우는 경로가 없었으므로 프로젝트는 영원히 비공개였다. 계약에 이미 있던
`publishProject`/`unpublishProject` 를 구현하고 주제·프로젝트 화면에 버튼을 둔다.

문서 사이 관계 연결 — `JdbcCatalogQueryAdapter` 의 RELATION/EVIDENCE 가
`List.of()` 스텁이라 어떤 기록도 연결 대상 목록을 채울 수 없었다. RELATION 은
작성 중에 고르는 것이므로 작업본까지 포함하고, EVIDENCE 는 읽는 사람이 따라갈
수 있어야 하므로 공개된 것만 포함한다.

본문 너비 — 문서 한 편이 세 폭으로 갈라져 있었다. 머리말 920px, 유형·프로젝트
줄은 shell 전체 1180px, 본문은 672px 를 가운데 정렬. 셋을 같은 폭·같은 왼쪽
끝에 세우고 읽는 단을 56rem 으로 넓힌다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 17:38:49 +09:00

571 lines
18 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { isSupportedEvidenceKey } from "../../../src/features/tech-log/adapters/static/evidence-assets.ts";
import {
evidenceCatalogEntriesFromAssets,
supportsResolvableEvidenceKey,
} from "../../../src/features/tech-log/domain/content-format/asset-evidence-catalog.ts";
import {
ContentFormatError,
parseCaseContent,
} from "../../../src/features/tech-log/domain/content-format/parse-case-content.ts";
import {
evidenceCatalogEntryFor,
projectWorkingCopy,
} from "../../../src/features/tech-log/domain/content-format/project-public-render-model.ts";
import { serializeCaseContent } from "../../../src/features/tech-log/domain/content-format/serialize-case-content.ts";
import type { components } from "../../../src/features/tech-log/contracts/studio/generated.ts";
import type { Asset } from "../../../src/features/tech-log/contracts/studio/contract.ts";
/**
* Fix round 4. Calls the production composition itself
* (`supportsResolvableEvidenceKey`) rather than re-deriving it: every caller
* -- `validate-working-copy.ts`, `adapters/mock/project-public-render-model.ts`,
* `instant-preview.tsx` -- builds its key gate from this exact function, so a
* caller drifting away from it now shows up here too. The question is "does a
* resolvable Asset (or the legacy static key) back this", never "is there any
* EVIDENCE catalog row for it" -- that is the projection's separate catalog
* check against its own `catalog` argument. Tests below that pass no `assets`
* reduce to the legacy check alone.
*/
function supportsEvidenceKey(
key: string,
assets: readonly Asset[] = [],
): boolean {
return supportsResolvableEvidenceKey(assets, isSupportedEvidenceKey)(key);
}
const rich = `## 측정 결과 {#measurements}
:::table id="fetch-loss" caption="반환 손실" rowHeaderColumn="1"
| 상태 | 결과 |
| --- | ---: |
| before | :status[20건]{tone="warning"} |
:::
\`\`\`sql label="재현 쿼리"
select * from feed_item;
\`\`\``;
describe("Content Format v1", () => {
it("parses and serializes the rich source fixture byte-for-byte", () => {
const parsed = parseCaseContent(rich);
expect(serializeCaseContent(parsed)).toBe(rich);
expect(parseCaseContent(serializeCaseContent(parsed))).toEqual(parsed);
expect(parsed.map((block) => block.type)).toEqual([
"HEADING",
"DATA_TABLE",
"CODE_BLOCK",
]);
});
it("accepts the plain Markdown the server renderer already accepted", () => {
/*
이 문법들은 서버 렌더러가 원래 읽던 것인데 이 파서만 거절하고 있었다 — 같은 본문이 Studio
와 공개 화면에서 다르게 읽혔다. 표는 `:::table` 로 감싸지 않아도 되고, callout 이름은 서버가
아는 note/tip/warning/danger 를 쓴다.
*/
expect(parseCaseContent("| A | B |\n|---|---|\n| 1 | 2 |").map((block) => block.type)).toEqual([
"DATA_TABLE",
]);
expect(parseCaseContent(":::note\n\n참고할 것.\n\n:::").map((block) => block.type)).toEqual([
"CALLOUT",
]);
expect(parseCaseContent(":::danger\n\n위험.\n\n:::").map((block) => block.type)).toEqual([
"CALLOUT",
]);
expect(parseCaseContent("# 제목1\n\n###### 제목6").map((block) => block.type)).toEqual([
"HEADING",
"HEADING",
]);
expect(parseCaseContent("---").map((block) => block.type)).toEqual(["THEMATIC_BREAK"]);
expect(parseCaseContent("![대체](/api/v1/public/media/x)").map((block) => block.type)).toEqual([
"IMAGE",
]);
// 속성이 없는 directive 도 이름이 온전해야 한다. 정규화 정규식이 되돌아가며 마지막 글자를
// 속성 쪽으로 넘기는 바람에 `:::note` 가 `:::not{e}` 로 바뀌던 적이 있다.
expect(() => parseCaseContent(":::unknown\n\ntext\n\n:::")).toThrow(
/unknown block directive: unknown/,
);
});
it("rejects unsafe, raw HTML, and unsupported input with source positions", () => {
const rejected = [
"<script>alert(1)</script>",
"[x](javascript:alert(1))",
"[x](//evil.example/path)",
"[x](//techlog.invalid/path)",
"[x](///techlog.invalid/path)",
"[x](//user@techlog.invalid/path)",
"[x](/\\evil.example/path)",
"[x](/\\\\evil.example/path)",
"[x](</safe\u0001path>)",
"[x](</safe\u001fpath>)",
"[x](</safe\u007fpath>)",
"- outer\n - nested",
"- [ ] task",
"> > nested",
':::unknown key="value"\ntext\n:::',
':::evidence key="https://example.com/x.png" alt="x" caption="x" zoom="true"\n:::',
"![x](javascript:alert(1))",
"![x](//evil.example/x.png)",
];
for (const source of rejected) {
expect(() => parseCaseContent(source), source).toThrow(
/CONTENT_FORMAT_INVALID/,
);
}
try {
parseCaseContent("safe\n\n<script>alert(1)</script>");
throw new Error("expected malformed content to fail");
} catch (error) {
expect(error).toBeInstanceOf(ContentFormatError);
expect((error as ContentFormatError).issues).toEqual([
{
line: 3,
column: 1,
detail: "unsupported block syntax: html",
},
]);
}
});
it("keeps explicitly allowed links and same-origin paths", () => {
const [paragraph] = parseCaseContent(
"[fragment](#section) [path](/safe/path) [query](/safe/path?q=one) [http](http://example.com/path) [https](https://example.com/path) [mail](mailto:test@example.com)",
);
expect(paragraph?.type).toBe("PARAGRAPH");
if (paragraph?.type !== "PARAGRAPH") return;
expect(
paragraph.content
.filter((inline) => inline.type === "LINK")
.map((inline) => inline.href),
).toEqual([
"#section",
"/safe/path",
"/safe/path?q=one",
"http://example.com/path",
"https://example.com/path",
"mailto:test@example.com",
]);
});
it("generates stable Korean heading IDs and suffixes duplicates", () => {
const blocks = parseCaseContent("## 한글 API!\n\n## 한글 API?\n\n## !!!");
expect(
blocks.map((block) => (block.type === "HEADING" ? block.id : null)),
).toEqual(["한글-api", "한글-api-2", "section"]);
});
it("round trips every supported block and inline variant", () => {
const source = `## 본문 {#body}
일반 *강조* **강함** \`code\` [내부](/path) [메일](mailto:test@example.com)
> 한 문단 인용
- 첫째
- 둘째
1. 하나
2. 둘
:::callout tone="info" label="정보"
안전한 안내입니다.
:::
:::evidence key="fetch-strategy-boundary" alt="비교" caption="경계" zoom="false"
:::`;
const parsed = parseCaseContent(source);
expect(parseCaseContent(serializeCaseContent(parsed))).toEqual(parsed);
expect(parsed.map((block) => block.type)).toEqual([
"HEADING",
"PARAGRAPH",
"BLOCKQUOTE",
"UNORDERED_LIST",
"ORDERED_LIST",
"CALLOUT",
"EVIDENCE_FIGURE",
]);
});
it("parses a decorative evidence figure with an empty alt", () => {
const source =
':::evidence key="fetch-strategy-boundary" alt="" caption="" zoom="false"\n:::';
const [block] = parseCaseContent(source);
expect(block?.type).toBe("EVIDENCE_FIGURE");
if (block?.type !== "EVIDENCE_FIGURE") return;
expect(block.alt).toBe("");
});
it("round-trips an empty alt without reintroducing it as a parse error", () => {
const source =
':::evidence key="fetch-strategy-boundary" alt="" caption="" zoom="false"\n:::';
const parsed = parseCaseContent(source);
expect(() => parseCaseContent(serializeCaseContent(parsed))).not.toThrow();
});
it("still rejects an unsafe evidence key", () => {
expect(() =>
parseCaseContent(
':::evidence key="../etc" alt="x" caption="" zoom="false"\n:::',
),
).toThrow(/CONTENT_FORMAT_INVALID/);
});
it("derives stable table column and row IDs", () => {
const [table] = parseCaseContent(`:::table id="metrics" caption="측정" rowHeaderColumn="none"
| 이름 | 수치 |
| :--- | ---: |
| before | 20 |
:::`);
expect(table?.type).toBe("DATA_TABLE");
if (table?.type !== "DATA_TABLE") return;
expect(table.columns.map((column) => column.id)).toEqual([
"metrics-column-1",
"metrics-column-2",
]);
expect(table.rows.map((row) => row.id)).toEqual(["metrics-row-1"]);
});
it("rejects unsupported inline syntax in table headers", () => {
for (const header of [
"<script>alert(1)</script>",
"![image](https://example.com/image.png)",
"~~deleted~~",
]) {
const source = `:::table id="unsafe-header" caption="검증" rowHeaderColumn="none"
| ${header} |
| --- |
| value |
:::`;
expect(() => parseCaseContent(source), header).toThrow(
/CONTENT_FORMAT_INVALID/,
);
}
});
const metricsTable = `:::table id="metrics" caption="측정" rowHeaderColumn="none"
| 이름 | 값 |
| --- | --- |
| before | 20 |
:::`;
it("rejects duplicate table IDs", () => {
expect(() =>
parseCaseContent(`${metricsTable}\n\n${metricsTable}`),
).toThrow(/duplicate explicit ID: metrics/);
});
it("rejects heading and table base ID collisions in either order", () => {
expect(() =>
parseCaseContent(`## 제목 {#metrics}\n\n${metricsTable}`),
).toThrow(/duplicate explicit ID: metrics/);
expect(() =>
parseCaseContent(`${metricsTable}\n\n## 제목 {#metrics}`),
).toThrow(/duplicate explicit ID: metrics/);
});
it("rejects heading IDs that collide with a later table column ID", () => {
expect(() =>
parseCaseContent(`## 열 {#metrics-column-1}\n\n${metricsTable}`),
).toThrow(/duplicate explicit ID: metrics-column-1/);
});
it("rejects table row IDs that collide with a later heading ID", () => {
expect(() =>
parseCaseContent(`${metricsTable}\n\n## 행 {#metrics-row-1}`),
).toThrow(/duplicate explicit ID: metrics-row-1/);
});
function expectRoundTrip(source: string) {
const parsed = parseCaseContent(source);
expect(parseCaseContent(serializeCaseContent(parsed))).toEqual(parsed);
}
it("round trips literal backslashes and intraword underscores", () => {
expectRoundTrip("literal_under_score and \\*star\\* and \\\\slash");
});
it("round trips literal emphasis-shaped underscores", () => {
expectRoundTrip("literal \\_emphasis\\_");
});
it("keeps escaped block-leading list and quote markers as paragraphs", () => {
for (const source of ["\\- literal", "\\> quote"]) {
const parsed = parseCaseContent(source);
expect(parsed[0]?.type).toBe("PARAGRAPH");
expect(parseCaseContent(serializeCaseContent(parsed))).toEqual(parsed);
}
});
it("round trips inline code containing backticks", () => {
expectRoundTrip("Use ``code ` tick`` here.");
});
it("round trips all-space inline code without semantic padding", () => {
expectRoundTrip("Use `` `` here.");
});
it("round trips escaped pipes in table cells", () => {
expectRoundTrip(`:::table id="pipes" caption="파이프" rowHeaderColumn="none"
| 표현 | 값 |
| --- | --- |
| a \\| b | c |
:::`);
});
it("round trips directive-looking text inside a longer code fence", () => {
expectRoundTrip(`\`\`\`\`text label="문법 예시"
:::callout tone="warning" label="코드 안"
\`\`\`inner
literal
\`\`\`
\`\`\`\``);
});
});
const catalog: components["schemas"]["CatalogEntry"][] = [
{
id: "topic-jpa",
type: "TOPIC",
label: "JPA",
publicPath: "/topics/jpa",
dependencyRevision: "r1",
},
{
id: "project-backend",
type: "PROJECT",
label: "Backend Skeleton",
publicPath: "/projects/backend-skeleton",
dependencyRevision: "r1",
},
{
id: "case-fetch",
type: "RELATION",
kind: "CASE",
label: "Fetch Join Case",
publicPath: "/cases/collection-fetch-join-pagination",
dependencyRevision: "r1",
},
{
id: "evidence-fetch",
type: "EVIDENCE",
label: "Fetch 전략 비교",
publicPath: "/media/fetch-strategy-boundary.svg",
dependencyRevision: "r1",
},
{
id: "resolution-evidence",
type: "EVIDENCE",
label: "Fetch Join Case",
publicPath: "/cases/collection-fetch-join-pagination",
dependencyRevision: "r1",
},
];
function caseWithEvidence(
key: string,
): components["schemas"]["CaseInput"] {
return {
kind: "CASE",
title: "Case",
slug: "case",
summary: "summary",
topicId: "topic-jpa",
projectId: "project-backend",
relations: [
{
id: "relation-1",
targetId: "case-fetch",
reason: "근거",
order: 1,
},
],
problem: "problem",
conclusion: "conclusion",
environment: "environment",
reproduction: "dataset",
lastVerifiedOn: "2026-08-14",
bodyMarkdown: `## 본문\n\n:::evidence key="${key}" alt="설명" caption="근거" zoom="true"\n:::`,
};
}
describe("Public render model projection", () => {
it("is pure and resolves a supported catalog evidence entry", () => {
const input = caseWithEvidence("fetch-strategy-boundary");
const before = structuredClone(input);
const model = projectWorkingCopy(
input,
catalog,
{
generatedAt: "2026-08-14T00:00:00Z",
dependencyRevision: "r1",
},
supportsEvidenceKey,
);
expect(model.kind).toBe("CASE");
expect(model.topic.label).toBe("JPA");
expect(model.project?.label).toBe("Backend Skeleton");
expect(model.relations[0]?.title).toBe("Fetch Join Case");
expect(input).toEqual(before);
});
it("preserves resolved Question fields and requires EVIDENCE", () => {
const input: components["schemas"]["QuestionInput"] = {
kind: "QUESTION",
title: "Question",
slug: "question",
summary: "summary",
topicId: "topic-jpa",
projectId: null,
relations: [],
questionStatus: "RESOLVED",
facts: [],
assumptions: [],
unknowns: [],
constraints: [],
options: [],
nextValidation: "다음에도 측정합니다.",
resolution: {
summary: "분리하기로 결정했습니다.",
evidenceTargetId: "resolution-evidence",
linkLabel: "Case 읽기",
},
};
const model = projectWorkingCopy(
input,
catalog,
{ mode: "PREVIEW", publishedAt: null },
supportsEvidenceKey,
);
expect(model.kind).toBe("QUESTION");
if (model.kind !== "QUESTION") return;
expect(model.nextValidation).toBe(input.nextValidation);
expect(model.resolution?.summary).toBe(input.resolution?.summary);
expect(model.resolution?.evidenceTarget.publicPath).toBe(
"/cases/collection-fetch-join-pagination",
);
expect(() =>
projectWorkingCopy(
{
...input,
resolution: {
...input.resolution!,
evidenceTargetId: "topic-jpa",
},
},
catalog,
{ mode: "PREVIEW", publishedAt: null },
supportsEvidenceKey,
),
).toThrow(/EVIDENCE catalog/);
});
const unsupportedEvidenceCatalog = [
...catalog,
{
id: "unsupported-id",
type: "EVIDENCE",
label: "지원하지 않음",
publicPath: "/media/unsupported-id.svg",
dependencyRevision: "r1",
},
{
id: "other-id",
type: "EVIDENCE",
label: "unsupported-label",
publicPath: "/media/unsupported-label.svg",
dependencyRevision: "r1",
},
] satisfies components["schemas"]["CatalogEntry"][];
function expectUnsupportedEvidence(key: string) {
expect(() =>
projectWorkingCopy(
caseWithEvidence(key),
unsupportedEvidenceCatalog,
{ mode: "PREVIEW", publishedAt: null },
supportsEvidenceKey,
),
).toThrow(/supported local evidence key/);
}
it("rejects an unknown evidence key", () => {
expectUnsupportedEvidence("unknown-asset");
});
it("rejects a catalog-matching unsupported evidence ID", () => {
expectUnsupportedEvidence("unsupported-id");
});
it("rejects a catalog-matching unsupported evidence label", () => {
expectUnsupportedEvidence("unsupported-label");
});
// Fix round 2 regression coverage. `catalog` above already carries
// "resolution-evidence" -- a real `EVIDENCE` row that exists for a
// QUESTION resolution target (see "requires EVIDENCE" above), not media.
// A production caller whose gate 1 collapsed into gate 2 (round 1's bug)
// would let this key through with no Asset and no legacy key backing it;
// this is the exact class of catalog row the reviewer used to reproduce
// that regression.
it("rejects a real EVIDENCE catalog row that exists for a different purpose than media (round 2 regression)", () => {
expect(() =>
projectWorkingCopy(
caseWithEvidence("resolution-evidence"),
catalog,
{ mode: "PREVIEW", publishedAt: null },
supportsEvidenceKey,
),
).toThrow(/supported local evidence key/);
});
it("accepts a key backed by a loaded READY Asset even though the document catalog carries no matching row", () => {
const assets: Asset[] = [
{
id: "99999999-9999-4999-8999-999999999999",
assetKey: "boundary-check",
kind: "DIAGRAM",
mediaType: "image/svg+xml",
originalFilename: "boundary.svg",
byteSize: 10,
width: null,
height: null,
altText: null,
decorative: false,
managementStatus: "READY",
publicPath: "/media/boundary-check.svg",
usageCount: 0,
version: 1,
createdAt: "2026-08-14T00:00:00.000Z",
updatedAt: "2026-08-14T00:00:00.000Z",
},
];
// `catalog` alone has no row for "boundary-check" -- gate 2 only passes
// because the caller merges in `evidenceCatalogEntriesFromAssets`, the
// same way every production caller does.
const evidenceCatalog = [...catalog, ...evidenceCatalogEntriesFromAssets(assets)];
const model = projectWorkingCopy(
caseWithEvidence("boundary-check"),
evidenceCatalog,
{ mode: "PREVIEW", publishedAt: null },
(key: string) => supportsEvidenceKey(key, assets),
);
expect(model.kind).toBe("CASE");
});
});