관계를 고르고 저장하면 400 이 돌아왔고, 편집기에는 "saveStudioDocument broke its contract." 한 줄만 남았다. 저장은 통째로 실패했다. 새 관계·규칙·선택지·문장의 id 를 `createLocalId` 로 만들고 있었다. 그 함수는 접두사를 붙여 `relation-<uuid>` 를 돌려준다 — React key 나 Idempotency-Key 로는 맞지만 계약이 그 자리에 요구하는 것은 uuid 다. 서버는 파싱조차 못 하고 InvalidFormatException 으로 거절했다. 목록에 고를 대상이 하나도 없던 동안에는(catalog RELATION/EVIDENCE 가 스텁이었다) 아무도 이 경로를 지나지 않아 드러나지 않았다. 두 결함이 서로를 가리고 있었다. Case 는 목록 항목이 없어 무사했다. Reference 의 규칙, Question 의 선택지, Decision 의 consequences 는 모두 같은 이유로 저장되지 않았을 것이다. `createNewItemId` 로 나눈다. 서버는 자기가 소유하지 않은 id 를 어차피 새로 부여하므로 (`StudioRelationStore.replace`) 이 값은 "이 줄은 새것"이라는 표시일 뿐이다 — 지켜야 할 것은 형식뿐이다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
352 lines
10 KiB
TypeScript
352 lines
10 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { test } from "vitest";
|
|
|
|
import type {
|
|
PublicationAggregate,
|
|
PublicPreview,
|
|
ValidationReport,
|
|
WorkingCopy,
|
|
WorkingCopyDetail,
|
|
} from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
|
import {
|
|
deriveDocumentState,
|
|
deriveNextAction,
|
|
derivePreviewState,
|
|
deriveValidationState,
|
|
} from "../../../src/features/tech-log/domain/studio/document-state.ts";
|
|
import {
|
|
createLocalId,
|
|
createNewItemId,
|
|
} from "../../../src/features/tech-log/domain/studio/local-id.ts";
|
|
|
|
const now = "2026-08-14T12:00:00.000Z";
|
|
const validUntil = "2026-08-14T12:30:00.000Z";
|
|
const expiredAt = "2026-08-14T12:00:00.000Z";
|
|
const futureExpiry = "2026-08-14T12:45:00.000Z";
|
|
|
|
function documentAt(
|
|
version = 7,
|
|
overrides: Partial<WorkingCopy> = {},
|
|
): WorkingCopy {
|
|
return {
|
|
id: "11111111-1111-4111-8111-111111111111",
|
|
kind: "CASE",
|
|
version,
|
|
title: "Fetch Join 경계",
|
|
slug: "fetch-join-boundary",
|
|
summary: "목록 경계를 검증합니다.",
|
|
topicId: "22222222-2222-4222-8222-222222222222",
|
|
projectId: null,
|
|
relations: [],
|
|
updatedAt: now,
|
|
problem: "문제",
|
|
conclusion: "결론",
|
|
environment: "PostgreSQL",
|
|
reproduction: "재현",
|
|
lastVerifiedOn: "2026-08-14",
|
|
bodyMarkdown: "## 본문",
|
|
...overrides,
|
|
} as WorkingCopy;
|
|
}
|
|
|
|
function validationAt(
|
|
version = 7,
|
|
overrides: Partial<ValidationReport> = {},
|
|
): ValidationReport {
|
|
return {
|
|
validationId: "33333333-3333-4333-8333-333333333333",
|
|
documentId: "11111111-1111-4111-8111-111111111111",
|
|
validatedVersion: version,
|
|
status: "VALID",
|
|
issues: [],
|
|
validatedAt: "2026-08-14T11:50:00.000Z",
|
|
validUntil,
|
|
dependencyRevision: "catalog-1",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function previewAt(
|
|
version = 7,
|
|
validationId = validationAt(version).validationId,
|
|
overrides: Partial<PublicPreview> = {},
|
|
): PublicPreview {
|
|
return {
|
|
previewId: "44444444-4444-4444-8444-444444444444",
|
|
documentId: "11111111-1111-4111-8111-111111111111",
|
|
previewVersion: version,
|
|
validationId,
|
|
createdAt: "2026-08-14T11:55:00.000Z",
|
|
expiresAt: futureExpiry,
|
|
renderModel: {
|
|
kind: "CASE",
|
|
title: "Fetch Join 경계",
|
|
slug: "fetch-join-boundary",
|
|
summary: "목록 경계를 검증합니다.",
|
|
topic: {
|
|
id: "22222222-2222-4222-8222-222222222222",
|
|
label: "JPA",
|
|
publicPath: null,
|
|
},
|
|
project: null,
|
|
relations: [],
|
|
blocks: [],
|
|
},
|
|
...overrides,
|
|
} as PublicPreview;
|
|
}
|
|
|
|
function publishedAt(version = 7): PublicationAggregate {
|
|
return {
|
|
publicationId: "55555555-5555-4555-8555-555555555555",
|
|
documentId: "11111111-1111-4111-8111-111111111111",
|
|
status: "PUBLISHED",
|
|
publishedVersion: version,
|
|
publicationRevision: 1,
|
|
latestEventId: "66666666-6666-4666-8666-666666666666",
|
|
publicPath: "/cases/fetch-join-boundary",
|
|
updatedAt: now,
|
|
};
|
|
}
|
|
|
|
type InputOverrides = Partial<Parameters<typeof deriveNextAction>[0]>;
|
|
|
|
function input(overrides: InputOverrides = {}) {
|
|
return {
|
|
document: documentAt(),
|
|
validation: validationAt(),
|
|
preview: previewAt(),
|
|
publication: null,
|
|
now,
|
|
dependencyRevision: "catalog-1",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test("same published version is NONE before stale validation checks", () => {
|
|
assert.equal(
|
|
deriveNextAction({
|
|
document: documentAt(7),
|
|
validation: validationAt(7, { validUntil: expiredAt }),
|
|
preview: previewAt(7, "other-validation", { expiresAt: expiredAt }),
|
|
publication: publishedAt(7),
|
|
now,
|
|
dependencyRevision: "catalog-2",
|
|
}),
|
|
"NONE",
|
|
);
|
|
});
|
|
|
|
test("validation freshness requires the saved version, a future expiry, and matching dependencies", () => {
|
|
assert.deepEqual(deriveValidationState(input({ validation: null })), {
|
|
result: "NOT_RUN",
|
|
freshness: "NONE",
|
|
});
|
|
assert.deepEqual(
|
|
deriveValidationState(input({ validation: validationAt(6) })),
|
|
{ result: "VALID", freshness: "STALE" },
|
|
);
|
|
assert.deepEqual(
|
|
deriveValidationState(
|
|
input({ validation: validationAt(7, { validUntil: now }) }),
|
|
),
|
|
{ result: "VALID", freshness: "STALE" },
|
|
);
|
|
assert.deepEqual(
|
|
deriveValidationState(input({ dependencyRevision: "catalog-2" })),
|
|
{ result: "VALID", freshness: "STALE" },
|
|
);
|
|
assert.deepEqual(deriveValidationState(input()), {
|
|
result: "VALID",
|
|
freshness: "CURRENT",
|
|
});
|
|
});
|
|
|
|
test("preview expiry takes precedence and all remaining freshness checks are enforced", () => {
|
|
const validation = validationAt();
|
|
assert.equal(derivePreviewState(input({ preview: null })), "NONE");
|
|
assert.equal(
|
|
derivePreviewState(
|
|
input({ preview: previewAt(6, validation.validationId) }),
|
|
),
|
|
"STALE",
|
|
);
|
|
assert.equal(
|
|
derivePreviewState(input({ preview: previewAt(7, "other-validation") })),
|
|
"STALE",
|
|
);
|
|
assert.equal(
|
|
derivePreviewState(
|
|
input({
|
|
preview: previewAt(6, "other-validation", { expiresAt: now }),
|
|
}),
|
|
),
|
|
"EXPIRED",
|
|
);
|
|
assert.equal(derivePreviewState(input()), "CURRENT");
|
|
assert.equal(
|
|
derivePreviewState(
|
|
input({ validation: validationAt(7, { validUntil: now }) }),
|
|
),
|
|
"STALE",
|
|
);
|
|
assert.equal(
|
|
derivePreviewState(input({ dependencyRevision: "catalog-2" })),
|
|
"STALE",
|
|
);
|
|
});
|
|
|
|
test("save-reset, dependency staleness, invalid reports, warnings and preview gaps are ordered", () => {
|
|
assert.equal(
|
|
deriveNextAction(
|
|
input({ document: documentAt(7, { title: "", slug: "", summary: "" }) }),
|
|
),
|
|
"CONTINUE_EDITING",
|
|
);
|
|
assert.equal(deriveNextAction(input({ validation: null })), "VALIDATE");
|
|
assert.equal(
|
|
deriveNextAction(input({ dependencyRevision: "catalog-2" })),
|
|
"VALIDATE",
|
|
);
|
|
assert.equal(
|
|
deriveNextAction(
|
|
input({ validation: validationAt(7, { status: "INVALID" }) }),
|
|
),
|
|
"FIX_VALIDATION",
|
|
);
|
|
assert.equal(
|
|
deriveNextAction(
|
|
input({
|
|
validation: validationAt(7, { status: "WARNINGS" }),
|
|
preview: null,
|
|
}),
|
|
),
|
|
"CREATE_PREVIEW",
|
|
);
|
|
assert.equal(
|
|
deriveNextAction(
|
|
input({ validation: validationAt(7, { status: "WARNINGS" }) }),
|
|
),
|
|
"PUBLISH",
|
|
);
|
|
assert.deepEqual(
|
|
deriveValidationState(
|
|
input({
|
|
validation: validationAt(7, { status: "WARNINGS" }),
|
|
dependencyRevision: "catalog-2",
|
|
}),
|
|
),
|
|
{ result: "WARNINGS", freshness: "STALE" },
|
|
);
|
|
assert.equal(
|
|
deriveNextAction(
|
|
input({
|
|
validation: validationAt(7, { status: "WARNINGS" }),
|
|
dependencyRevision: "catalog-2",
|
|
}),
|
|
),
|
|
"VALIDATE",
|
|
);
|
|
});
|
|
|
|
test("saving retains an old preview as stale and returns to validation", () => {
|
|
const previousValidation = validationAt(7);
|
|
const previousPreview = previewAt(7, previousValidation.validationId);
|
|
const state = deriveDocumentState(
|
|
input({
|
|
document: documentAt(8),
|
|
validation: null,
|
|
preview: previousPreview,
|
|
}),
|
|
);
|
|
|
|
assert.deepEqual(state.validation, {
|
|
result: "NOT_RUN",
|
|
freshness: "NONE",
|
|
});
|
|
assert.equal(state.preview, "STALE");
|
|
assert.equal(state.nextAction, "VALIDATE");
|
|
});
|
|
|
|
test("unpublished or old-version publications follow the normal next-action flow", () => {
|
|
assert.equal(
|
|
deriveNextAction(
|
|
input({ publication: { ...publishedAt(), status: "UNPUBLISHED" } }),
|
|
),
|
|
"PUBLISH",
|
|
);
|
|
assert.equal(
|
|
deriveNextAction(input({ publication: publishedAt(6) })),
|
|
"PUBLISH",
|
|
);
|
|
});
|
|
|
|
test("deriveDocumentState is consistent with a WorkingCopyDetail aggregate", () => {
|
|
const detail: WorkingCopyDetail = {
|
|
document: documentAt(),
|
|
currentValidation: validationAt(),
|
|
latestPreview: previewAt(),
|
|
currentPublication: publishedAt(),
|
|
dependencyRevision: "catalog-1",
|
|
// `deriveDocumentState` computes `nextAction`; it never reads it back off
|
|
// `WorkingCopyDetail`. This mirrors the expected `explicit` result below.
|
|
nextAction: "NONE",
|
|
};
|
|
const explicit = deriveDocumentState({
|
|
document: detail.document,
|
|
validation: detail.currentValidation,
|
|
preview: detail.latestPreview,
|
|
publication: detail.currentPublication,
|
|
dependencyRevision: detail.dependencyRevision,
|
|
now,
|
|
});
|
|
const fromDetail = deriveDocumentState({ ...detail, now });
|
|
|
|
assert.deepEqual(explicit, {
|
|
validation: { result: "VALID", freshness: "CURRENT" },
|
|
preview: "CURRENT",
|
|
nextAction: "NONE",
|
|
});
|
|
assert.deepEqual(fromDetail, explicit);
|
|
});
|
|
|
|
test("local Studio IDs are deterministic without Web Crypto and prefer UUIDs when present", () => {
|
|
assert.equal(
|
|
createLocalId("preview", null, () => 1234, () => 0.25),
|
|
"preview-1234-250000000",
|
|
);
|
|
assert.equal(
|
|
createLocalId("save", { randomUUID: () => "fixture-uuid" }),
|
|
"save-fixture-uuid",
|
|
);
|
|
});
|
|
|
|
/*
|
|
이 두 함수가 나뉜 이유는 하나는 화면 안에만 머물고 다른 하나는 계약을 건너가기 때문이다.
|
|
|
|
한때 새 관계·규칙·선택지의 id 를 `createLocalId` 로 만들었고, 그래서 `relation-<uuid>` 같은
|
|
값이 요청 본문에 실렸다. 계약은 그 자리에 uuid 를 요구하므로 서버는 파싱조차 못 하고 400 을
|
|
돌려주었다 — 편집기에는 "saveStudioDocument broke its contract." 한 줄만 남고 저장은 통째로
|
|
실패했다. 목록에 고를 대상이 하나도 없던 시절에는 아무도 이 경로를 지나지 않아 드러나지 않았다.
|
|
*/
|
|
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
|
|
test("new list item IDs are bare UUIDs, because they cross the contract", () => {
|
|
assert.equal(
|
|
createNewItemId({ randomUUID: () => "3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607" }),
|
|
"3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607",
|
|
);
|
|
// Web Crypto 가 없는 환경에서도 형식은 uuid 여야 한다 — 값은 서버가 새로 부여하지만, 형식이
|
|
// 어긋나면 요청 자체가 거절된다.
|
|
assert.match(createNewItemId(null, () => 0.5), UUID_SHAPE);
|
|
assert.match(createNewItemId(null, () => 0), UUID_SHAPE);
|
|
assert.match(createNewItemId(null, () => 0.999), UUID_SHAPE);
|
|
});
|
|
|
|
test("prefixed local IDs never satisfy the contract's UUID shape", () => {
|
|
assert.equal(
|
|
UUID_SHAPE.test(createLocalId("relation", { randomUUID: () => "3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607" })),
|
|
false,
|
|
);
|
|
});
|