#!/usr/bin/env python3 """升格作品备份脚本的纯离线回归测试:不连接数据库、不调用模型。""" import datetime as dt import json import pathlib import shutil import sys import uuid from contextlib import contextmanager from decimal import Decimal from unittest import mock PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] SCRIPT_DIR = PROJECT_ROOT / "muse" / "content" / "entity" / "skills" / "ingest" / "备份作品抽取结果" / "scripts" sys.path.insert(0, str(SCRIPT_DIR)) import backup_upgrade_work as backup # noqa: E402 class _Result: """模拟 psycopg 查询结果,仅暴露脚本使用的读取接口。""" def __init__(self, rows): self.rows = rows def fetchone(self): return self.rows[0] if self.rows else None def fetchall(self): return list(self.rows) class _OfflineConnection: """按表名返回固定数据,并记录完整 SQL 与连接关闭时机。""" def __init__(self): self.closed = False self.executions = [] self.rows = { "drafts": [ { "id": 2, "work_id": 8, "source_type": "upgrade_book", "confidence": Decimal("0.8200"), "draft_payload": {"名称": "乙"}, "create_time": dt.datetime(2026, 7, 21, 9, 1, 2, 3000), "deleted": True, "tenant_id": 1, }, { "id": 1, "work_id": 8, "source_type": "upgrade_book", "confidence": Decimal("0.90"), "draft_payload": {"名称": "甲"}, "create_time": dt.datetime(2026, 7, 21, 9, 1, 1), "deleted": False, "tenant_id": 1, }, ], "windows": [ {"id": 12, "work_id": 8, "window_no": 2, "from_chapter": 2, "to_chapter": 2, "deleted": False, "tenant_id": 1}, {"id": 11, "work_id": 8, "window_no": 1, "from_chapter": 1, "to_chapter": 1, "deleted": False, "tenant_id": 1}, ], "aliases": [{"id": 21, "work_id": 8, "alias": "阿甲", "tenant_id": 1}], "presence": [{"id": 31, "work_id": 8, "name": "路人", "tenant_id": 1}], "card_state": [{"draft_id": 2, "work_id": 8, "watermark_window": 2, "tenant_id": 1}], "audits": [{"id": 41, "draft_id": 2, "window_no": 2, "tenant_id": 1}], "embeddings": [{"id": 51, "draft_id": 2, "embedding": "[0.1,0.20]", "content_hash": "a" * 64, "tenant_id": 1}], } def __enter__(self): return self def __exit__(self, exc_type, exc, traceback): self.closed = True def execute(self, sql, params=None): normalized = " ".join(sql.split()).lower() self.executions.append((normalized, params)) if normalized.startswith("begin") or normalized in {"commit", "rollback"}: return _Result([]) if "current_database()" in normalized: return _Result([{"database": "muse-example", "server_addr": "100.64.0.8", "server_port": 5433, "server_version": "160000"}]) if "from muse_content_chapter c" in normalized: return _Result([ {"chapter_id": 1, "chapter_order": 1, "chapter_title": "一", "chapter_revision": 1, "block_id": 101, "block_order": 1, "block_type": "scene", "block_title": None, "content_doc": None, "content_text": "正文一", "block_revision": 1}, {"chapter_id": 2, "chapter_order": 2, "chapter_title": "二", "chapter_revision": 1, "block_id": 102, "block_order": 1, "block_type": "scene", "block_title": None, "content_doc": None, "content_text": "正文二", "block_revision": 1}, ]) if "from muse_meta_schema s" in normalized: return _Result([ {"schema_key": key, "active_version_id": index, "field_contract_snapshot": {"key": key}} for index, key in enumerate(sorted(backup.UPGRADE_SCHEMA_KEYS), start=1) ]) if "from muse_content_work" in normalized: return _Result([{"title": "离线测试书"}]) table_to_domain = { "from muse_knowledge_draft d": "drafts", "from example_upgrade_window w": "windows", "from example_upgrade_alias a": "aliases", "from example_upgrade_presence p": "presence", "from example_upgrade_card_state s": "card_state", "from example_upgrade_audit a": "audits", "from example_knowledge_embedding e": "embeddings", } for marker, domain in table_to_domain.items(): if marker in normalized: return _Result(self.rows[domain]) raise AssertionError(f"未覆盖的离线 SQL:{normalized}") class _RestoreConnection: """记录恢复事务 SQL;查询默认返回空集合,便于只验证恢复边界。""" def __init__(self, sequence_last=100, table_max=50, extra_window=False): self.closed = False self.executions = [] self.sequence_last = sequence_last self.table_max = table_max self.extra_window = extra_window def __enter__(self): return self def __exit__(self, exc_type, exc, traceback): self.closed = True def execute(self, sql, params=None): normalized = " ".join(sql.split()).lower() self.executions.append((normalized, params)) if "from pg_sequences" in normalized: return _Result([{"last_value": self.sequence_last}]) if "select max(id) as max_id" in normalized: return _Result([{"max_id": self.table_max}]) if "pg_get_serial_sequence" in normalized: table = params[0] if params else "unknown" return _Result([{"sequence_name": f"public.{table}_id_seq"}]) if "from example_upgrade_window w" in normalized and "not (w.id = any" in normalized: return _Result([{"id": 999}] if self.extra_window else []) return _Result([]) def _new_backup_dir(): """返回 /private/tmp 下尚不存在的测试目录。""" return pathlib.Path("/private/tmp") / f"backup-upgrade-offline-{uuid.uuid4()}" def _code_identity(): """离线测试固定代码身份,避免读取工作区状态影响摘要。""" return { "gitCommit": "0123456789abcdef", "gitHeadCoversCodeFiles": False, "codeFiles": { "backup_upgrade_work.py": "b" * 64, "parse_upgrade.py": "u" * 64, "parse_llm.py": "p" * 64, "embed_drafts.py": "e" * 64, "upgrade_work_lock.py": "k" * 64, "llm.py": "l" * 64, "parse-book/SKILL.md": "s" * 64, }, "headMatches": {}, } @contextmanager def _no_lock(*_args, **_kwargs): """离线测试锁替身,不连接 PostgreSQL。""" yield (1, 2) def _check(name, condition): """沿用 parse-book 离线测试的简明输出格式。""" if not condition: raise AssertionError(name) print(f"[PASS] {name}") def test_stable_sort_and_json_types(): """相同主键集合不受查询返回顺序影响,特殊数据库类型可稳定 JSON 化。""" rows = [ {"id": 10, "at": dt.datetime(2026, 7, 21, 8, 0), "score": Decimal("1.2300")}, {"id": 2, "at": dt.date(2026, 7, 20), "score": Decimal("0.10")}, ] ordered = backup.sort_rows("drafts", rows) _check("排序稳定-按域主键升序", [row["id"] for row in ordered] == [2, 10]) encoded = json.loads(backup.canonical_json(ordered[1])) _check("JSON类型-datetime转ISO字符串", encoded["at"] == "2026-07-21T08:00:00") _check("JSON类型-Decimal保留精度字符串", encoded["score"] == "1.2300") vector = json.loads(backup.canonical_json({"embedding": [Decimal("0.10"), Decimal("-2.0")]})) _check("JSON类型-vector元素稳定序列化", vector["embedding"] == ["0.10", "-2.0"]) def test_path_guards(): """输出只能是 /private/tmp 下的新目录。""" for invalid in ("/tmp/not-explicit", "/private/tmp", "/private/tmp/../etc/backup"): try: backup.validate_new_output_dir(pathlib.Path(invalid)) except ValueError: pass else: raise AssertionError(f"路径越界未拒绝:{invalid}") _check("路径越界-全部拒绝", True) existing = _new_backup_dir() existing.mkdir(mode=0o700) try: try: backup.validate_new_output_dir(existing) except FileExistsError: _check("重复目录-拒绝覆盖", True) else: raise AssertionError("已存在目录未拒绝") finally: existing.rmdir() def test_backup_transaction_queries_and_round_trip(): """七域在单个只读一致性事务中读取,关库后再从磁盘复验。""" output = _new_backup_dir() conn = _OfflineConnection() original_verify = backup.verify_backup def verify_after_close(path): if not conn.closed: raise AssertionError("数据库连接尚未关闭就开始磁盘复验") return original_verify(path) try: with mock.patch.object(backup, "verify_backup", side_effect=verify_after_close): manifest = backup.backup_work( work_id=8, output_dir=output, tenant=1, dsn="postgresql://user:secret@example.invalid/db", connect=lambda *_args, **_kwargs: conn, git_commit="0123456789abcdef", code_identity=_code_identity(), expected_chapters=2, expected_windows=2, lock_context=_no_lock, ) _check("事务-连接关闭后复验", conn.closed) statements = [sql for sql, _params in conn.executions] _check("事务-仅一个REPEATABLE READ READ ONLY", statements.count( "begin isolation level repeatable read read only") == 1) _check("事务-正常提交一次", statements.count("commit") == 1) _check("事务-无数据库写语句", not any( sql.startswith(("insert ", "update ", "delete ", "alter ", "drop ")) for sql in statements )) _check("七域-文件与清单齐全", set(manifest["domains"]) == set(backup.DOMAIN_SPECS)) # 作品标题一条 SELECT,加七域各一条 SELECT;draft 子查询不应被误算成独立域查询。 _check("七域与input-各查询只执行一次", sum(sql.startswith("select") for sql in statements) == 11) sql_by_domain = { name: next(sql for sql in statements if f"from {spec.table} " in sql) for name, spec in backup.DOMAIN_SPECS.items() } _check("查询边界-drafts含软删", "d.deleted" not in sql_by_domain["drafts"]) _check("查询边界-windows仅active", "w.deleted = false" in sql_by_domain["windows"]) _check("查询边界-audits指向目标draft", "a.draft_id in" in sql_by_domain["audits"] and "source_type" in sql_by_domain["audits"]) _check("查询边界-embeddings指向目标draft", "e.draft_id in" in sql_by_domain["embeddings"] and "source_type" in sql_by_domain["embeddings"]) _check("清单-身份与边界摘要齐全", manifest["work"] == 8 and manifest["tenant"] == 1 and manifest["title"] == "离线测试书" and len(manifest["windowBoundarySha"]) == 64 and len(manifest["inputSha"]) == 64) _check("input-同快照绑定Canonical窗合同代码与计划", manifest["input"]["canonicalContent"]["chapterCount"] == 2 and manifest["input"]["windowBoundary"]["count"] == 2 and manifest["input"]["activeFieldContracts"]["count"] == 7 and manifest["input"]["codeFiles"]["llm.py"] == "l" * 64 and manifest["input"]["plan"] == { "model": "MiniMax-M3", "semanticDedup": True}) _check("清单-不泄露数据库密码", "secret" not in json.dumps(manifest, ensure_ascii=False)) _check("排序稳定-JSONL按主键", [ json.loads(line)["id"] for line in (output / "drafts.jsonl").read_text(encoding="utf-8").splitlines() ] == [1, 2]) _check("原子写-无临时文件残留", not list(output.glob("*.tmp"))) _check("离线复验-七域校验通过", original_verify(output)["backup_id"] == manifest["backup_id"]) finally: shutil.rmtree(output, ignore_errors=True) def test_accidental_corruption_detection(): """任一 JSONL 意外损坏后离线 verify 必须失败;不模拟恶意重写摘要。""" output = _new_backup_dir() conn = _OfflineConnection() try: backup.backup_work( work_id=8, output_dir=output, tenant=1, dsn="unused", connect=lambda *_args, **_kwargs: conn, git_commit="fedcba9876543210", code_identity=_code_identity(), expected_chapters=2, expected_windows=2, lock_context=_no_lock, ) with (output / "aliases.jsonl").open("ab") as stream: stream.write(b'{"id":999}\n') try: backup.verify_backup(output) except backup.VerificationError: _check("意外损坏检测-文件变化被拒绝", True) else: raise AssertionError("意外损坏后的备份仍通过 verify") finally: shutil.rmtree(output, ignore_errors=True) def test_dict_row_and_tuple_rejection(): """真实连接必须请求 dict_row,tuple 行不得被误转成 dict。""" with mock.patch("psycopg.connect") as connect: backup._default_connect("postgresql://example.invalid/db", autocommit=True) from psycopg.rows import dict_row _check("连接-dict_row", connect.call_args.kwargs["row_factory"] is dict_row) try: backup.sort_rows("drafts", [(1, 8)]) except backup.BackupError: _check("真实rows-tuple拒绝", True) else: raise AssertionError("tuple 数据行被错误接受") def _restore_manifest(): """构造恢复 SQL 测试所需的最小已验证 manifest。""" input_summary = { "databaseIdentity": {"database": "muse-example", "serverAddr": "100.64.0.8", "serverPort": 5433, "serverVersion": "160000"}, "canonicalContent": {"chapterCount": 594, "blockCount": 594, "sha": "c" * 64}, "windowBoundary": {"count": 116, "sha": "w" * 64}, "activeFieldContracts": {"count": 7, "sha": "f" * 64}, "codeFiles": { "backup_upgrade_work.py": "b" * 64, "parse_upgrade.py": "u" * 64, "parse_llm.py": "p" * 64, "embed_drafts.py": "e" * 64, "upgrade_work_lock.py": "k" * 64, "llm.py": "l" * 64, "parse-book/SKILL.md": "s" * 64, }, "plan": {"model": "MiniMax-M3", "semanticDedup": True}, "expectedChapters": 594, "expectedWindows": 116, "sourceType": "upgrade_book", "tenant": 1, "work": 8, } manifest = { "backup_id": "11111111-1111-4111-8111-111111111111", "work": 8, "tenant": 1, "input": input_summary, "inputSha": backup._sha256(backup.canonical_json(input_summary).encode("utf-8")), } confirmation = { "backup_id": manifest["backup_id"], "codeFiles": input_summary["codeFiles"], "inputSha": manifest["inputSha"], "tenant": manifest["tenant"], "work": manifest["work"], } manifest["confirmationSha"] = backup._sha256( backup.canonical_json(confirmation).encode("utf-8") ) return manifest def _restore_rows(): """七域各给一行,驱动 upsert 与清理顺序测试。""" return { "drafts": [{"id": 1, "work_id": 8, "source_type": "upgrade_book", "draft_payload": {"名称": "甲"}, "deleted": False, "tenant_id": 1}], "windows": [{"id": 11, "work_id": 8, "window_no": 1, "from_chapter": 1, "to_chapter": 5, "deleted": False, "tenant_id": 1}], "aliases": [{"id": 21, "work_id": 8, "canonical_name": "甲", "alias": "阿甲", "tenant_id": 1}], "presence": [{"id": 31, "work_id": 8, "window_no": 1, "chapter_no": 1, "entity_type": "character", "name": "甲", "tenant_id": 1}], "card_state": [{"draft_id": 1, "work_id": 8, "watermark_window": 1, "tenant_id": 1}], "audits": [{"id": 41, "draft_id": 1, "window_no": 1, "field_name": "名称", "tenant_id": 1}], "embeddings": [{"id": 51, "draft_id": 1, "content_hash": "a" * 64, "embed_text": "甲", "model": "Qwen", "dimensions": 2, "embedding": "[0.1,0.2]", "tenant_id": 1}], } def test_input_identity_and_drift_guard(): """input 必须绑定完整身份,任一 Canonical 输入漂移均拒绝恢复。""" manifest = _restore_manifest() expected = manifest["input"] _check("input-模型与语义判重绑定", expected["plan"] == { "model": "MiniMax-M3", "semanticDedup": True}) _check("input-594章与116窗绑定", expected["canonicalContent"]["chapterCount"] == 594 and expected["windowBoundary"]["count"] == 116) changed = json.loads(json.dumps(expected)) changed["canonicalContent"]["sha"] = "d" * 64 try: backup.assert_restore_input(manifest, changed, work_id=8, tenant=1) except backup.BackupError: _check("input漂移-拒绝恢复", True) else: raise AssertionError("Canonical input 漂移未拒绝") wrong_database = json.loads(json.dumps(expected)) wrong_database["databaseIdentity"]["database"] = "other-db" try: backup.assert_restore_input(manifest, wrong_database, work_id=8, tenant=1) except backup.BackupError: _check("数据库identity漂移-拒绝恢复", True) else: raise AssertionError("数据库 identity 不符未拒绝") for work_id, tenant in ((9, 1), (8, 2)): try: backup.assert_restore_input(manifest, expected, work_id=work_id, tenant=tenant) except backup.BackupError: pass else: raise AssertionError("work/tenant 不符未拒绝") _check("恢复范围-work与tenant不符拒绝", True) def test_restore_sql_boundaries_and_rehearse_rollback(): """共享恢复内核按七域边界操作,演练无条件回滚。""" manifest = _restore_manifest() rows = _restore_rows() conn = _RestoreConnection() with mock.patch.object(backup, "verify_backup", return_value=manifest), \ mock.patch.object(backup, "read_backup_rows", return_value=rows), \ mock.patch.object(backup, "capture_input_snapshot", return_value=manifest["input"]), \ mock.patch.object(backup, "verify_restored_domains"): backup.restore_backup( pathlib.Path("/private/tmp/offline-restore"), work_id=8, tenant=1, dsn="unused", connect=lambda *_args, **_kwargs: conn, execute=False, confirmation_sha=manifest["confirmationSha"], lock_context=_no_lock, ) statements = [sql for sql, _params in conn.executions] _check("rehearse-强制ROLLBACK", statements[-1] == "rollback" and "commit" not in statements) _check("exact恢复-事务局部关闭更新时间触发器", "set local session_replication_role to replica" in statements) _check("恢复边界-额外卡软删", any( sql.startswith("update muse_knowledge_draft") and "not (d.id = any" in sql for sql in statements )) _check("恢复边界-额外向量软删", any( sql.startswith("update example_knowledge_embedding") and "not (e.id = any" in sql for sql in statements )) for table in ("example_upgrade_alias", "example_upgrade_presence", "example_upgrade_card_state", "example_upgrade_audit"): if not any(sql.startswith(f"delete from {table}") for sql in statements): raise AssertionError(f"恢复前未清理 {table}") _check("恢复边界-四域先清再插", True) _check("恢复边界-windows拒绝额外active", any( "from example_upgrade_window" in sql and "not (w.id = any" in sql for sql in statements )) _check("恢复边界-七域均有upsert", sum("on conflict" in sql for sql in statements) == 7) rejected = _RestoreConnection(extra_window=True) try: backup.apply_restore_kernel(rejected, manifest, rows) except backup.BackupError: _check("恢复边界-实际拒绝额外active窗且写前失败", not any( sql.startswith(("insert ", "update ", "delete ")) for sql, _params in rejected.executions )) else: raise AssertionError("额外 active 窗未阻断恢复") def test_restore_execute_confirmation_and_sequence_forward_only(): """execute 必须双确认;sequence 当前值更大时不得 setval 回退。""" manifest = _restore_manifest() for supplied in (None, "22222222-2222-4222-8222-222222222222"): try: backup.validate_execute_confirmation( True, supplied, manifest["backup_id"], manifest["confirmationSha"], manifest["confirmationSha"], ) except backup.BackupError: pass else: raise AssertionError("execute 缺少匹配 backup_id 仍被允许") backup.validate_execute_confirmation( True, manifest["backup_id"], manifest["backup_id"], manifest["confirmationSha"], manifest["confirmationSha"], ) try: backup.validate_execute_confirmation( False, None, manifest["backup_id"], "wrong", manifest["confirmationSha"] ) except backup.BackupError: _check("confirmation确认-rehearse也必须精确匹配", True) else: raise AssertionError("rehearse confirmationSha 不匹配仍被允许") _check("execute确认-必须显式flag与匹配backup_id", True) conn = _RestoreConnection(sequence_last=100, table_max=50) backup.advance_sequences_forward(conn) _check("序列-不回退", not any("setval" in sql for sql, _params in conn.executions)) forward = _RestoreConnection(sequence_last=100, table_max=150) backup.advance_sequences_forward(forward) _check("序列-仅在需要时向前", sum( sql.startswith("select setval") for sql, _params in forward.executions ) == len(backup.SEQUENCE_TABLES)) execute_conn = _RestoreConnection() with mock.patch.object(backup, "verify_backup", return_value=manifest), \ mock.patch.object(backup, "read_backup_rows", return_value=_restore_rows()), \ mock.patch.object(backup, "capture_input_snapshot", return_value=manifest["input"]), \ mock.patch.object(backup, "verify_restored_domains"), \ mock.patch.object(backup, "advance_sequences_forward"): backup.restore_backup( pathlib.Path("/private/tmp/offline-restore"), work_id=8, tenant=1, dsn="unused", connect=lambda *_args, **_kwargs: execute_conn, execute=True, backup_id=manifest["backup_id"], code_identity=_code_identity(), confirmation_sha=manifest["confirmationSha"], lock_context=_no_lock, ) execute_statements = [sql for sql, _params in execute_conn.executions] _check("execute确认-匹配后才COMMIT", execute_statements[-1] == "commit") def test_expected_scope_code_dependencies_and_lock_fail_closed(): """期望范围、依赖代码与同书锁均进入机械门。""" chapters = [ {"chapter_id": 1, "chapter_order": 1, "block_id": 101, "content_text": "甲"}, {"chapter_id": 2, "chapter_order": 2, "block_id": 102, "content_text": "乙"}, ] windows = [ {"id": 11, "window_no": 1, "from_chapter": 1, "to_chapter": 1}, {"id": 12, "window_no": 2, "from_chapter": 2, "to_chapter": 2}, ] backup.validate_expected_scope(chapters, windows, expected_chapters=2, expected_windows=2) _check("expected-章节窗口连续且每章非空", True) for bad_chapters, bad_windows in ( (chapters[:1], windows), ([dict(chapters[0], content_text=""), chapters[1]], windows), (chapters, [windows[0], dict(windows[1], window_no=3)]), (chapters, [windows[0], dict(windows[1], from_chapter=3)]), ): try: backup.validate_expected_scope( bad_chapters, bad_windows, expected_chapters=2, expected_windows=2 ) except backup.BackupError: pass else: raise AssertionError("expected 范围异常未拒绝") _check("expected-数量非空与连续性异常均拒绝", True) identity = backup.capture_code_identity("HEAD") _check("代码身份-补齐实际依赖SHA", { "reset_upgrade_work.py", "parse_llm.py", "embed_drafts.py", "upgrade_work_lock.py" }.issubset(identity["codeFiles"])) business_calls = {"count": 0} @contextmanager def unavailable_lock(*_args, **_kwargs): raise backup.UpgradeWorkLockUnavailable(1, 8) yield def forbidden_connect(*_args, **_kwargs): business_calls["count"] += 1 raise AssertionError("锁失败后仍创建业务连接") output = _new_backup_dir() try: backup.backup_work( 8, output, tenant=1, dsn="unused", connect=forbidden_connect, code_identity=_code_identity(), expected_chapters=594, expected_windows=116, lock_context=unavailable_lock, ) except backup.UpgradeWorkLockUnavailable: _check("advisory-lock-失败时零业务SQL", business_calls["count"] == 0) else: raise AssertionError("同书锁失败未阻断 backup") with mock.patch.object(backup, "verify_backup") as offline_verify: try: backup.restore_backup( pathlib.Path("/private/tmp/not-read"), work_id=8, tenant=1, dsn="unused", connect=forbidden_connect, confirmation_sha="unused", lock_context=unavailable_lock, ) except backup.UpgradeWorkLockUnavailable: _check("advisory-lock-恢复演练也在离线verify前阻断", not offline_verify.called) else: raise AssertionError("同书锁失败未阻断 restore/rehearse") def main(): """顺序运行全部离线测试,便于直接以 Python 执行。""" test_stable_sort_and_json_types() test_path_guards() test_backup_transaction_queries_and_round_trip() test_accidental_corruption_detection() test_dict_row_and_tuple_rejection() test_input_identity_and_drift_guard() test_restore_sql_boundaries_and_rehearse_rollback() test_restore_execute_confirmation_and_sequence_forward_only() test_expected_scope_code_dependencies_and_lock_fail_closed() print("\n全部离线自测通过:49 项(未连库、未发任何 LLM 调用)") if __name__ == "__main__": main()