diff --git a/scripts/studio-save.py b/scripts/studio-save.py index 1095237..ab2f14f 100644 --- a/scripts/studio-save.py +++ b/scripts/studio-save.py @@ -415,7 +415,8 @@ def _reject_forbidden_paths(plan: list[dict]) -> 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) 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, "onConflict": "VERSION_CONFLICT 409 — 덮어쓰지 않고 멈춘다"}, }) + if harness_test and not document_id: + # 새로 만든 뒤 한 번 더 저장해 낙관적 락을 실제로 태운다. `expectedVersion` 은 + # **만들기 응답이 준 version** 이다 — 모른 채 보내면 서버가 0 으로 채워 늘 충돌한다. + # 이 단계가 없으면 이 런에서 `expectedVersion`·`VERSION_CONFLICT` 경로가 안 돈다 + body = {"expectedVersion": "", "document": doc} + steps.append({ + "op": "save-after-create", "method": "PUT", + "path": f"{BASE_PATH}/documents/<생성된 id>", + "headers": {IDEMPOTENCY_HEADER: "<이 본문으로 다시 만든 save 키>", + "X-CSRF-TOKEN": ""}, + "cookies": {"TECHLOG_SESSION": ""}, + "body": body, + "expect": {"status": 200, "readHeader": REPLAYED_HEADER, + "onConflict": "VERSION_CONFLICT 409 — 덮어쓰지 않고 멈춘다", + "note": "멱등 키는 보낼 내용이 정해진 뒤에 만든다. " + "scripts/studio-save.py 의 idempotency_key('save', 경로, 본문) 이다"}, + }) steps.append({ "op": "verify", "method": "GET", "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": "정규화한 본문·칸·자료를 보낸 것과 견준다", "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": ""}, + "cookies": {"TECHLOG_SESSION": ""}, + "body": None, + "expect": {"status": 204, + "onConflict": "409 DOCUMENT_PUBLISHED(공개된 기록은 삭제할 수 없습니다) " + "또는 DOCUMENT_IN_USE(참조하는 곳이 있어 삭제할 수 없습니다) " + "— 둘 다 사람이 화면에서 처리한다", + "why": "시험 초안을 남기지 않는다"}, + }) _reject_forbidden_paths(steps) return steps @@ -579,7 +616,8 @@ def main() -> int: doc = build_input(args.record) if args.harness_test: 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: raise Refused("무인 저장이 꺼져 있다 — " + UNATTENDED_SAVE_BLOCKED_BY) diff --git a/scripts/tests/test_studio_save.py b/scripts/tests/test_studio_save.py index f9bda41..de3239b 100644 --- a/scripts/tests/test_studio_save.py +++ b/scripts/tests/test_studio_save.py @@ -489,3 +489,44 @@ class PublishedRecordGuardTest(unittest.TestCase): def test_the_harness_prefix_is_defined(self): """시험 초안을 사람이 눈으로 가릴 수 있어야 한다.""" 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"])