feat(studio-save): 시험 초안 계획에 두 번째 저장과 정리 단계를 넣는다

만들기만 하면 이 런에서 expectedVersion·VERSION_CONFLICT 경로가 한 번도 안 돈다. 새로
만든 뒤 한 번 더 저장해 낙관적 락을 태운다. expectedVersion 은 만들기 응답이 준 version
이고, 모른 채 보내면 서버가 0 으로 채워 늘 충돌한다.

만든 시험 초안을 그 자리에서 지운다. DELETE /api/v1/studio/cases/{id} 가 실재하는 것을
소스와 openapi 에서 확인했고 게시 가드에 안 걸리는 것도 확인했다. 시험 초안의 종류를
CASE 로 고른 이유가 그것이다 — Decision 은 계약에 삭제 경로가 없다.

정리 단계는 --harness-test 일 때만 낸다. 진짜 기록에는 삭제 요청을 만들지 않는다.

삭제가 409 를 낼 수 있다 — 공개된 기록이거나 참조하는 곳이 있는 경우다. 둘 다 사람이
화면에서 처리하고, 계획에 그렇게 적었다.

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-10 19:15:07 +09:00
co-authored by Claude Opus 5
parent f16785b93e
commit c66eee6cb2
2 changed files with 81 additions and 2 deletions
+40 -2
View File
@@ -415,7 +415,8 @@ def _reject_forbidden_paths(plan: list[dict]) -> None:
def plan_requests(record_path: str, doc: dict, document_id: str | None, def plan_requests(record_path: str, doc: dict, document_id: str | None,
expected_version: int | None) -> list[dict]: expected_version: int | None,
harness_test: bool = False) -> list[dict]:
"""보낼 요청을 그대로 적어 낸다. 보내지 않는다.""" """보낼 요청을 그대로 적어 낸다. 보내지 않는다."""
rel = os.path.relpath(os.path.abspath(record_path), ROOT) rel = os.path.relpath(os.path.abspath(record_path), ROOT)
steps: list[dict] = [] steps: list[dict] = []
@@ -459,6 +460,23 @@ def plan_requests(record_path: str, doc: dict, document_id: str | None,
"expect": {"status": 200, "readHeader": REPLAYED_HEADER, "expect": {"status": 200, "readHeader": REPLAYED_HEADER,
"onConflict": "VERSION_CONFLICT 409 — 덮어쓰지 않고 멈춘다"}, "onConflict": "VERSION_CONFLICT 409 — 덮어쓰지 않고 멈춘다"},
}) })
if harness_test and not document_id:
# 새로 만든 뒤 한 번 더 저장해 낙관적 락을 실제로 태운다. `expectedVersion` 은
# **만들기 응답이 준 version** 이다 — 모른 채 보내면 서버가 0 으로 채워 늘 충돌한다.
# 이 단계가 없으면 이 런에서 `expectedVersion`·`VERSION_CONFLICT` 경로가 안 돈다
body = {"expectedVersion": "<create 응답의 document.version>", "document": doc}
steps.append({
"op": "save-after-create", "method": "PUT",
"path": f"{BASE_PATH}/documents/<생성된 id>",
"headers": {IDEMPOTENCY_HEADER: "<이 본문으로 다시 만든 save 키>",
"X-CSRF-TOKEN": "<env STUDIO_CSRF_TOKEN>"},
"cookies": {"TECHLOG_SESSION": "<env STUDIO_SESSION_COOKIE>"},
"body": body,
"expect": {"status": 200, "readHeader": REPLAYED_HEADER,
"onConflict": "VERSION_CONFLICT 409 — 덮어쓰지 않고 멈춘다",
"note": "멱등 키는 보낼 내용이 정해진 뒤에 만든다. "
"scripts/studio-save.py 의 idempotency_key('save', 경로, 본문) 이다"},
})
steps.append({ steps.append({
"op": "verify", "method": "GET", "op": "verify", "method": "GET",
"path": f"{BASE_PATH}/documents/{document_id or '<생성된 id>'}", "path": f"{BASE_PATH}/documents/{document_id or '<생성된 id>'}",
@@ -468,6 +486,25 @@ def plan_requests(record_path: str, doc: dict, document_id: str | None,
"compare": "정규화한 본문·칸·자료를 보낸 것과 견준다", "compare": "정규화한 본문·칸·자료를 보낸 것과 견준다",
"private": "currentPublication 이 null 이거나 status != PUBLISHED"}, "private": "currentPublication 이 null 이거나 status != PUBLISHED"},
}) })
if harness_test:
# 시험 초안은 만든 자리에서 지운다. **`--harness-test` 일 때만** 이 단계를 낸다 —
# 진짜 기록에는 삭제 요청을 만들지 않는다.
#
# `DELETE /api/v1/studio/cases/{id}` 는 실재한다
# (`ManagementDocumentController.java:72` · `studio-management-v1.yaml:126,246`).
# 종류를 CASE 로 고른 이유가 그것이다 — Decision 은 계약에 삭제 경로가 없다.
steps.append({
"op": "cleanup", "method": "DELETE",
"path": f"/api/v1/studio/cases/{document_id or '<생성된 id>'}",
"headers": {"X-CSRF-TOKEN": "<env STUDIO_CSRF_TOKEN>"},
"cookies": {"TECHLOG_SESSION": "<env STUDIO_SESSION_COOKIE>"},
"body": None,
"expect": {"status": 204,
"onConflict": "409 DOCUMENT_PUBLISHED(공개된 기록은 삭제할 수 없습니다) "
"또는 DOCUMENT_IN_USE(참조하는 곳이 있어 삭제할 수 없습니다) "
"— 둘 다 사람이 화면에서 처리한다",
"why": "시험 초안을 남기지 않는다"},
})
_reject_forbidden_paths(steps) _reject_forbidden_paths(steps)
return steps return steps
@@ -579,7 +616,8 @@ def main() -> int:
doc = build_input(args.record) doc = build_input(args.record)
if args.harness_test: if args.harness_test:
doc["title"] = f"{HARNESS_PREFIX} {doc.get('title', '')}".strip() doc["title"] = f"{HARNESS_PREFIX} {doc.get('title', '')}".strip()
steps = plan_requests(args.record, doc, args.document_id, args.expected_version) steps = plan_requests(args.record, doc, args.document_id, args.expected_version,
args.harness_test)
if args.send and not UNATTENDED_SAVE_ENABLED: if args.send and not UNATTENDED_SAVE_ENABLED:
raise Refused("무인 저장이 꺼져 있다 — " + UNATTENDED_SAVE_BLOCKED_BY) raise Refused("무인 저장이 꺼져 있다 — " + UNATTENDED_SAVE_BLOCKED_BY)
+41
View File
@@ -489,3 +489,44 @@ class PublishedRecordGuardTest(unittest.TestCase):
def test_the_harness_prefix_is_defined(self): def test_the_harness_prefix_is_defined(self):
"""시험 초안을 사람이 눈으로 가릴 수 있어야 한다.""" """시험 초안을 사람이 눈으로 가릴 수 있어야 한다."""
self.assertEqual("[HARNESS-TEST]", ss.HARNESS_PREFIX) self.assertEqual("[HARNESS-TEST]", ss.HARNESS_PREFIX)
class HarnessTestPlanTest(unittest.TestCase):
"""시험 초안 계획. 만든 것을 그 자리에서 지운다."""
def test_cleanup_only_appears_for_a_harness_test(self):
"""진짜 기록에는 삭제 요청을 만들지 않는다."""
plain = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None)
self.assertNotIn("cleanup", [s["op"] for s in plain])
harness = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None,
harness_test=True)
self.assertIn("cleanup", [s["op"] for s in harness])
def test_the_cleanup_path_is_the_case_delete_endpoint(self):
"""`DELETE /api/v1/studio/cases/{id}` 는 실재한다
(`ManagementDocumentController.java:72`). Decision 은 삭제 경로가 없어서
시험 초안의 종류를 CASE 로 고른다."""
steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None,
harness_test=True)
cleanup = next(s for s in steps if s["op"] == "cleanup")
self.assertEqual("DELETE", cleanup["method"])
self.assertIn("/api/v1/studio/cases/", cleanup["path"])
def test_the_cleanup_path_does_not_trip_the_publish_guard(self):
"""삭제 경로가 게시 가드에 걸리면 정리를 못 한다."""
ss._reject_forbidden_paths([{"path": "/api/v1/studio/cases/abc"}])
def test_a_second_save_exercises_the_optimistic_lock(self):
"""이 단계가 없으면 이 런에서 `expectedVersion` 경로가 안 돈다."""
steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None,
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"])
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,
harness_test=True)
for s in steps:
self.assertNotIn("/publish", s["path"])
self.assertNotIn("/unpublish", s["path"])