fix(studio-save): 정상 입력이 저장까지 못 가던 이유 셋을 고친다

C 가 운영 Studio 에 직접 걸어 찾았다. 셋 다 서버가 아니라 이 어댑터가 만든 요청의 문제다.

① 422 IDEMPOTENT_REQUEST_MISMATCH 가 어디에도 안 적혀 있었다. 코드만 보면 본문이
   틀렸다고 읽는다 — 서버 문구도 무슨 일이 있었는지만 말한다. SERVER_ERRORS 표를
   만들어 계획의 expect 에 실었다. 표에 없는 코드는 Refused 다.
   키 설계는 안 건드렸다. 본문 해시를 넣으면 「재시도가 문서를 둘 만들지 않는다」가
   깨진다 — idempotency_key 주석에 이 설계가 막는 것을 적어 두었다.

② --harness-test 가 제목만 바꾸고 slug 는 원본 그대로였다. slug 에 유일성 제약이
   있어 409 DB_UNIQUE_VIOLATION 이다. 시험 초안은 반드시 원본에서 나오므로 이 충돌은
   필연이다. 접두사는 두 번 붙지 않고, 길이 상한을 넘기면 자르지 않고 거절한다.

③ QUESTION 의 칸이 문자열이 아니었다. facts·assumptions·unknowns·constraints 는
   OrderedText 배열, options 는 QuestionOption 배열, 마지막 칸 이름은 nextValidation
   이다(studio-v1.yaml:1013-1040). id 는 uuid5 로 만든다 — 난수면 같은 기록을 다시
   계획할 때 본문이 달라져 저장 멱등 키가 흔들린다.

곁다리로 relations 가 [] 이지 None 이 아닌 이유를 주석에 남기고, 정리 단계의 값을
계약이 적은 400 이 아니라 C 가 관측한 422 로 고쳤다. 404 는 지워졌다는 뜻이 아니다 —
종류를 어긋나게 보내면 404 뒤 GET 이 200 이다.

python3 -m unittest discover -s scripts/tests — Ran 257 · OK (skipped=13)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
This commit is contained in:
DongHyeonka
2026-09-11 09:07:38 +09:00
co-authored by Claude Opus 5
parent 05e96b5add
commit d2855d1e6c
2 changed files with 300 additions and 12 deletions
+207 -10
View File
@@ -37,6 +37,7 @@ import json
import os
import re
import sys
import uuid
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -61,8 +62,13 @@ FIELD_MAP = {
"CONCEPT": {"본문": "bodyMarkdown"},
"REFERENCE": {"목적": "purpose", "규칙": "rules", "적용 조건": "appliesWhen",
"예외": "exceptions", "예시": "examples"},
# **QUESTION 의 칸은 문자열이 아니다.** `facts`·`assumptions`·`unknowns`·`constraints` 는
# `OrderedText` 배열이고 `options` 는 `QuestionOption` 배열이며, 마지막 칸의 이름은
# `nextValidation` 이다 — `nextVerification` 이 아니다 (`studio-v1.yaml:1013-1040`).
# 문자열로 보내면 `400 VALIDATION_FAILED · MismatchedInputException` 이다.
# C 가 V-009 의 `P-QUESTION-02` 에서 맞았다. 여기서는 이름만 잇고 모양은 `_question_shape` 가 만든다
"QUESTION": {"사실": "facts", "가정": "assumptions", "미지수": "unknowns",
"제약": "constraints", "선택지": "options", "다음 검증": "nextVerification"},
"제약": "constraints", "선택지": "options", "다음 검증": "nextValidation"},
"PROJECT_DECISION": {"근거": "basis", "결정문": "decision", "판단 이유": "rationale",
"영향": "impact"},
}
@@ -173,6 +179,20 @@ def idempotency_key(op: str, record_rel: str, payload: dict | None = None) -> st
- **저장**은 경로와 보낼 내용을 함께 넣는다. 같은 내용의 재시도는 서버가 첫 결과를
재생하고, 내용이 바뀌면 새 저장이 된다. 경로만 쓰면 두 번째 저장이 첫 결과로
조용히 재생돼 버린다.
**이 설계가 막는 것이 따로 있다 — 운영에서 확인했다.**
만들기 키가 경로만으로 만들어지므로 **같은 경로의 만들기는 서버에서 영원히 같은 키다.**
그래서 「재시도가 문서를 둘 만들지 않는다」는 지켜지지만, **문서를 지워도 키는 서버에
남아** 같은 기록으로 다시 만들려 하면 `422 IDEMPOTENT_REQUEST_MISMATCH` 가 난다.
첫 만들기가 본문 문제로 실패한 뒤 본문을 고쳐 다시 보내는 것도 같다.
두 번째부터 `PUT` 인 정상 편집 흐름은 영향이 없다. 키 정책을 고치는 것은 **여기서 하지
않는다** — 본문 해시를 그냥 넣으면 「재시도가 문서를 둘 만들지 않는다」가 깨진다.
「같은 시도인가」와 「새 생성인가」를 가르는 표식이 필요하고 그것은 설계 결정이다.
키가 언제 사라지는지는 `IDEMPOTENCY_TTL_CONTRACT` 를 본다 — **운영 인스턴스의 실제
값은 미확인이다.**
"""
base = _sha256_text(record_rel)[:32]
key = f"studio-{op}-{base}"
@@ -186,6 +206,81 @@ def idempotency_key(op: str, record_rel: str, payload: dict | None = None) -> st
return key
# 서버가 돌려주는 코드를 사람 말로 옮긴다. **코드만으로는 무엇을 하라는 것인지 모른다.**
# 서버가 주는 `client_safe_message` 는 무슨 일이 있었는지만 말하고 무엇을 해야 하는지는
# 말하지 않는다 — 그 줄이 이 표에 있는 이유다.
#
# 출처는 `tech-log-backend @ a000f87` 이다. 옮겨 적은 것이 아니라 읽고 확인한 것이다.
SERVER_ERRORS = {
"IDEMPOTENT_REQUEST_MISMATCH": {
"status": 422,
"server": "Idempotency key reused with different request body",
"means": "이 기록은 전에 **다른 내용으로** 만들어졌다. 같은 멱등 키에 다른 본문이 "
"오면 서버는 재생하지 않고 거절한다",
"likely": ["만들기가 아니라 저장이어야 한다 — 문서가 이미 서버에 있다",
"같은 경로로 지웠다 다시 만드는 중이다. **문서를 지워도 키는 남는다**",
"첫 만들기가 본문 문제로 실패한 뒤 본문만 고쳐 다시 보냈다"],
"notBody": "본문이 스키마에 안 맞는다는 뜻이 **아니다**. 그쪽은 "
"`REQUEST_VALIDATION_FAILED` 다",
"source": "error-codes.yaml:389 · OperationalError.java:48 · "
"IdempotencyRequestMismatchException.java:3-7",
},
"REQUEST_VALIDATION_FAILED": {
"status": 422,
"means": "본문이 스키마에 안 맞는다. **검증이 멱등 처리보다 앞이라 이 요청은 "
"지문을 남기지 않는다** — 본문을 고쳐 같은 키로 다시 보낼 수 있다",
"likely": ["`relations` 가 빠졌거나 `null` 이다 (required · nullable 아님)",
"정리 단계 DELETE 에 본문이 없다 (`requestBody: required: true`)"],
"source": "studio-management-v1.yaml:6150,6186 · 577-582",
},
"DOCUMENT_NOT_FOUND": {
"status": 404,
"means": "그 경로의 **종류**로는 그 id 의 문서가 없다. id 가 없는 것일 수도, "
"경로의 종류가 문서의 종류와 어긋난 것일 수도 있다",
"warning": "**404 를 받았다고 지워진 것이 아니다.** 종류가 어긋난 DELETE 는 404 를 "
"내고 문서는 그대로 남는다 — 지워졌는지는 GET 으로 확인한다",
"source": "DeleteDocumentDraftUseCase:20 — 종류를 조회 조건에 넣지 않으면 "
"Case 경로로 Reference 를 지울 수 있게 된다",
},
"VERSION_CONFLICT": {
"status": 409,
"means": "내가 읽은 뒤에 남이 고쳤다. `details.latestDocument` 에 현재 문서가 들어 있다",
"likely": ["`expectedVersion` 을 안 보내 서버가 0 으로 채웠다",
"되읽기와 저장 사이에 다른 사람이 저장했다"],
"source": "StudioDocumentController.java:196",
},
"DOCUMENT_PUBLISHED": {
"status": 409,
"means": "한 번이라도 게시한 문서다. 게시를 취소해도 삭제는 거절된다",
"source": "publishing-tech-log-to-studio/SKILL.md",
},
}
# **멱등 키가 얼마나 남아 있나.** 지운 문서를 같은 기록으로 다시 만들려면 키가 사라져야 한다.
#
# 계약에서 읽은 것 — `APP_IDEMPOTENCY_TTL` 기본 **24h**, 상한 72h(`IdempotencyProperties` 가
# 검증), 청소 주기 `reaper-interval: 10m` (`application.yml:473-477` ·
# `env-keys.yaml:1668-1680`).
#
# **미확인 — 이 운영 인스턴스의 실제 값은 모른다.** `application.yml` 이 `${APP_IDEMPOTENCY_TTL}`
# 을 기본값 없이 쓰므로 값은 그 서버의 환경 변수에 있고, 이 어댑터는 그것을 읽을 수 없다.
# C 가 관측한 것은 「V-007 이후 몇 시간 뒤에도 키가 남아 있었다」까지다. 24h 로 단정하지 않는다.
IDEMPOTENCY_TTL_CONTRACT = "기본 24h · 상한 72h · reaper 10m (운영 인스턴스의 실제 값은 미확인)"
def explain_error(code: str) -> dict | None:
"""서버 코드를 사람 말로. 모르는 코드는 지어내지 않고 None 을 돌려준다."""
return SERVER_ERRORS.get((code or "").upper())
def _expect_error(code: str) -> dict:
"""계획에 실을 형태. 계획을 읽는 사람이 코드를 다시 찾아보지 않아도 되게 한다."""
info = explain_error(code)
if info is None: # 표에 없는 코드를 계획에 적지 않는다
raise Refused(f"모르는 서버 오류 코드다: {code!r}")
return {"code": code, **info}
def _front_matter(text: str) -> dict:
if not text.startswith("---"):
return {}
@@ -215,6 +310,63 @@ def _sections(text: str) -> dict[str, str]:
return out
# `OrderedText`·`QuestionOption` 은 `id` 가 required 이고 `format: uuid` 다
# (`studio-v1.yaml:871-896`). 새 초안에는 서버가 준 id 가 없으므로 이쪽에서 만든다.
# **난수로 만들지 않는다** — 같은 기록을 다시 계획하면 같은 본문이어야 저장 멱등 키가
# 안정적이다. 기록 slug·칸 이름·순번으로 uuid5 를 만든다.
_ORDERED_NS = uuid.UUID("6f6b2f7a-5a1e-5a3f-9c4d-0a1b2c3d4e5f")
def _ordered_id(slug: str, field: str, order: int) -> str:
return str(uuid.uuid5(_ORDERED_NS, f"{slug}|{field}|{order}"))
def _bullets(chunk: str) -> list[str]:
"""`- ` 로 시작하는 항목만 고른다. 이어지는 들여쓴 줄은 같은 항목에 붙인다."""
items: list[list[str]] = []
for line in chunk.split("\n"):
if re.match(r"^\s*-\s+", line):
items.append([re.sub(r"^\s*-\s+", "", line).rstrip()])
elif items and line.strip():
items[-1].append(line.strip())
return ["\n".join(x).strip() for x in items if "\n".join(x).strip()]
def _question_options(chunk: str) -> list[dict]:
"""`### N. 제목` 과 그 아래 문단을 `QuestionOption` 으로."""
out: list[dict] = []
parts = re.split(r"^###\s+(.+)$", chunk, flags=re.M)
for i in range(1, len(parts), 2):
title = re.sub(r"^\d+[.)]\s*", "", parts[i].strip())
out.append({"title": title[:120], "description": parts[i + 1].strip()})
return out
def _question_shape(doc: dict, fm: dict) -> dict:
"""QUESTION 의 칸을 서버 스키마의 **모양**으로 바꾼다.
값이 아니라 타입이 문제였다. 문자열로 보내면 `MismatchedInputException` 이다.
"""
slug = doc.get("slug") or ""
for field in ("facts", "assumptions", "unknowns", "constraints"):
raw = doc.get(field)
items = _bullets(raw) if isinstance(raw, str) else []
# **빈 배열이지 빠뜨리는 것이 아니다.** 넷 다 required 다 (`studio-v1.yaml:1023`)
doc[field] = [{"id": _ordered_id(slug, field, i), "text": t, "order": i}
for i, t in enumerate(items)]
raw = doc.get("options")
opts = _question_options(raw) if isinstance(raw, str) else []
doc["options"] = [{"id": _ordered_id(slug, "options", i), "title": o["title"],
"description": o["description"], "order": i}
for i, o in enumerate(opts)]
if not isinstance(doc.get("nextValidation"), str):
doc["nextValidation"] = ""
# required 이고 `null` 도 받는다. 기록이 안 적었으면 지어내지 않고 null 로 둔다
status = (fm.get("questionStatus") or "").strip().upper()
doc["questionStatus"] = status if status in ("OPEN", "RESOLVED") else None
return doc
def _summary(text: str) -> str:
m = re.search(r"^#\s+.+$", text, re.M)
if not m:
@@ -241,6 +393,9 @@ def build_input(record_path: str) -> dict:
"summary": _summary(text),
"topicId": None, # 이름→uuid 해석은 catalog 조회가 필요하다. 지어내지 않는다
"projectId": None,
# **빈 배열이지 `None` 이 아니다.** `relations` 는 스키마의 `required` 목록에 있고
# `nullable: true` 가 없다(`studio-management-v1.yaml:6150,6186`). 빠지거나 `null` 이면
# 만들기가 `422 REQUEST_VALIDATION_FAILED` 로 거절된다 — 기본값을 `None` 으로 바꾸지 않는다
"relations": [],
}
for section, field in FIELD_MAP[kind].items():
@@ -248,6 +403,8 @@ def build_input(record_path: str) -> dict:
doc[field] = found[section]
if kind == "CASE":
doc["lastVerifiedOn"] = fm.get("lastVerifiedOn") or None
if kind == "QUESTION":
doc = _question_shape(doc, fm)
return doc
@@ -255,6 +412,37 @@ VERDICTS = ("PASS", "FAIL", "UNKNOWN")
# 사람이 눈으로 가릴 수 있게 시험 초안에 붙이는 접두사
HARNESS_PREFIX = "[HARNESS-TEST]"
# **제목만 바꾸면 저장이 안 된다.** `slug` 에 유일성 제약이 있어 원본 기록과 같은 slug 로
# 만들면 `409 DB_UNIQUE_VIOLATION` 이다 (C 가 V-009 에서 `P-CASE-01` 로 맞았다).
# 시험 초안은 **반드시** 원본과 같은 기록에서 나오므로 이 충돌은 우연이 아니라 필연이다.
HARNESS_SLUG_PREFIX = "harness-test-"
def as_harness_test(doc: dict) -> dict:
"""시험 초안으로 만든다. 제목과 `slug` 를 **함께** 바꾼다.
제목만 바꾸면 사람 눈에는 시험용인데 서버에는 원본과 같은 문서다. C 가 `slug` 앞에
`harness-test-` 를 붙여 같은 본문을 다시 걸었더니 201 이었다 — 원인이 확정된 자리다.
"""
out = dict(doc)
title = (doc.get("title") or "").strip()
if not title.startswith(HARNESS_PREFIX): # 두 번 걸어도 하나만 붙는다
out["title"] = f"{HARNESS_PREFIX} {title}".strip()
slug = (doc.get("slug") or "").strip()
if not slug:
raise Refused("slug 가 없는 기록으로 시험 초안을 만들지 않는다 — "
"무엇을 지워야 하는지 알 수 없다")
if not slug.startswith(HARNESS_SLUG_PREFIX):
out["slug"] = HARNESS_SLUG_PREFIX + slug
# `title` 은 120자, `slug` 는 100자가 상한이다 (`studio-v1.yaml:915-919`).
# 접두사가 상한을 넘기면 저장이 거절되므로 여기서 먼저 막는다 — 조용히 자르지 않는다
if len(out["title"]) > 120:
raise Refused(f"시험 초안 제목이 {len(out['title'])}자다. 상한 120자 — "
f"{HARNESS_PREFIX} 를 붙일 자리가 없다")
if len(out["slug"]) > 100:
raise Refused(f"시험 초안 slug 가 {len(out['slug'])}자다. 상한 100자 — "
f"{HARNESS_SLUG_PREFIX} 를 붙일 자리가 없다")
return out
# 종류마다 삭제 경로가 다르다. **서버가 종류를 조회 조건에 넣는다** —
# 「그렇게 하지 않으면 Case 경로로 Reference 를 지울 수 있게 되기 때문이다」
@@ -457,7 +645,12 @@ def plan_requests(record_path: str, doc: dict, document_id: str | None,
"X-CSRF-TOKEN": "<env STUDIO_CSRF_TOKEN>"},
"cookies": {"TECHLOG_SESSION": "<env STUDIO_SESSION_COOKIE>"},
"body": doc,
"expect": {"status": 201, "readHeader": REPLAYED_HEADER},
"expect": {
"status": 201, "readHeader": REPLAYED_HEADER,
# C 가 운영에서 이것을 맞았다. 코드만 보면 본문이 틀린 줄 안다
"onIdempotencyMismatch": _expect_error("IDEMPOTENT_REQUEST_MISMATCH"),
"onValidationFailed": _expect_error("REQUEST_VALIDATION_FAILED"),
},
})
else:
if expected_version is None:
@@ -475,7 +668,7 @@ def plan_requests(record_path: str, doc: dict, document_id: str | None,
"cookies": {"TECHLOG_SESSION": "<env STUDIO_SESSION_COOKIE>"},
"body": body,
"expect": {"status": 200, "readHeader": REPLAYED_HEADER,
"onConflict": "VERSION_CONFLICT 409 — 덮어쓰지 않고 멈춘다"},
"onConflict": _expect_error("VERSION_CONFLICT")},
})
if harness_test and not document_id:
# 새로 만든 뒤 한 번 더 저장해 낙관적 락을 실제로 태운다. `expectedVersion` 은
@@ -490,7 +683,7 @@ def plan_requests(record_path: str, doc: dict, document_id: str | None,
"cookies": {"TECHLOG_SESSION": "<env STUDIO_SESSION_COOKIE>"},
"body": body,
"expect": {"status": 200, "readHeader": REPLAYED_HEADER,
"onConflict": "VERSION_CONFLICT 409 — 덮어쓰지 않고 멈춘다",
"onConflict": _expect_error("VERSION_CONFLICT"),
"note": "멱등 키는 보낼 내용이 정해진 뒤에 만든다. "
"scripts/studio-save.py 의 idempotency_key('save', 경로, 본문) 이다"},
})
@@ -530,7 +723,9 @@ def plan_requests(record_path: str, doc: dict, document_id: str | None,
"headers": {"X-CSRF-TOKEN": "<env STUDIO_CSRF_TOKEN>"},
"cookies": {"TECHLOG_SESSION": "<env STUDIO_SESSION_COOKIE>"},
# **본문이 필수다.** `requestBody: required: true` 이고 컨트롤러가
# `@RequestBody ExpectedVersionRequest` 를 받는다. 안 보내면 400 이고 초안이 남는다.
# `@RequestBody ExpectedVersionRequest` 를 받는다. 안 보내면 초안이 남는다.
# **계약은 400 을 적었지만 운영에서 온 것은 `422 REQUEST_VALIDATION_FAILED` 다**
# (C 가 V-008 에서 직접 걸었다). 계약이 적은 값이 아니라 관측한 값을 적는다.
# 값은 **`verify` 가 읽은 version** 이다 — 만들 때 version 이 아니다.
# 만든 뒤 한 번 더 저장하므로 그 사이에 올라가 있다
"body": {"expectedVersion": "<verify 가 읽은 document.version>"},
@@ -538,10 +733,12 @@ def plan_requests(record_path: str, doc: dict, document_id: str | None,
"onConflict": "409 DOCUMENT_PUBLISHED(공개된 기록은 삭제할 수 없습니다) "
"또는 DOCUMENT_IN_USE(참조하는 곳이 있어 삭제할 수 없습니다) "
"— 둘 다 사람이 화면에서 처리한다",
"onNotFound": "404 DOCUMENT_NOT_FOUND — 경로의 종류가 문서의 종류와 "
"다르면 이렇게 나오고 초안이 남는다",
"onNotFound": _expect_error("DOCUMENT_NOT_FOUND"),
"onMissingBody": _expect_error("REQUEST_VALIDATION_FAILED"),
"csrfHeader": "X-CSRF-TOKEN 이다. X-XSRF-TOKEN 이면 403 이 난다",
"then": "GET 으로 404 를 확인한다 — 지워졌다는 것은 그것이다",
# C 가 운영에서 본 것: 종류를 어긋나게 보내면 DELETE 가 404 인데
# **그 뒤 GET 이 200 이다.** 404 는 지워졌다는 뜻이 아니다
"then": "GET 이 404 여야 지워진 것이다. DELETE 의 204 만 보고 끝내지 않는다",
"why": "시험 초안을 남기지 않는다"},
})
_reject_forbidden_paths(steps)
@@ -632,7 +829,7 @@ def main() -> int:
ap.add_argument("-o", "--out", help="계획을 적을 파일")
ap.add_argument("--verdicts", help="경고마다 PASS/FAIL/UNKNOWN 을 적은 검토 판정 파일")
ap.add_argument("--harness-test", action="store_true",
help=f"시험 초안이다. 제목 앞에 {HARNESS_PREFIX} 를 붙인다")
help=f"시험 초안이다. 제목 앞에 {HARNESS_PREFIX}, slug 앞에 {HARNESS_SLUG_PREFIX} 를 붙인다")
ap.add_argument("--send", action="store_true", help="실제로 보낸다 (지금은 막혀 있다)")
args = ap.parse_args()
@@ -654,7 +851,7 @@ def main() -> int:
cleared = _review_gate(all_warnings, args.verdicts, args.package)
doc = build_input(args.record)
if args.harness_test:
doc["title"] = f"{HARNESS_PREFIX} {doc.get('title', '')}".strip()
doc = as_harness_test(doc)
steps = plan_requests(args.record, doc, args.document_id, args.expected_version,
args.harness_test)
+93 -2
View File
@@ -549,7 +549,11 @@ class HarnessTestPlanTest(unittest.TestCase):
harness_test=True)
e = next(s for s in steps if s["op"] == "cleanup")["expect"]
self.assertIn("X-CSRF-TOKEN", e["csrfHeader"])
self.assertIn("DOCUMENT_NOT_FOUND", e["onNotFound"])
self.assertEqual("DOCUMENT_NOT_FOUND", e["onNotFound"]["code"])
# **코드만 적는 것으로는 모자란다.** C 가 운영에서 종류를 어긋나게 보냈더니
# DELETE 가 404 인데 그 뒤 GET 이 200 이었다 — 404 는 지워졌다는 뜻이 아니다
self.assertIn("지워진 것이 아니다", e["onNotFound"]["warning"])
self.assertIn("GET 이 404", e["then"])
def test_the_cleanup_path_is_the_case_delete_endpoint(self):
"""`DELETE /api/v1/studio/cases/{id}` 는 실재한다
@@ -574,7 +578,94 @@ class HarnessTestPlanTest(unittest.TestCase):
harness_test=True)
save = next(s for s in steps if s["op"] == "save-after-create")
self.assertIn("expectedVersion", save["body"])
self.assertIn("VERSION_CONFLICT", save["expect"]["onConflict"])
self.assertEqual("VERSION_CONFLICT", save["expect"]["onConflict"]["code"])
def test_the_create_step_explains_the_idempotency_mismatch(self):
"""C 가 운영에서 `422 IDEMPOTENT_REQUEST_MISMATCH` 를 맞았다. 코드만 보면 본문이
틀린 줄 안다 — 계획이 「전에 다른 내용으로 만들어졌다」를 함께 실어야 한다."""
steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None,
harness_test=True)
e = next(s for s in steps if s["op"] == "create")["expect"]
m = e["onIdempotencyMismatch"]
self.assertEqual("IDEMPOTENT_REQUEST_MISMATCH", m["code"])
self.assertEqual(422, m["status"])
# **지워도 키는 남는다** — 이 문장이 없으면 다음 사람이 왜 막혔는지 모른다
self.assertTrue(any("키는 남는다" in x for x in m["likely"]), m["likely"])
# 본문 문제와 갈라 준다. 둘 다 422 라 코드만으로는 못 가린다
self.assertIn("REQUEST_VALIDATION_FAILED", m["notBody"])
self.assertEqual(422, e["onValidationFailed"]["status"])
def test_an_unknown_server_code_is_not_invented(self):
"""모르는 코드를 계획에 지어 적지 않는다."""
with self.assertRaises(ss.Refused):
ss._expect_error("NOPE_NOT_A_CODE")
def test_the_key_lifetime_is_recorded_as_unverified(self):
"""키가 언제 사라지는지는 서버 환경 변수에 있고 이 어댑터는 못 읽는다.
계약에서 읽은 값을 확인한 값으로 승격하지 않는다."""
self.assertIn("미확인", ss.IDEMPOTENCY_TTL_CONTRACT)
def test_relations_is_an_empty_list_not_none(self):
"""`relations` 는 required 이고 nullable 이 아니다. `None` 이면 만들기가 422 다."""
import tempfile, os
with tempfile.TemporaryDirectory() as d:
f = os.path.join(d, "x.md")
open(f, "w", encoding="utf-8").write(
"---\nkind: CASE\ntitle: t\nslug: s\n---\n\n## 문제\n\n가.\n")
doc = ss.build_input(f)
self.assertEqual([], doc["relations"])
self.assertIsNotNone(doc["relations"])
def test_a_harness_test_changes_the_slug_too(self):
"""제목만 바꾸면 `slug` 가 원본과 같아 `409 DB_UNIQUE_VIOLATION` 이다.
C 가 V-009 의 `P-CASE-01` 에서 맞았고, slug 를 바꾸니 201 이었다."""
out = ss.as_harness_test({"title": "제목", "slug": "a-real-slug"})
self.assertTrue(out["title"].startswith(ss.HARNESS_PREFIX))
self.assertEqual("harness-test-a-real-slug", out["slug"])
# 두 번 걸어도 하나만 붙는다 — 계획을 다시 내도 본문이 같아야 저장 키가 안정적이다
self.assertEqual(out, ss.as_harness_test(out))
def test_a_harness_test_refuses_a_record_without_a_slug(self):
"""slug 가 없으면 무엇을 지워야 할지 모른다."""
with self.assertRaises(ss.Refused):
ss.as_harness_test({"title": "제목", "slug": ""})
def test_a_harness_test_refuses_when_the_prefix_overflows(self):
"""`title` 120자 · `slug` 100자가 상한이다. 조용히 자르지 않는다."""
with self.assertRaises(ss.Refused):
ss.as_harness_test({"title": "" * 120, "slug": "a-slug"})
with self.assertRaises(ss.Refused):
ss.as_harness_test({"title": "제목", "slug": "a" * 95})
def test_question_fields_are_arrays_not_strings(self):
"""`facts` 넷은 `OrderedText` 배열이고 마지막 칸 이름은 `nextValidation` 이다.
문자열로 보내면 `MismatchedInputException` 이다 (C 의 `P-QUESTION-02`)."""
doc = {"slug": "s", "facts": "- 하나\n- 둘", "assumptions": "", "unknowns": "",
"constraints": "", "options": "### 1. 고른다\n\n설명이다."}
out = ss._question_shape(dict(doc), {"questionStatus": "OPEN"})
self.assertEqual(2, len(out["facts"]))
self.assertEqual({"id", "text", "order"}, set(out["facts"][0]))
self.assertEqual("하나", out["facts"][0]["text"])
self.assertEqual([], out["assumptions"]) # required — 빠뜨리지 않는다
self.assertEqual({"id", "title", "description", "order"}, set(out["options"][0]))
self.assertEqual("고른다", out["options"][0]["title"])
self.assertIn("nextValidation", out)
self.assertNotIn("nextVerification", out)
self.assertEqual("OPEN", out["questionStatus"])
def test_question_ids_are_stable_across_runs(self):
"""id 를 난수로 만들면 같은 기록을 다시 계획할 때 본문이 달라져
저장 멱등 키가 흔들린다."""
doc = {"slug": "s", "facts": "- 하나", "assumptions": "", "unknowns": "",
"constraints": "", "options": ""}
a = ss._question_shape(dict(doc), {})
b = ss._question_shape(dict(doc), {})
self.assertEqual(a["facts"][0]["id"], b["facts"][0]["id"])
def test_an_unwritten_question_status_is_not_invented(self):
"""기록이 안 적은 상태를 지어내지 않는다. `null` 도 스키마가 받는다."""
out = ss._question_shape({"slug": "s"}, {})
self.assertIsNone(out["questionStatus"])
def test_a_harness_plan_still_has_no_publish_path(self):
steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None,