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:
co-authored by
Claude Opus 5
parent
05e96b5add
commit
d2855d1e6c
+207
-10
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user