652 lines
27 KiB
Python
652 lines
27 KiB
Python
#!/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()
|