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:
co-authored by
Claude Opus 5
parent
f16785b93e
commit
c66eee6cb2
+40
-2
@@ -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)
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user