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
+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,