deer-flow/backend/tests/test_migration_0024_project_documents.py
hataa db6130861d
fix(persistence): repair run-change clock schema skipped by the 0023 insertion (#5517)
0023_run_change_seq was chained ahead of the already-shipped
0023_user_preferences revision, so databases stamped at that revision or
later treat it as an applied ancestor and never execute it: the
run_change_clock table and runs.change_seq column are permanently missing
and the first thread deletion fails with 'no such table:
run_change_clock' (#5516). 0025_repair_run_change_seq re-applies the same
guarded DDL on upgrade and no-ops on healthy shapes. RunChangeClockRow and
UserPreferenceRow are also registered in the ORM model registry.

Fixes #5516

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-18 10:48:26 +08:00

113 lines
3.3 KiB
Python

"""Migration tests for 0024_project_documents (Phase-2 spec §6.1).
Pins the table shape and the four indexes on upgrade, and the clean downgrade
(the chain-head pin moved on to 0025_repair_run_change_seq with that
revision).
"""
from __future__ import annotations
import asyncio
import pytest
import sqlalchemy as sa
from alembic import command
from deerflow.persistence.bootstrap import _get_alembic_config
from deerflow.persistence.engine import close_engine, init_engine
pytestmark = pytest.mark.asyncio
REVISION = "0024_project_documents"
PREVIOUS = "0023_user_preferences"
TABLE = "project_documents"
INDEXES = {
"ix_project_documents_project_id",
"ix_project_documents_user_id",
"ix_project_documents_sha256",
"ix_project_documents_trashed_at",
}
COLUMNS = {
"id",
"project_id",
"user_id",
"name",
"stored_relpath",
"sha256",
"size_bytes",
"source_thread_id",
"source_kind",
"source_name",
"trashed_at",
"trash_origin",
"created_at",
"updated_at",
}
async def _engine(tmp_path, name: str = "test.db"):
url = f"sqlite+aiosqlite:///{tmp_path / name}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
from deerflow.persistence.engine import get_engine
return get_engine()
async def _inspect(engine):
async with engine.connect() as conn:
def _read(sync_conn):
inspector = sa.inspect(sync_conn)
return (
inspector.has_table(TABLE),
{c["name"] for c in inspector.get_columns(TABLE)} if inspector.has_table(TABLE) else set(),
{i["name"] for i in inspector.get_indexes(TABLE)} if inspector.has_table(TABLE) else set(),
)
return await conn.run_sync(_read)
async def test_0024_upgrade_creates_table_and_indexes(tmp_path):
engine = await _engine(tmp_path)
try:
exists, columns, indexes = await _inspect(engine)
assert exists
assert columns == COLUMNS
assert INDEXES <= indexes
finally:
await close_engine()
async def test_0024_downgrade_drops_table_and_reupgrade_recreates(tmp_path):
engine = await _engine(tmp_path)
try:
cfg = _get_alembic_config(engine)
await asyncio.to_thread(command.downgrade, cfg, PREVIOUS)
exists, _, _ = await _inspect(engine)
assert not exists
# Downgrade is guarded and idempotent: repeating it is a no-op.
await asyncio.to_thread(command.downgrade, cfg, PREVIOUS)
await asyncio.to_thread(command.upgrade, cfg, REVISION)
exists, columns, indexes = await _inspect(engine)
assert exists
assert columns == COLUMNS
assert INDEXES <= indexes
finally:
await close_engine()
async def test_0024_upgrade_is_guarded_against_an_existing_table(tmp_path):
"""The inspector.has_table idiom keeps re-running the revision a no-op."""
engine = await _engine(tmp_path)
try:
cfg = _get_alembic_config(engine)
# Simulate a database that already carries the table (create_all
# bootstrap path): upgrading again must not fail.
await asyncio.to_thread(command.upgrade, cfg, REVISION)
exists, columns, _ = await _inspect(engine)
assert exists and columns == COLUMNS
finally:
await close_engine()