From d2855d1e6c2bfcfdbf37ebcdef63ecd238ffb01f Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Fri, 11 Sep 2026 09:07:38 +0900 Subject: [PATCH] =?UTF-8?q?fix(studio-save):=20=EC=A0=95=EC=83=81=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EC=9D=B4=20=EC=A0=80=EC=9E=A5=EA=B9=8C?= =?UTF-8?q?=EC=A7=80=20=EB=AA=BB=20=EA=B0=80=EB=8D=98=20=EC=9D=B4=EC=9C=A0?= =?UTF-8?q?=20=EC=85=8B=EC=9D=84=20=EA=B3=A0=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk --- scripts/studio-save.py | 217 ++++++++++++++++++++++++++++-- scripts/tests/test_studio_save.py | 95 ++++++++++++- 2 files changed, 300 insertions(+), 12 deletions(-) diff --git a/scripts/studio-save.py b/scripts/studio-save.py index 87b5daf..4d66c2f 100644 --- a/scripts/studio-save.py +++ b/scripts/studio-save.py @@ -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": ""}, "cookies": {"TECHLOG_SESSION": ""}, "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": ""}, "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": ""}, "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": ""}, "cookies": {"TECHLOG_SESSION": ""}, # **본문이 필수다.** `requestBody: required: true` 이고 컨트롤러가 - # `@RequestBody ExpectedVersionRequest` 를 받는다. 안 보내면 400 이고 초안이 남는다. + # `@RequestBody ExpectedVersionRequest` 를 받는다. 안 보내면 초안이 남는다. + # **계약은 400 을 적었지만 운영에서 온 것은 `422 REQUEST_VALIDATION_FAILED` 다** + # (C 가 V-008 에서 직접 걸었다). 계약이 적은 값이 아니라 관측한 값을 적는다. # 값은 **`verify` 가 읽은 version** 이다 — 만들 때 version 이 아니다. # 만든 뒤 한 번 더 저장하므로 그 사이에 올라가 있다 "body": {"expectedVersion": ""}, @@ -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) diff --git a/scripts/tests/test_studio_save.py b/scripts/tests/test_studio_save.py index f93d103..d50482f 100644 --- a/scripts/tests/test_studio_save.py +++ b/scripts/tests/test_studio_save.py @@ -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,