From 4db8ba17d7b1bcbf7a82fd37191d15ef1adb3527 Mon Sep 17 00:00:00 2001 From: djfksjd Date: Sat, 25 Jul 2026 18:06:14 +0900 Subject: [PATCH] Harden K-Startup coverage honesty, diff GONE gating, and key redaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-test + Codex adversarial audit findings, all with regression tests: - kstartup_api: fail closed to the crawl on duplicate/overlapping pages, id-less rows, or within-page duplicate ids — a repeated page could otherwise "prove" exhaustion while dropping the real tail (false proven=True). Exhaustion now requires distinct, non-empty ids. - diff_surveys: GONE/CLOSED is now allowed only for sources the current run proved it covered in full (run_manifest status=ok, exit 0). A partial (api-window/page-cap) or manifest-absent source has its removals suppressed (--assume-complete overrides for legacy dirs) so a partial crawl can never read as "all closed". - diff_surveys: first-time content_hash appearance is CHANGED (re-review), not UNCHANGED; profile carryover now requires ALL judgment axes; duplicate JSON keys are rejected (object_pairs_hook). - run_manifest: reject negative counts and status↔exit_code contradictions; refuse unknown manifest_schema_version (preserve as .corrupt) instead of a silent v1 rewrite; reject duplicate JSON keys on read. Tests: 220 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HNthU8pnPYv2vpEetXdK6P --- skills/ir-search/scripts/diff_surveys.py | 101 ++++++++++++++++++-- skills/ir-search/scripts/kstartup_api.py | 16 ++++ skills/ir-search/scripts/run_manifest.py | 34 ++++++- tests/test_diff_record_schema.py | 2 +- tests/test_diff_surveys.py | 112 ++++++++++++++++++++++- tests/test_kstartup_api.py | 42 +++++++-- tests/test_run_manifest.py | 33 ++++++- 7 files changed, 321 insertions(+), 19 deletions(-) diff --git a/skills/ir-search/scripts/diff_surveys.py b/skills/ir-search/scripts/diff_surveys.py index 0c62ff7..bc809ed 100644 --- a/skills/ir-search/scripts/diff_surveys.py +++ b/skills/ir-search/scripts/diff_surveys.py @@ -56,6 +56,17 @@ COMPARE_FIELDS = ["title", "apply_start", "apply_end", "status", "content_hash"] + +def _reject_dup_keys(pairs): + """object_pairs_hook — 한 레코드에 중복 키가 있으면 거부(Codex #12). + 기본 로더는 뒤값만 남겨 위조 필드가 검사를 우회한다.""" + d = {} + for k, v in pairs: + if k in d: + raise ValueError(f"중복 JSON 키: {k!r}") + d[k] = v + return d + # ir-search-profile.md bullets that define the judgment axes. If any of these # change, previous A/B/C verdicts can no longer be carried over. PROFILE_AXES = ["창업 단계", "지역 연고", "대표자", "필요한 것"] @@ -83,8 +94,8 @@ def load_dir(d: Path): if not line: continue try: - rec = json.loads(line) - except json.JSONDecodeError as e: + rec = json.loads(line, object_pairs_hook=_reject_dup_keys) + except (json.JSONDecodeError, ValueError) as e: sys.exit(f"ERROR: broken JSON at {f}:{ln} — {e}") if "kind" in rec and "record" in rec: continue # stray diff artifact record — not a raw crawl row @@ -135,6 +146,44 @@ def profile_fingerprint(fields): return hashlib.sha256(payload.encode()).hexdigest()[:16] +def gone_eligible_from_manifest(curr_dir): + """Sources that MAY yield GONE/CLOSED — only those with a proven-complete + current run. Coverage honesty: an announcement can be declared gone only when + the current run demonstrably covered that source in full. + + Reads run_manifest.json in *curr_dir* and returns (eligible, note): + - eligible: set of source names whose run is status=="ok" AND exit_code==0. + A source that is partial (api-window/page-cap/manual/inactive) OR absent + from the manifest is NOT eligible — its records must not be reported GONE. + - eligible is None when the manifest is absent OR unreadable — the caller + then suppresses ALL removals unless --assume-complete is given + (fail-closed: a partial/unknown run must never look like everything + closed — README ir §K-Startup API). + note is a human string for the summary, "" when a clean manifest was read. + """ + mpath = curr_dir / "run_manifest.json" + if not mpath.exists(): + return None, ("no run_manifest.json — coverage unproven; GONE suppressed " + "(pass --assume-complete if these were full crawls)") + try: + data = json.loads(mpath.read_text(encoding="utf-8"), + object_pairs_hook=_reject_dup_keys) + runs = data.get("runs", []) + if not isinstance(runs, list): + raise ValueError('"runs" not a list') + except (OSError, json.JSONDecodeError, ValueError, AttributeError) as e: + return None, f"run_manifest.json unreadable ({e}) — GONE suppressed" + eligible = set() + for r in runs: + if not isinstance(r, dict): + continue + if r.get("status") == "ok" and r.get("exit_code") == 0: + s = r.get("source") + if s: + eligible.add(s) + return eligible, "" + + def changed_fields(old, new): return [f for f in COMPARE_FIELDS if (old.get(f) or None) != (new.get(f) or None)] @@ -164,6 +213,11 @@ def classify(old, new): "changed_fields": ["hash_version(산식 전환 — 1회 상세 재검증)"]} if old_h and not new_h: return {"kind": "NEEDS_REHASH", "changed_fields": []} + if new_h and not old_h: + # 직전엔 해시가 없었는데 이번에 상세를 처음 수집했다 — 목록 필드가 같아도 + # 직전 판정은 상세 없이 내려졌을 수 있으므로 1회 재검토(Codex #6). + return {"kind": "CHANGED", + "changed_fields": ["content_hash(최초 상세수집 — 재검토)"]} return {"kind": "UNCHANGED", "changed_fields": []} @@ -187,6 +241,11 @@ def main(): ap.add_argument("--out", type=Path, help="write items needing review as jsonl") ap.add_argument("--old-profile", help="profile snapshot used for prev_dir") ap.add_argument("--new-profile", help="profile used for curr_dir") + ap.add_argument("--assume-complete", action="store_true", + help="treat the current run as a full crawl of every source " + "even without a run_manifest.json (legacy/manual dirs). " + "Without this AND without a proven manifest run, GONE is " + "suppressed — a partial crawl must not read as all-closed.") args = ap.parse_args() for d in (args.prev_dir, args.curr_dir): @@ -210,12 +269,15 @@ def main(): new_fields = parse_profile_bullets(args.new_profile) # 판정 축(PROFILE_AXES)이 하나도 없는 프로필은 파싱 실패와 같다 — 무관한 # 불릿만 있는 파일 두 개가 "동일 fingerprint"로 승계를 통과하면 안 된다. - old_axes = any(old_fields.get(k) for k in PROFILE_AXES) - new_axes = any(new_fields.get(k) for k in PROFILE_AXES) + # 판정 축은 하나라도 빠지면(지역만·단계만 등) 승계 근거가 불완전하다 — + # any가 아니라 ALL을 요구한다(Codex #14). 미완성 프로필은 승계 무효. + old_axes = all(old_fields.get(k) for k in PROFILE_AXES) + new_axes = all(new_fields.get(k) for k in PROFILE_AXES) if not old_fields or not new_fields or not old_axes or not new_axes: invalidate = True - print("WARNING: 프로필 파일을 읽지 못했거나 판정 축(창업 단계·지역 등)이 " - "비어 있다 — 승계 무효(fail-closed), 전체 재검토", file=sys.stderr) + print("WARNING: 프로필 파일을 읽지 못했거나 판정 축(창업 단계·지역·대표자·" + "필요한 것)이 하나라도 비어 있다 — 승계 무효(fail-closed), 전체 재검토", + file=sys.stderr) elif profile_fingerprint(old_fields) != profile_fingerprint(new_fields): invalidate = True print("WARNING: profile changed — 전체 재판정 필요 " @@ -228,8 +290,23 @@ def main(): curr_sources = {k[0] for k in curr} common = prev_sources & curr_sources + # Coverage guard (fail-closed): an announcement may be declared GONE/CLOSED + # only for a source the CURRENT run proved it covered in full (manifest + # status=ok, exit 0). A partial run (api-window/page-cap) or a source with no + # proven run cannot distinguish "closed" from "outside the collected window", + # so its removals are suppressed rather than reported as false expirations. + eligible, manifest_note = gone_eligible_from_manifest(args.curr_dir) + if eligible is None: # no/unreadable manifest → nothing proven + gone_eligible = set(common) if args.assume_complete else set() + else: + gone_eligible = {s for s in eligible if s in common} + suppressed_sources = {s for s in common if s not in gone_eligible} + new = [curr[k] for k in curr if k not in prev and k[0] in common] - closed = [prev[k] for k in prev if k not in curr and k[0] in common] + closed = [prev[k] for k in prev if k not in curr and k[0] in common + and k[0] not in suppressed_sources] + suppressed_closed = [prev[k] for k in prev if k not in curr and k[0] in common + and k[0] in suppressed_sources] results = {k: classify(prev[k], curr[k]) for k in curr if k in prev} changed = [ (prev[k], curr[k], r["changed_fields"]) @@ -269,6 +346,16 @@ def main(): for r in closed: print(f" - [{r.get('source')}] {r.get('title', '(no title)')}") + if manifest_note: + print(f"\n## COVERAGE NOTE — {manifest_note}") + if suppressed_closed: + srcs = ", ".join(sorted({r.get('source') for r in suppressed_closed})) + print(f"\n## CLOSED SUPPRESSED ({len(suppressed_closed)}) — current run for " + f"[{srcs}] was partial (api-window/page-cap/manual): absence is NOT " + "concluded GONE. Re-run a full crawl to confirm expirations.") + for r in suppressed_closed: + print(f" · [{r.get('source')}] {r.get('title', '(no title)')}") + if invalidate: print(f"\n## UNCHANGED: {unchanged} items — 승계 불가(프로필 변경), 전건 재검토") else: diff --git a/skills/ir-search/scripts/kstartup_api.py b/skills/ir-search/scripts/kstartup_api.py index bf47b04..da5c8ca 100644 --- a/skills/ir-search/scripts/kstartup_api.py +++ b/skills/ir-search/scripts/kstartup_api.py @@ -415,6 +415,7 @@ def list_announcements(key, min_expected=1, per_page=PER_PAGE, max_pages=MAX_PAG """ redact = _make_redactor(key) out = {} + seen_ids = set() # every id seen across pages — overlap breaks the position proof total = None zero_open_streak = 0 page = 1 @@ -444,6 +445,21 @@ def list_announcements(key, min_expected=1, per_page=PER_PAGE, max_pages=MAX_PAG # a mixed container (valid dicts + junk) is a schema change, and the # junk still counts toward totalCount — fail closed to the crawl. raise ApiError("mixed unparseable records; using crawl") + # Exhaustion is proved by scanned ROW POSITIONS reaching totalCount, so + # every scanned position must map to a *distinct real id*. If a row has + # no id, or an id repeats within this page, or overlaps an id already + # seen on a prior page (duplicate/overlapping pagination), the position + # counter overshoots while unique coverage stalls — we'd declare `proven` + # early and silently drop the real tail. Any of these fails closed to the + # crawl (the exhaustive-coverage authority). + page_ids = [_pick(r, ("pbanc_sn", "biz_pbanc_sn", "pbancSn")) for r in recs] + if not all(page_ids): + raise ApiError("record without id; cannot prove coverage; using crawl") + if len(set(page_ids)) != len(page_ids): + raise ApiError("duplicate ids within a page; using crawl") + if seen_ids.intersection(page_ids): + raise ApiError("overlapping pages (duplicate ids); using crawl") + seen_ids.update(page_ids) open_on_page = 0 for r in recs: if not _is_open(r): diff --git a/skills/ir-search/scripts/run_manifest.py b/skills/ir-search/scripts/run_manifest.py index 4615e3a..542b31b 100644 --- a/skills/ir-search/scripts/run_manifest.py +++ b/skills/ir-search/scripts/run_manifest.py @@ -43,6 +43,17 @@ MANIFEST_SCHEMA_VERSION = 1 MANIFEST_NAME = "run_manifest.json" + + +def _reject_dup_keys(pairs): + """object_pairs_hook — 중복 키 거부(Codex #12). 중복 manifest_schema_version/ + status가 #10 검증을 우회하지 못하게 매니페스트 로드에도 적용한다.""" + d = {} + for k, v in pairs: + if k in d: + raise ValueError(f"duplicate JSON key: {k!r}") + d[k] = v + return d KST = timezone(timedelta(hours=9)) VALID_STATUS = ("ok", "partial", "manual", "inactive") @@ -52,6 +63,20 @@ def make_run(source, status, exit_code, pages_fetched, collected, stop_reason, """Build one schema-v1 run entry. Counts/status only — no content.""" if status not in VALID_STATUS: raise ValueError(f"invalid status {status!r} (expected one of {VALID_STATUS})") + # 정합성 계약(Codex #10): 상태와 종료코드는 모순될 수 없다. ok는 성공(0), + # 그 외(partial/manual/inactive)는 반드시 비-0이어야 한다. 음수 카운트도 거부. + exit_code = int(exit_code) + if status == "ok" and exit_code != 0: + raise ValueError(f"status=ok인데 exit_code={exit_code} (0이어야 함)") + if status != "ok" and exit_code == 0: + raise ValueError(f"status={status!r}인데 exit_code=0 (비-0이어야 함)") + for label, val in (("pages_fetched", pages_fetched), ("collected", collected)): + if int(val) < 0: + raise ValueError(f"{label}가 음수({val}) — 불가") + if reported_total is not None and int(reported_total) < 0: + raise ValueError(f"reported_total 음수({reported_total}) — 불가") + if duplicates is not None and int(duplicates) < 0: + raise ValueError(f"duplicates 음수({duplicates}) — 불가") run = { "source": source, "status": status, @@ -84,9 +109,16 @@ def update_manifest(output_path, new_runs): if os.path.exists(path): try: with open(path, encoding="utf-8") as f: - old = json.load(f) + old = json.load(f, object_pairs_hook=_reject_dup_keys) if not isinstance(old, dict): raise ValueError("manifest top level is not a JSON object") + # 알 수 없는 스키마 버전을 조용히 v1로 덮어쓰지 않는다(Codex #10) — + # 미래/손상 버전은 corrupt 경로로 보존한 뒤 새로 시작한다. + old_ver = old.get("manifest_schema_version") + if old_ver != MANIFEST_SCHEMA_VERSION: + raise ValueError( + f"unsupported manifest_schema_version {old_ver!r} " + f"(this writer emits v{MANIFEST_SCHEMA_VERSION})") old_runs = old.get("runs", []) if not isinstance(old_runs, list): raise ValueError('"runs" is not a list') diff --git a/tests/test_diff_record_schema.py b/tests/test_diff_record_schema.py index 31195ea..a30499d 100644 --- a/tests/test_diff_record_schema.py +++ b/tests/test_diff_record_schema.py @@ -166,7 +166,7 @@ def biz(i, title, **extra): write_jsonl(curr / "bizinfo.jsonl", [biz(1, "그대로")]) out = tmp_path / "new_items.jsonl" monkeypatch.setattr("sys.argv", ["diff_surveys.py", str(prev), str(curr), - "--out", str(out)]) + "--out", str(out), "--assume-complete"]) diff_surveys.main() lines = [] diff --git a/tests/test_diff_surveys.py b/tests/test_diff_surveys.py index 254d47b..3d034b7 100644 --- a/tests/test_diff_surveys.py +++ b/tests/test_diff_surveys.py @@ -34,7 +34,10 @@ def test_new_changed_closed_classification(diff_surveys, monkeypatch, tmp_path, ks(4, "제목이 바뀐 공고", deadline="2026-08-15"), ]) out = tmp_path / "new_items.jsonl" - run_diff(diff_surveys, monkeypatch, [str(prev), str(curr), "--out", str(out)]) + # no manifest here → assert-complete signals these were full crawls so GONE + # is authorized (coverage-honesty default otherwise suppresses removals) + run_diff(diff_surveys, monkeypatch, + [str(prev), str(curr), "--out", str(out), "--assume-complete"]) text = capsys.readouterr().out assert "## NEW (1)" in text and "새로 뜬 공고" in text assert "## CHANGED (1)" in text and "title" in text and "apply_end" in text @@ -215,3 +218,110 @@ def test_gone_file_not_loaded_as_raw_crawl(diff_surveys, monkeypatch, tmp_path): out = tmp_path / "out.jsonl" run_diff(diff_surveys, monkeypatch, [str(prev), str(curr), "--out", str(out)]) assert out.read_text(encoding="utf-8") == "" # 소멸 잔재가 NEW로 안 뜬다 + + +def test_partial_current_run_suppresses_closed(diff_surveys, monkeypatch, tmp_path, + capsys): + """현재 run_manifest.json이 kstartup을 partial(api-window)로 표기하면, 이전에 + 있던 공고의 부재를 CLOSED로 결론짓지 않는다 — partial 수집을 '전부 소멸'로 + 오인하지 않는 커버리지 정직성 계약(Codex #2).""" + prev, curr = tmp_path / "prev", tmp_path / "curr" + write_jsonl(prev / "kstartup.jsonl", [ks(101, "a"), ks(102, "b")]) + write_jsonl(curr / "kstartup.jsonl", [ks(101, "a")]) # 102 부재 + (curr / "run_manifest.json").write_text(json.dumps({ + "manifest_schema_version": 1, "generated_at": "2026-07-25T00:00:00+09:00", + "runs": [{"source": "kstartup", "status": "partial", "exit_code": 2, + "pages_fetched": 1, "collected": 1, "stop_reason": "api-window"}], + }), encoding="utf-8") + out = tmp_path / "out.jsonl" + run_diff(diff_surveys, monkeypatch, [str(prev), str(curr), "--out", str(out)]) + o = capsys.readouterr().out + assert "## CLOSED (0)" in o + assert "CLOSED SUPPRESSED" in o + assert (tmp_path / "gone_out.jsonl").read_text(encoding="utf-8") == "" + + +def test_source_absent_from_manifest_suppresses_closed(diff_surveys, monkeypatch, + tmp_path, capsys): + """현재 매니페스트에 kstartup 실행 기록이 아예 없으면(다른 소스만 ok) kstartup + 커버리지가 증명되지 않았으므로 kstartup 공고 부재를 CLOSED로 결론짓지 + 않는다 — GONE은 '전수 증명된 소스'에만 허용(Codex #2 residual).""" + prev, curr = tmp_path / "prev", tmp_path / "curr" + write_jsonl(prev / "kstartup.jsonl", [ks(101, "a"), ks(102, "b")]) + write_jsonl(curr / "kstartup.jsonl", [ks(101, "a")]) # 102 부재 + (curr / "run_manifest.json").write_text(json.dumps({ + "manifest_schema_version": 1, "generated_at": "2026-07-25T00:00:00+09:00", + "runs": [{"source": "bizinfo", "status": "ok", "exit_code": 0, + "pages_fetched": 1, "collected": 1, "stop_reason": "done"}], + }), encoding="utf-8") # kstartup 항목 없음 + out = tmp_path / "out.jsonl" + run_diff(diff_surveys, monkeypatch, [str(prev), str(curr), "--out", str(out)]) + o = capsys.readouterr().out + assert "## CLOSED (0)" in o + assert "CLOSED SUPPRESSED" in o + assert (tmp_path / "gone_out.jsonl").read_text(encoding="utf-8") == "" + + +def test_ok_current_run_reports_closed(diff_surveys, monkeypatch, tmp_path, capsys): + """반대로 현재 run이 ok(전수 증명)면 102 부재는 정상적으로 CLOSED.""" + prev, curr = tmp_path / "prev", tmp_path / "curr" + write_jsonl(prev / "kstartup.jsonl", [ks(101, "a"), ks(102, "b")]) + write_jsonl(curr / "kstartup.jsonl", [ks(101, "a")]) + (curr / "run_manifest.json").write_text(json.dumps({ + "manifest_schema_version": 1, "generated_at": "2026-07-25T00:00:00+09:00", + "runs": [{"source": "kstartup", "status": "ok", "exit_code": 0, + "pages_fetched": 3, "collected": 1, "stop_reason": "api"}], + }), encoding="utf-8") + out = tmp_path / "out.jsonl" + run_diff(diff_surveys, monkeypatch, [str(prev), str(curr), "--out", str(out)]) + o = capsys.readouterr().out + assert "## CLOSED (1)" in o + gone = [x for x in (tmp_path / "gone_out.jsonl").read_text().splitlines() if x.strip()] + assert len(gone) == 1 + + +def test_first_hash_appearance_is_changed(diff_surveys, monkeypatch, tmp_path, capsys): + """직전엔 해시가 없다가 이번에 상세를 처음 수집 → CHANGED 재검토(Codex #6).""" + prev, curr = tmp_path / "prev", tmp_path / "curr" + write_jsonl(prev / "kstartup.jsonl", [ks(1, "공고")]) # 해시 없음 + rec = ks(1, "공고"); rec["content_hash"] = "h1"; rec["hash_version"] = "v3" + write_jsonl(curr / "kstartup.jsonl", [rec]) + out = tmp_path / "out.jsonl" + run_diff(diff_surveys, monkeypatch, + [str(prev), str(curr), "--out", str(out), "--assume-complete"]) + assert "## CHANGED (1)" in capsys.readouterr().out + (r,) = [json.loads(x) for x in out.read_text(encoding="utf-8").splitlines()] + assert r["kind"] == "CHANGED" and any("최초 상세수집" in c for c in r["changed_fields"]) + + +def test_duplicate_json_keys_rejected(diff_surveys, monkeypatch, tmp_path): + """한 줄에 중복 JSON 키(위조 필드)면 즉시 실패(Codex #12).""" + prev, curr = tmp_path / "prev", tmp_path / "curr" + write_jsonl(prev / "kstartup.jsonl", [ks(1, "a")]) + (curr / "kstartup.jsonl").parent.mkdir(parents=True, exist_ok=True) + (curr / "kstartup.jsonl").write_text( + '{"pbancSn":"1","pbancSn":"9","title":"x","deadline":"2026-08-01"}\n', + encoding="utf-8") + with pytest.raises(SystemExit) as e: + run_diff(diff_surveys, monkeypatch, [str(prev), str(curr), "--out", + str(tmp_path / "o.jsonl")]) + assert e.value.code != 0 + + +def test_incomplete_profile_invalidates_carryover(diff_surveys, monkeypatch, + tmp_path, capsys): + """판정 축이 하나라도 빠진 프로필은 승계 무효 — 전건 재검토(Codex #14).""" + prev, curr = tmp_path / "prev", tmp_path / "curr" + write_jsonl(prev / "kstartup.jsonl", [ks(1, "a")]) + write_jsonl(curr / "kstartup.jsonl", [ks(1, "a")]) + full = tmp_path / "full.md" + full.write_text("- 창업 단계: 예비창업\n- 지역 연고: 충남\n- 대표자: 만 39세 이하\n" + "- 필요한 것: 자금\n", encoding="utf-8") + partial = tmp_path / "partial.md" # 대표자 축 누락 + partial.write_text("- 창업 단계: 예비창업\n- 지역 연고: 충남\n- 필요한 것: 자금\n", + encoding="utf-8") + run_diff(diff_surveys, monkeypatch, + [str(prev), str(curr), "--out", str(tmp_path / "o.jsonl"), + "--old-profile", str(full), "--new-profile", str(partial), + "--assume-complete"]) + assert "CARRY-OVER INVALIDATED" in capsys.readouterr().out diff --git a/tests/test_kstartup_api.py b/tests/test_kstartup_api.py index d98fee7..89c998c 100644 --- a/tests/test_kstartup_api.py +++ b/tests/test_kstartup_api.py @@ -314,7 +314,12 @@ def test_list_below_min_raises(kstartup_api, monkeypatch): kstartup_api.list_announcements("key", min_expected=p + 100) -def test_list_duplicate_page_does_not_early_stop(kstartup_api, monkeypatch): +def test_list_duplicate_page_fails_closed_to_crawl(kstartup_api, monkeypatch): + # A repeated page makes the position counter (scanned) overshoot while unique + # coverage stalls: scanning positions 0..3p while only ids 0..2p exist would + # declare `proven` one page early and silently drop the real tail [2p..3p). + # Overlap must fail closed to the crawl (the exhaustive-coverage authority), + # never claim proven exhaustion — the coverage-honesty contract. p = kstartup_api.PER_PAGE tot = 3 * p page1 = {"totalCount": tot, "data": [_open(i) for i in range(p)]} @@ -323,19 +328,44 @@ def test_list_duplicate_page_does_not_early_stop(kstartup_api, monkeypatch): monkeypatch.setattr( kstartup_api, "_fetch_page", _fake_pages([page1, page2, page3]) ) - recs, _t, _pg, proven = kstartup_api.list_announcements("key", min_expected=1) - assert len(recs) == 2 * p # dup page must NOT have stopped collection - assert proven is True # scanned 3*PER_PAGE == totalCount + with pytest.raises(kstartup_api.ApiError): + kstartup_api.list_announcements("key", min_expected=1) + + +def test_list_within_page_duplicate_ids_fails_closed(kstartup_api, monkeypatch): + # A single page whose rows repeat an id (totalCount=2, rows [A, A]) would + # dedup to one record while scanned positions reach totalCount → false + # proven exhaustion. Must fail closed to the crawl (Codex #3 residual). + dup = {"totalCount": 2, "data": [_open("A"), _open("A")]} + monkeypatch.setattr(kstartup_api, "_fetch_page", _fake_pages([dup])) + with pytest.raises(kstartup_api.ApiError): + kstartup_api.list_announcements("key", min_expected=1) + + +def test_list_record_without_id_fails_closed(kstartup_api, monkeypatch): + # If a scanned row carries no id we cannot prove it is distinct coverage; + # fail closed rather than count a position we cannot verify (Codex #3). + page = {"totalCount": 2, "data": [_open("A"), {"biz_pbanc_nm": "no id", + "pbanc_rcpt_end_dt": "20991231", "pbanc_rcpt_bgng_dt": "20260101"}]} + monkeypatch.setattr(kstartup_api, "_fetch_page", _fake_pages([page])) + with pytest.raises(kstartup_api.ApiError): + kstartup_api.list_announcements("key", min_expected=1) def test_list_stops_after_zero_open_streak(kstartup_api, monkeypatch): p = kstartup_api.PER_PAGE stop = kstartup_api.ZERO_OPEN_STOP openpage = {"data": [_open(i) for i in range(p)]} - closed = {"data": [{"pbanc_sn": f"c{i}", "rcrt_prgs_yn": "N"} for i in range(p)]} + # each all-closed page carries DISTINCT ids (real pagination never repeats a + # record across pages — identical ids would be an overlap anomaly, caught + # separately by test_list_duplicate_page_fails_closed_to_crawl) + closed_pages = [ + {"data": [{"pbanc_sn": f"c{pg}_{i}", "rcrt_prgs_yn": "N"} for i in range(p)]} + for pg in range(stop) + ] # one open page, then exactly ZERO_OPEN_STOP all-closed pages -> stop monkeypatch.setattr( - kstartup_api, "_fetch_page", _fake_pages([openpage] + [closed] * stop) + kstartup_api, "_fetch_page", _fake_pages([openpage] + closed_pages) ) recs, _t, pages, proven = kstartup_api.list_announcements("key", min_expected=1) assert len(recs) == p diff --git a/tests/test_run_manifest.py b/tests/test_run_manifest.py index 4686b31..b47875e 100644 --- a/tests/test_run_manifest.py +++ b/tests/test_run_manifest.py @@ -36,9 +36,20 @@ def test_make_run_optional_fields_omitted(run_manifest): assert run["errors"] == ["HTTP 500"] -@pytest.mark.parametrize("status", ["ok", "partial", "manual", "inactive"]) -def test_all_status_values_accepted(run_manifest, status): - assert run_manifest.make_run("x", status, 0, 0, 0, "s")["status"] == status +# status↔exit_code 정합 계약: ok=0, 그 외는 비-0 (partial=2/manual=3/inactive=4) +@pytest.mark.parametrize("status,code", [("ok", 0), ("partial", 2), + ("manual", 3), ("inactive", 4)]) +def test_all_status_values_accepted(run_manifest, status, code): + assert run_manifest.make_run("x", status, code, 0, 0, "s")["status"] == status + + +def test_status_exit_code_mismatch_rejected(run_manifest): + with pytest.raises(ValueError): + run_manifest.make_run("x", "ok", 2, 0, 0, "s") # ok인데 비-0 + with pytest.raises(ValueError): + run_manifest.make_run("x", "partial", 0, 0, 0, "s") # 비-ok인데 0 + with pytest.raises(ValueError): + run_manifest.make_run("x", "ok", 0, -1, 0, "s") # 음수 카운트 def test_invalid_status_rejected(run_manifest): @@ -130,3 +141,19 @@ def test_update_manifest_valid_manifest_not_flagged_as_recovered(run_manifest, t m = read_manifest(tmp_path) assert "recovered_from_corrupt" not in m assert not [p for p in os.listdir(tmp_path) if ".corrupt-" in p] + + +def test_manifest_reader_rejects_duplicate_keys(run_manifest, tmp_path): + """기존 매니페스트에 중복 키(예: manifest_schema_version 2개)가 있으면 #10 + 검증을 우회하지 못하게 corrupt 처리 후 새로 시작한다(Codex #12).""" + out = tmp_path / "kstartup_all.jsonl" + (tmp_path / "run_manifest.json").write_text( + '{"manifest_schema_version":999,"manifest_schema_version":1,"runs":[]}', + encoding="utf-8") + run = run_manifest.make_run("kstartup", "ok", 0, 1, 1, "done") + run_manifest.update_manifest(str(out), [run]) # 예외 없이 corrupt 보존+재작성 + data = read_manifest(tmp_path) + assert data["manifest_schema_version"] == 1 + assert "recovered_from_corrupt" in data + assert any(p.name.startswith("run_manifest.json.corrupt-") + for p in tmp_path.iterdir())