Files
AgentLab/backend/tests/test_provenance_db.py
ThearchyHelios e4f8ee5d08 Excel 导入改造期 4:证据下钻——报告里引用上传表格的数,点开能看到它来自原表格的哪一格;裁判核对时看得到来源、口径、接受理由和区域外文字
推断的来源(新 engine/direct_select、data/provenance、data/provenance_db;api/evidence 新增推断来源接口)
- 只对直接引用一列的查询下钻:受限文法用和执行时相同的 SQLite 分词读法,别名互换、自连接、表达式、聚合、子查询、集合运算(含藏在注释、字符串、方括号、转义、BOM 之后的)一律只给表级来历;再用 EXPLAIN 字节码和授权回调兜底
- 结果带齐主键、值非空,按主键参数化回查只读快照、类型和值严格一致才给格子;不一致时整份重算库哈希,哈希不符标红
- 从导入清单和溯源找回格子:宽表、交叉表逆透视的长表(日期取自表头格、年份取自统计期、行标签、分段标题)、列表、合计表、按期累积的并集(换算到所属那一期);原文与规范写法不同时一并列出
- 清单读不到、登记哈希与清单不一致、快照已回收、遮罩列,都只给表级来历或不给,写明原因;原件已清除、接受已作废照实显示;未封存的运行照常推断并标明
- 相关核对:这一行、这一期的核对结论与接受理由

裁判摘录(engine/judge)
- 带期 4 标记的报告,上传表格的查询旁加来源行、口径行、接受理由、可见单元格里的区域外文字和人工录入的统计期;限定语写明这些数未经系统核对,长度有上限、截断时写明
- 隐藏行列里的区域外文字不送;设了遮罩的源只写条数

前端证据面板(run/EvidenceProvenance;ManifestBody 从版本页抽出复用)
- 「推断的来源」一节与已封存的证据分开标注:格子坐标、取自哪些格、年份来源、规范写法、回查、相关核对、数据版本与各期;标红与拒绝原因按状态显示

不变的部分
- 文档顶层加 provenance 标记,只有新报告走新路径;期 4 之前的运行、非上传源、手工源的面板、复核、裁判摘录逐字不变,由金样测试钉住
- 执行器给区域外文字记录是否在隐藏行列;不建「导入校验」系统表(D6)
2026-10-02 14:38:10 +08:00

498 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""证据下钻(期 4)里碰数据库和数据文件的几步:provenance_db 的各函数,engine.py 的两处小改,sum_eq_conditions。
数据文件都是测试里现造的小 SQLite 库(表名「日客流」「时段客流」,分区甲、分区乙,数字随机),数据文件记录
直接写进测试库的 source_snapshots,不经导入管线:这里要钉的是回查、编译核对、强制重算、行级核对各自的口径,
整条链(真导入、真运行)由 WP-E 的验收覆盖。
"""
from __future__ import annotations
import hashlib
import os
import random
import sqlite3
import threading
import uuid
from datetime import datetime, timezone
import pytest
from app.data import engine as engine_mod
from app.data import provenance_db
from app.data import recipe_checks
from app.data.engine import EngineCache, SnapshotTampered, cache_key, engines, open_checked_sqlite
from app.data.provenance_types import _QUOTED, CompileFacts, StateNote, SumEqRule
from app.data.recipe_checks import sum_eq_conditions
from app.data.recipe_types import ColumnOut, SumEq
from app.data.table_versions import SnapshotManifestMismatch, SnapshotMissing
from app.db.base import SessionLocal
from app.db.models import DataSource, SourceSnapshot, TableImport, TableRecipe
RNG = random.Random(4004)
# --------------------------------------------------------------------------
# 夹具
# --------------------------------------------------------------------------
def _sha(path) -> str:
with open(path, "rb") as f:
return hashlib.sha256(f.read()).hexdigest()
def _build(path, *, daily, hourly=(), real=()) -> str:
"""造一个快照库:日客流(单列主键,写成 PRIMARY KEY DESC,和执行器一样不让它成为 rowid 的别名)、
时段客流(两列主键)、分区占比(REAL 列)。"""
conn = sqlite3.connect(path)
conn.executescript("""
CREATE TABLE "日客流" ("日期" TEXT NOT NULL, "全日客流" INTEGER, "分区甲" INTEGER, "分区乙" INTEGER,
PRIMARY KEY ("日期" DESC));
CREATE TABLE "时段客流" ("日期" TEXT NOT NULL, "时段" TEXT NOT NULL, "客流" INTEGER,
PRIMARY KEY ("日期", "时段"));
CREATE TABLE "分区占比" ("日期" TEXT NOT NULL, "合计" REAL, "分区甲" REAL, "分区乙" REAL,
PRIMARY KEY ("日期" DESC));
""")
conn.executemany('INSERT INTO "日客流" VALUES (?, ?, ?, ?)', list(daily))
conn.executemany('INSERT INTO "时段客流" VALUES (?, ?, ?)', list(hourly))
conn.executemany('INSERT INTO "分区占比" VALUES (?, ?, ?, ?)', list(real))
conn.commit()
conn.close()
os.chmod(path, 0o444)
return str(path)
def _daily_rows(n=6):
"""前几行成立,第 3 行不成立(合计多 1),第 5 行分区乙为空。"""
rows = []
for i in range(n):
a, b = RNG.randint(1000, 9000), RNG.randint(1000, 9000)
total = a + b
if i == 2:
total += 1
rows.append((f"2026-08-{i + 1:02d}", total, a, None if i == 4 else b))
return rows
async def _register(path, *, source_id=None, retired=False, sha=None, manifest=None) -> SourceSnapshot:
snap = SourceSnapshot(
id=uuid.uuid4().hex + uuid.uuid4().hex, source_id=source_id or uuid.uuid4().hex,
db_path=str(path), db_sha256=_sha(path) if sha is None else sha, schema_cache={},
retired_at=datetime.now(timezone.utc) if retired else None, manifest_artifact=manifest,
)
async with SessionLocal() as session:
session.add(snap)
await session.commit()
await session.refresh(snap)
return snap
@pytest.fixture
async def snapshot(tmp_path):
"""一个登记好的快照库,数据源记录不存在(模拟数据源已删除)。用完清掉引擎缓存。"""
path = _build(tmp_path / "snap.db", daily=_daily_rows(),
hourly=[("2026-08-03", "7-8", None), ("2026-08-03", "8-9", 321)],
real=[("2026-08-01", 0.3, 0.1, 0.2), ("2026-08-02", 1.5, 0.5, 0.75)])
snap = await _register(path)
yield snap
await engines.invalidate(snap.source_id)
async def _view(snap: SourceSnapshot):
return provenance_db.view_of(snap, source_name="flow_demo")
def _poke(path: str, *, keep_mtime: bool) -> None:
"""原地改一个字节(大小不变)。keep_mtime 时再用 os.utime 还原修改时间:EngineCache 的指纹认不出这种改动。"""
st = os.stat(path)
os.chmod(path, 0o644)
with open(path, "r+b") as f:
f.seek(-1, os.SEEK_END)
last = f.read(1)
f.seek(-1, os.SEEK_END)
f.write(bytes([last[0] ^ 0x01]))
os.chmod(path, 0o444)
if keep_mtime:
os.utime(path, ns=(st.st_atime_ns, st.st_mtime_ns))
# --------------------------------------------------------------------------
# sum_eq_conditions:导入时的核对和行级核对共用一段条件
# --------------------------------------------------------------------------
def test_sum_eq_conditions_integer_and_real():
nonnull, anynull, mismatch = sum_eq_conditions("全日客流", ["分区甲", "分区乙"],
{"全日客流": "INTEGER", "分区甲": "INTEGER"})
assert nonnull == '"全日客流" IS NOT NULL AND "分区甲" IS NOT NULL AND "分区乙" IS NOT NULL'
assert anynull == '"全日客流" IS NULL OR "分区甲" IS NULL OR "分区乙" IS NULL'
assert mismatch == '"全日客流" <> "分区甲" + "分区乙"'
# 任一列是 REAL 就按相对误差比:0.1 + 0.2 不严格等于 0.3
_, _, real = sum_eq_conditions("合计", ["分区甲", "分区乙"], {"分区乙": "REAL"})
assert real == 'ABS("合计" - ("分区甲" + "分区乙")) > 1e-9 * MAX(1, ABS("合计"))'
# 名字里的双引号照样转义(DQS 关闭的连接上写错的名字直接报错)
assert sum_eq_conditions('a"b', ["c", "d"], {})[2] == '"a""b" <> "c" + "d"'
@pytest.mark.parametrize("table,total,parts,types", [
("日客流", "全日客流", ["分区甲", "分区乙"], {"全日客流": "INTEGER", "分区甲": "INTEGER", "分区乙": "INTEGER"}),
("分区占比", "合计", ["分区甲", "分区乙"], {"合计": "REAL", "分区甲": "REAL", "分区乙": "REAL"}),
])
async def test_row_status_agrees_with_the_import_check(snapshot, table, total, parts, types):
"""逐行的 row_status 和导入时的 _check_sum_eq 在同一个库上的结论对得上:成立、不成立、含空值的行数都相同。
REAL 表里 0.1 + 0.2 = 0.3 那一行两边都判成立(改成严格比较的条件会被这里抓到)。"""
view = await _view(snapshot)
rule = SumEqRule(table=table, total=total, parts=parts, types=types)
conn = open_checked_sqlite(snapshot.db_path)
try:
rowids = [r[0] for r in conn.execute(f'SELECT rowid FROM "{table}" ORDER BY rowid')]
result = recipe_checks._check_sum_eq(
recipe_checks._Sql(conn), SumEq(id="R1", kind="sum_eq", table=table, total=total, parts=parts), "核对",
[ColumnOut(name=n, type=t) for n, t in types.items()], ["日期"], {})
finally:
conn.close()
statuses = [await provenance_db.row_status(view, rule, rid) for rid in rowids]
assert statuses.count("mismatch") == result.failed
assert statuses.count("unverifiable") == result.unverifiable
assert statuses.count("passed") + statuses.count("mismatch") == result.checked
if table == "日客流":
assert statuses == ["passed", "passed", "mismatch", "passed", "unverifiable", "passed"]
else:
assert statuses == ["passed", "mismatch"]
async def test_row_status_follows_the_snapshot_rowid_it_is_given(snapshot):
"""按传进来的快照库 rowid 判,不按别的编号:同一条规则,第 3 行不成立、第 4 行成立。"""
view = await _view(snapshot)
rule = SumEqRule(table="日客流", total="全日客流", parts=["分区甲", "分区乙"], types={})
assert await provenance_db.row_status(view, rule, 3) == "mismatch"
assert await provenance_db.row_status(view, rule, 4) == "passed"
async def test_row_status_is_none_when_the_row_or_a_column_is_not_there(snapshot):
view = await _view(snapshot)
assert await provenance_db.row_status(
view, SumEqRule(table="日客流", total="全日客流", parts=["分区甲", "分区乙"], types={}), 999) is None
assert await provenance_db.row_status(
view, SumEqRule(table="日客流", total="全日客流", parts=["分区甲", "分区丙"], types={}), 1) is None
assert await provenance_db.row_status(
view, SumEqRule(table="没有这张表", total="a", parts=["b", "c"], types={}), 1) is None
# 名字只对 ASCII 不区分大小写(和 SQLite 一致),中文名原样比
assert await provenance_db.row_status(
view, SumEqRule(table="日客流", total="全日客流", parts=["分区甲", "分区乙"], types={}), 1) == "passed"
# --------------------------------------------------------------------------
# 数据文件记录与绑定
# --------------------------------------------------------------------------
async def test_snapshot_record(snapshot):
async with SessionLocal() as session:
got = await provenance_db.snapshot_record(session, snapshot.id)
assert got is not None and got.id == snapshot.id and got.db_path == snapshot.db_path
assert await provenance_db.snapshot_record(session, "f" * 64) is None
assert await provenance_db.snapshot_record(session, "") is None
async def test_view_of_uses_a_stand_in_keyed_like_the_query(snapshot):
"""数据源记录不存在(已删除)也能绑定;id 用 snapshot.source_id,缓存键和查询时相同,首次哈希只算一次。"""
async with SessionLocal() as session:
assert await session.get(DataSource, snapshot.source_id) is None
view = provenance_db.view_of(snapshot, source_name="flow_demo")
assert view.id == snapshot.source_id and view.name == "flow_demo"
assert view.snapshot_id == snapshot.id and view.database == snapshot.db_path
assert view.immutable is True and view.readonly is True
assert view.expected_sha256 == snapshot.db_sha256
assert view.options == {} and view.description == "" and view.enabled is True
assert cache_key(view) == f"{snapshot.source_id}:{snapshot.id}"
async def test_view_of_refuses_unregistered_hash_and_gone_files(tmp_path):
path = _build(tmp_path / "a.db", daily=_daily_rows(2))
with pytest.raises(SnapshotMissing):
provenance_db.view_of(await _register(path, sha=""), source_name="flow_demo")
with pytest.raises(SnapshotMissing):
provenance_db.view_of(await _register(path, retired=True), source_name="flow_demo")
gone = await _register(path)
os.chmod(path, 0o644)
os.remove(path)
# 文件不在:绑定前就认出来(snapshot_gone,不标红),不留给 EngineCache 报成「文件被改」
with pytest.raises(SnapshotMissing) as info:
provenance_db.view_of(gone, source_name="flow_demo")
assert not isinstance(info.value, SnapshotTampered)
async def test_view_of_raises_manifest_mismatch_untouched(tmp_path):
"""快照清单里的库哈希和登记的不一致:bind_snapshot 抛的 SnapshotManifestMismatch 原样抛出。"""
from app.core import artifact_store
path = _build(tmp_path / "u.db", daily=_daily_rows(2))
mid = await artifact_store.put_json({"snapshot_id": None, "union": {"db_sha256": "0" * 64}},
kind="snapshot_manifest")
snap = await _register(path, manifest=mid)
with pytest.raises(SnapshotManifestMismatch):
provenance_db.view_of(snap, source_name="flow_demo")
# --------------------------------------------------------------------------
# R16:回查
# --------------------------------------------------------------------------
async def test_recheck_sql_params_and_rows(snapshot):
view = await _view(snapshot)
rows, sql, params = await provenance_db.recheck(view, table="日客流", column="全日客流", pk={"日期": "2026-08-02"})
assert sql == 'SELECT rowid, "全日客流" FROM "日客流" WHERE "日期" = ?'
assert params == ["2026-08-02"]
conn = sqlite3.connect(snapshot.db_path)
want = conn.execute('SELECT rowid, "全日客流" FROM "日客流" WHERE "日期" = ?', ["2026-08-02"]).fetchall()
conn.close()
assert rows == want and isinstance(rows[0][1], int)
# 契约 Recheck 的口径:去掉双引号里的标识符后 ? 的个数等于主键列数
assert _QUOTED.sub("", sql).count("?") == 1
async def test_recheck_composite_key_keeps_primary_key_order_and_types(snapshot):
view = await _view(snapshot)
rows, sql, params = await provenance_db.recheck(view, table="时段客流", column="客流",
pk={"日期": "2026-08-03", "时段": "8-9"})
assert sql == 'SELECT rowid, "客流" FROM "时段客流" WHERE "日期" = ? AND "时段" = ?'
assert params == ["2026-08-03", "8-9"] and [v for _, v in rows] == [321]
# 空值照实回(R11 已拦下空值,这里只回事实);REAL 回 float,不和 int 混
rows, _, _ = await provenance_db.recheck(view, table="时段客流", column="客流", pk={"日期": "2026-08-03", "时段": "7-8"})
assert [v for _, v in rows] == [None]
rows, _, _ = await provenance_db.recheck(view, table="分区占比", column="合计", pk={"日期": "2026-08-02"})
assert rows[0][1] == 1.5 and isinstance(rows[0][1], float)
rows, _, _ = await provenance_db.recheck(view, table="日客流", column="全日客流", pk={"日期": "1999-01-01"})
assert rows == []
async def test_recheck_quotes_identifiers_with_double_quotes(tmp_path):
path = tmp_path / "q.db"
conn = sqlite3.connect(path)
conn.execute('CREATE TABLE "表""甲" ("键""一" TEXT NOT NULL, "值?" INTEGER, PRIMARY KEY ("键""一" DESC))')
conn.execute('INSERT INTO "表""甲" VALUES (?, ?)', ["k", 7])
conn.commit()
conn.close()
snap = await _register(path)
try:
rows, sql, params = await provenance_db.recheck(
await _view(snap), table='表"甲', column="值?", pk={'键"一': "k"})
assert sql == 'SELECT rowid, "值?" FROM "表""甲" WHERE "键""一" = ?'
assert [v for _, v in rows] == [7] and params == ["k"]
assert _QUOTED.sub("", sql).count("?") == 1
finally:
await engines.invalidate(snap.source_id)
async def test_recheck_raises_tampered_when_the_file_changed(snapshot):
"""改过一个字节(修改时间也变了):经 EngineCache 取引擎时重新核对哈希,SnapshotTampered 原样抛出。"""
view = await _view(snapshot)
await provenance_db.recheck(view, table="日客流", column="全日客流", pk={"日期": "2026-08-01"})
_poke(snapshot.db_path, keep_mtime=False)
with pytest.raises(SnapshotTampered):
await provenance_db.recheck(view, table="日客流", column="全日客流", pk={"日期": "2026-08-01"})
async def test_recheck_needs_a_primary_key(snapshot):
with pytest.raises(ValueError):
await provenance_db.recheck(await _view(snapshot), table="日客流", column="全日客流", pk={})
# --------------------------------------------------------------------------
# R16:强制重算
# --------------------------------------------------------------------------
async def test_rehash_catches_an_in_place_edit_that_keeps_the_fingerprint(snapshot, monkeypatch):
"""原地改一个字节、再还原修改时间:EngineCache 的指纹不变、照样给缓存的引擎,强制重算认得出(R16,A25②)。
重算放在线程里。"""
view = await _view(snapshot)
await engines.get(view)
assert await provenance_db.rehash(view) is True
threads: list[bool] = []
real = engine_mod._file_sha256
def spy(path):
threads.append(threading.current_thread() is threading.main_thread())
return real(path)
monkeypatch.setattr(engine_mod, "_file_sha256", spy)
before = engines.pinned(view)
_poke(snapshot.db_path, keep_mtime=True)
assert engine_mod._fingerprint(snapshot.db_path) == before
await engines.get(view) # 指纹没变,缓存命中,不重新核对
assert await provenance_db.rehash(view) is False
assert threads and not any(threads)
async def test_rehash_is_false_without_a_hash_or_a_file(snapshot):
view = await _view(snapshot)
view.expected_sha256 = None
assert await provenance_db.rehash(view) is False
view = await _view(snapshot)
view.database = str(snapshot.db_path) + ".missing"
assert await provenance_db.rehash(view) is False
# --------------------------------------------------------------------------
# R15:编译核对
# --------------------------------------------------------------------------
@pytest.mark.parametrize("sql,tables,rows,yields", [
('SELECT "日期", "全日客流" FROM "日客流" WHERE "日期" = \'2026-08-01\'', {"日客流"}, 1, 0),
('SELECT * FROM "日客流" ORDER BY "全日客流" DESC LIMIT 3', {"日客流"}, 1, 0),
('SELECT "日期","全日客流" FROM "日客流" UNION ALL SELECT "日期","分区甲" FROM "日客流"', {"日客流"}, 2, 0),
('SELECT "日期","全日客流" FROM "日客流" WHERE "日期" LIKE \'2026%\' ESCAPE \'\\\' '
'UNION ALL SELECT "日期","分区甲" FROM "日客流"', {"日客流"}, 2, 0),
('SELECT "日期","全日客流" FROM "日客流" WHERE "日期" IN (SELECT "日期" FROM "时段客流")',
{"日客流", "时段客流"}, 1, 0),
])
async def test_compile_facts_reports_tables_and_result_rows(snapshot, sql, tables, rows, yields):
facts = await provenance_db.compile_facts(await _view(snapshot), sql)
assert facts == CompileFacts(tables=tables, result_rows=rows, yields=yields)
@pytest.mark.parametrize("op", ["UNION", "INTERSECT", "EXCEPT"])
async def test_compile_facts_sees_yield_in_set_operations(snapshot, op):
facts = await provenance_db.compile_facts(
await _view(snapshot), f'SELECT "日期","全日客流" FROM "日客流" {op} SELECT "日期","分区甲" FROM "日客流"')
assert facts.yields >= 1
async def test_compile_facts_runs_in_a_thread_on_an_immutable_read_only_connection(snapshot, monkeypatch):
seen: list[dict] = []
real_open = provenance_db.open_checked_sqlite
def spy(path, **kw):
seen.append({"path": path, **kw, "main": threading.current_thread() is threading.main_thread()})
return real_open(path, **kw)
monkeypatch.setattr(provenance_db, "open_checked_sqlite", spy)
await provenance_db.compile_facts(await _view(snapshot), 'SELECT "日期" FROM "日客流"')
assert seen == [{"path": snapshot.db_path, "readonly": True, "immutable": True, "main": False}]
async def test_compile_facts_compares_against_the_engine_cache_fingerprint(snapshot, monkeypatch):
"""开连接前的指纹和 EngineCache 核对过的那份不等:SnapshotTampered(→ db_tampered)。"""
view = await _view(snapshot)
monkeypatch.setattr(engines, "pinned", lambda source: (0, 0, 0, 0))
with pytest.raises(SnapshotTampered):
await provenance_db.compile_facts(view, 'SELECT "日期" FROM "日客流"')
async def test_compile_facts_checks_the_fingerprint_again_after_closing(snapshot, monkeypatch):
"""编译期间文件被换(关连接后的指纹对不上):同样 SnapshotTampered,不交出编译结果。"""
view = await _view(snapshot)
real = provenance_db._fingerprint
calls: list[int] = []
def drifting(path):
calls.append(1)
fp = real(path)
return fp if len(calls) == 1 else (fp[0], fp[1] + 1, fp[2], fp[3])
monkeypatch.setattr(provenance_db, "_fingerprint", drifting)
with pytest.raises(SnapshotTampered):
await provenance_db.compile_facts(view, 'SELECT "日期" FROM "日客流"')
assert len(calls) == 2
async def test_compile_facts_without_a_pinned_engine_is_snapshot_missing(snapshot, monkeypatch):
monkeypatch.setattr(engines, "pinned", lambda source: None)
with pytest.raises(SnapshotMissing) as info:
await provenance_db.compile_facts(await _view(snapshot), 'SELECT "日期" FROM "日客流"')
assert not isinstance(info.value, SnapshotTampered)
async def test_compile_facts_refuses_a_replaced_file(snapshot):
"""引擎已在缓存里、文件随后被改(修改时间变了):取引擎时就重新核对,SnapshotTampered。"""
view = await _view(snapshot)
await provenance_db.compile_facts(view, 'SELECT "日期" FROM "日客流"')
_poke(snapshot.db_path, keep_mtime=False)
with pytest.raises(SnapshotTampered):
await provenance_db.compile_facts(view, 'SELECT "日期" FROM "日客流"')
# --------------------------------------------------------------------------
# 导入记录上的当前状态
# --------------------------------------------------------------------------
async def test_import_states():
sid = uuid.uuid4().hex
async with SessionLocal() as session:
recipe = TableRecipe(source_id=sid, seq=3, recipe={}, recipe_sha256="a" * 64)
session.add(recipe)
await session.flush()
kept = TableImport(source_id=sid, seq=1, raw_state="kept", recipe_id=recipe.id)
purged = TableImport(source_id=sid, seq=2, raw_state="purged", recipe_id="nosuchrecipe",
purged={"at": "2026-09-30T01:02:03+00:00", "reason": "合成理由:清除原件",
"signed_by": "录入员甲", "signed_by_verified": False})
revoked = TableImport(source_id=sid, seq=3, raw_state="kept",
revoked={"at": "2026-10-01T00:00:00+00:00", "reason": "合成理由:作废",
"signed_by": None, "signed_by_verified": False})
session.add_all([kept, purged, revoked])
await session.commit()
ids = [kept.id, purged.id, revoked.id]
got = await provenance_db.import_states(session, [*ids, "nosuchimport", kept.id])
assert set(got) == set(ids)
assert got[kept.id] == {"raw_state": "kept", "purged": None, "revoked": None, "recipe_seq": 3}
assert got[purged.id] == {"raw_state": "purged", "revoked": None, "recipe_seq": None, "purged": StateNote(
at="2026-09-30T01:02:03+00:00", signed_by="录入员甲", reason="合成理由:清除原件")}
assert got[revoked.id]["revoked"] == StateNote(at="2026-10-01T00:00:00+00:00", signed_by=None,
reason="合成理由:作废")
assert got[revoked.id]["recipe_seq"] is None
async with SessionLocal() as session:
assert await provenance_db.import_states(session, []) == {}
# --------------------------------------------------------------------------
# engine.py:EngineCache.pinned、open_checked_sqlite 的 immutable
# --------------------------------------------------------------------------
async def test_pinned_returns_the_verified_fingerprint(tmp_path):
path = _build(tmp_path / "p.db", daily=_daily_rows(2))
snap = await _register(path)
view = await _view(snap)
cache = EngineCache()
try:
assert cache.pinned(view) is None # 还没建引擎
await cache.get(view)
st = os.stat(path)
assert cache.pinned(view) == (st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns)
manual = type("S", (), {"id": "m1", "kind": "sqlite", "database": str(path), "readonly": True,
"options": {}, "name": "手工源"})()
await cache.get(manual)
assert cache.pinned(manual) is None # 手工源没有核对过的指纹
await cache.invalidate(snap.source_id)
assert cache.pinned(view) is None
finally:
await cache.close()
def test_open_checked_sqlite_immutable_flag(tmp_path, monkeypatch):
path = _build(tmp_path / "o.db", daily=_daily_rows(1))
uris: list[str] = []
real = sqlite3.connect
def spy(database, *a, **kw):
uris.append(database)
return real(database, *a, **kw)
monkeypatch.setattr(engine_mod.sqlite3, "connect", spy)
for kwargs in ({}, {"readonly": True}, {"readonly": True, "immutable": True}):
conn = open_checked_sqlite(path, **kwargs)
try:
assert conn.execute('SELECT COUNT(*) FROM "日客流"').fetchone() == (1,)
finally:
conn.close()
# 缺省行为不变:只读、不带 immutable
assert uris[0].endswith("?mode=ro") and uris[1].endswith("?mode=ro")
assert uris[2].endswith("?mode=ro&immutable=1")
with pytest.raises(ValueError):
open_checked_sqlite(path, readonly=False, immutable=True)