"""tests/test_db.py —— 三表迁移幂等 + JSON 编解码。""" from __future__ import annotations import asyncio import pytest from app.db import Database, dumps_json, loads_json pytestmark = pytest.mark.bioflow_unit def _run(coro): return asyncio.new_event_loop().run_until_complete(coro) def _tables(db): return {r[0] for r in _run(db.fetchall("SELECT name FROM sqlite_master WHERE type='table'"))} def test_schema_created(db): tables = _tables(db) assert {"bio_jobs", "bio_stages", "bio_archives"} <= tables def test_migration_idempotent(tmp_path): """TC-BIO-DB:重复 connect 幂等(不报错不重复建表)。""" path = tmp_path / "a" / "bioflow.db" db1 = Database(path) _run(db1.connect()) _run(db1.close()) db2 = Database(path) _run(db2.connect()) tables = _tables(db2) assert {"bio_jobs", "bio_stages", "bio_archives"} <= tables _run(db2.close()) def test_incremental_column_migration(tmp_path): """老库(无增量列)connect 后补列(bio_jobs.error / bio_archives.report_artifact)。""" import sqlite3 path = tmp_path / "old" / "bioflow.db" path.parent.mkdir(parents=True) conn = sqlite3.connect(path) conn.execute("""CREATE TABLE bio_jobs ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'created', stage_chain TEXT NOT NULL DEFAULT '[]', params TEXT NOT NULL DEFAULT '{}', auto_continue INTEGER NOT NULL DEFAULT 0, progress INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)""") conn.execute("""CREATE TABLE bio_stages ( id TEXT PRIMARY KEY, job_id TEXT NOT NULL REFERENCES bio_jobs(id) ON DELETE CASCADE, stage TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'waiting', params TEXT NOT NULL DEFAULT '{}', manifest_path TEXT, log_tail TEXT NOT NULL DEFAULT '', attempt INTEGER NOT NULL DEFAULT 0, started_at TEXT, finished_at TEXT, UNIQUE(job_id, stage))""") conn.execute("""CREATE TABLE bio_archives ( id TEXT PRIMARY KEY, job_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '', stage_chain TEXT NOT NULL DEFAULT '[]', params TEXT NOT NULL DEFAULT '{}', disk_path TEXT, size_bytes INTEGER NOT NULL DEFAULT 0, reused_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL)""") conn.commit() conn.close() db = Database(path) _run(db.connect()) cols = {r[1] for r in _run(db.fetchall("PRAGMA table_info(bio_jobs)"))} assert "error" in cols and "archive_id" in cols cols2 = {r[1] for r in _run(db.fetchall("PRAGMA table_info(bio_archives)"))} assert "report_artifact" in cols2 _run(db.close()) def test_job_crud(db): _run(db.execute( "INSERT INTO bio_jobs (id, session_id, title, status, stage_chain, params, created_at, updated_at) " "VALUES ('j1', 's1', 't', 'created', ?, ?, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')", (dumps_json(["identify"]), dumps_json({})))) row = _run(db.fetchone("SELECT * FROM bio_jobs WHERE id='j1'")) assert row["title"] == "t" assert loads_json(row["stage_chain"]) == ["identify"] assert loads_json(row["params"]) == {} def test_stage_cascade_delete(db): _run(db.execute( "INSERT INTO bio_jobs (id, session_id, title, status, stage_chain, params, created_at, updated_at) " "VALUES ('j1', 's1', 't', 'created', '[]', '{}', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')")) _run(db.execute( "INSERT INTO bio_stages (id, job_id, stage, status, params, log_tail, attempt) " "VALUES ('j1:identify', 'j1', 'identify', 'waiting', '{}', '', 0)")) _run(db.execute("DELETE FROM bio_jobs WHERE id='j1'")) rows = _run(db.fetchall("SELECT * FROM bio_stages WHERE job_id='j1'")) assert rows == [] # FK CASCADE def test_dumps_loads_json(): assert dumps_json({"a": 1}) == '{"a": 1}' assert loads_json('{"a": 1}') == {"a": 1} assert loads_json(None, "d") == "d" assert loads_json("bad", []) == [] assert dumps_json(object(), "x") == '"x"'