fix(studio-save): 응답 껍데기를 벗기는 자리를 코드가 갖고, 계획마다 새 키가 나게 한다
① GET /documents/{id} 는 문서를 data.document 아래에 둔다(studio-v1.yaml:1831). A 가
B-011 에서 data 를 문서로 보고 견줘 칸이 전부 「다르다」로 나왔다 — 벗기는 자리를
코드가 갖고 있지 않아서 난 일이라 unwrap_document 를 둔다. 모양이 다르면 조용히
넘기지 않고 거절한다. 잘못된 층을 견주는 것보다 멈추는 편이 낫다.
그리고 updatedAt 을 서버가 붙이는 칸으로 옮겼다. 매 저장마다 「서버가 더 줬다」로
나오면 그 칸을 아무도 안 읽게 된다. variantIds 는 안 옮겼다 — 계약이 입력으로 받는
칸인데 이 어댑터가 안 보내므로 보여야 한다.
② CR-007 때문에 같은 키를 두 번 못 쓴다. 런 식별자를 계획 파일에 적고 키에 섞는다.
같은 계획을 두 번 보내면 같은 키, 계획을 새로 만들면 다른 키다. 보낼 때 만들면
재시도가 곧 중복 생성이 되므로 만드는 자리는 계획을 짜는 순간 하나뿐이다.
--run-id 를 안 주면 예전 키 모양 그대로라 옛 계획을 안 깬다.
python3 -m unittest discover -s scripts/tests — Ran 291 · OK (skipped=13)
PIPELINE CONTRACT: PASS
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
1be1c8ea6d
commit
d473609e0a
@@ -7,10 +7,18 @@ import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
# 런 식별자 회귀가 실제로 계획을 내 보려고 쓰는 입력. 관문이 전부 exit 0 인 프로젝트다
|
||||
DECISION_RECORD = os.path.join(
|
||||
"docs", "n+1liner", "tech-log-studio", "jpa-feed-query-performance", "decision",
|
||||
"decision-keyset-for-feed-pagination.md")
|
||||
PACKAGE = "/home/donghyeon/workspace/chat-gpt-container/success/reports/"\
|
||||
"B-B011-plans/package.json"
|
||||
PROJECT_ID = "8ff68a93-0cf6-4d91-a973-40e9f3d3e99d"
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"studio_save", os.path.join(ROOT, "scripts", "studio-save.py"))
|
||||
ss = importlib.util.module_from_spec(_spec)
|
||||
@@ -902,6 +910,78 @@ class HarnessTestPlanTest(unittest.TestCase):
|
||||
q = {"kind": "QUESTION", "facts": [{"id": "a", "text": "t", "order": 0}]}
|
||||
self.assertNotIn("itemIdBehaviourUnmeasured", ss.compare_saved(q, dict(q)))
|
||||
|
||||
def test_the_envelope_is_unwrapped_before_comparing(self):
|
||||
"""`GET /documents/{id}` 는 문서를 `data.document` 아래에 둔다
|
||||
(`studio-v1.yaml:1831`). A 가 `data` 를 문서로 보고 견줘 칸이 전부 「다르다」로
|
||||
나왔다 — **껍데기를 벗기는 자리를 코드가 갖고 있지 않아서 난 일이다.**"""
|
||||
doc = {"kind": "PROJECT_DECISION", "title": "t", "statement": "s"}
|
||||
env = {"success": True, "meta": {},
|
||||
"data": {"document": doc, "currentPublication": None,
|
||||
"currentValidation": None, "latestPreview": None,
|
||||
"dependencyRevision": {}, "nextAction": "VALIDATE"}}
|
||||
got, detail = ss.unwrap_document(env)
|
||||
self.assertEqual(doc, got)
|
||||
self.assertTrue(ss.is_private(detail)[0])
|
||||
# 벗기면 같고, 안 벗기면 전부 다르다
|
||||
self.assertTrue(ss.compare_saved(doc, got)["same"])
|
||||
self.assertFalse(ss.compare_saved(doc, env["data"])["same"])
|
||||
# `data` 만 줘도 받는다
|
||||
self.assertEqual(doc, ss.unwrap_document(env["data"])[0])
|
||||
|
||||
def test_an_unexpected_envelope_is_refused_not_compared(self):
|
||||
"""모양이 다르면 조용히 넘기지 않는다. 잘못된 층을 견주는 것보다 멈추는 편이 낫다 —
|
||||
그 실수는 「전부 다르다」로 크게 나와 줄 때만 눈에 띈다."""
|
||||
for bad in ({"success": True, "data": {"nextAction": "X"}},
|
||||
{"success": True, "data": {"document": "문자열이다"}},
|
||||
"객체가 아니다"):
|
||||
with self.subTest(bad=str(bad)[:32]):
|
||||
with self.assertRaises(ss.Refused):
|
||||
ss.unwrap_document(bad)
|
||||
|
||||
def test_server_assigned_fields_are_not_reported_as_unexpected(self):
|
||||
"""`updatedAt` 은 서버가 붙인다. 매 저장마다 「서버가 더 줬다」로 나오면
|
||||
그 칸을 아무도 안 읽게 된다. **`variantIds` 는 다르다** — 계약이 입력으로 받는데
|
||||
이 어댑터가 안 보내는 칸이라 보여야 한다."""
|
||||
sent = {"kind": "CASE", "title": "t"}
|
||||
got = {**sent, "id": "x", "version": 2, "updatedAt": "2026-09-11T00:00:00Z",
|
||||
"variantIds": []}
|
||||
self.assertEqual(["variantIds"], ss.compare_saved(sent, got)["unexpected"])
|
||||
|
||||
def test_a_new_plan_gets_a_new_key_but_one_plan_keeps_its_own(self):
|
||||
"""`CR-007` 때문에 같은 키를 두 번 못 쓴다. 그래서 계획을 새로 만들면 새 키여야
|
||||
한다 — 그런데 **보낼 때 만들면 재시도가 곧 중복 생성이 된다.**
|
||||
런 식별자를 계획에 적어 그 둘을 함께 얻는다."""
|
||||
rel = "docs/p/t/case/x.md"
|
||||
a = ss.idempotency_key("create", rel, None, "run-aaa")
|
||||
b = ss.idempotency_key("create", rel, None, "run-bbb")
|
||||
self.assertNotEqual(a, b) # 계획이 새로 만들어지면 새 키
|
||||
self.assertEqual(a, ss.idempotency_key("create", rel, None, "run-aaa"))
|
||||
# 같은 계획을 두 번 보내는 것은 같은 값을 두 번 쓰는 것이다
|
||||
self.assertLessEqual(len(a), ss.MAX_KEY_LENGTH)
|
||||
# 런 식별자를 안 주면 예전 모양 그대로다 — 옛 계획을 깨지 않는다
|
||||
self.assertEqual(ss.idempotency_key("create", rel),
|
||||
ss.idempotency_key("create", rel, None, None))
|
||||
|
||||
def test_the_plan_records_its_run_id(self):
|
||||
"""**보낼 때 만들지 않는다.** 계획 파일에 적혀 있어야 같은 계획이 같은 키를 쓴다."""
|
||||
if not os.path.isfile(PACKAGE):
|
||||
# 묶음은 worktree 밖에 있다. 없으면 **안 돌았다고 적고 넘어간다** —
|
||||
# 조용히 통과시키면 이 회귀가 초록인 채로 아무것도 안 재게 된다
|
||||
self.skipTest(f"묶음이 없어 계획을 못 낸다: {PACKAGE}")
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
out = os.path.join(d, "plan.json")
|
||||
r = subprocess.run(
|
||||
[sys.executable, "scripts/studio-save.py", "--record", DECISION_RECORD,
|
||||
"--package", PACKAGE, "--harness-test", "--project-id", PROJECT_ID,
|
||||
"--run-id", "run-fixed-for-the-test", "-o", out],
|
||||
cwd=ROOT, capture_output=True, text=True)
|
||||
self.assertEqual(0, r.returncode, r.stderr)
|
||||
plan = json.load(open(out, encoding="utf-8"))
|
||||
self.assertEqual("run-fixed-for-the-test", plan["runId"])
|
||||
key = next(s["headers"]["Idempotency-Key"] for s in plan["requests"]
|
||||
if s["op"] == "create")
|
||||
self.assertTrue(key.endswith(ss._sha256_text("run-fixed-for-the-test")[:12]))
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user