deer-flow/backend/tests/test_migration_0037_project_document_summaries.py
spud 21175ba46f
fix(runtime): persistently fence stale run event producers (#7619)
* fix(runtime): fence revoked run event producers

* fix(runtime): reduce off-mode producer guard locking

* docs: keep inherited guidance within size budget

* chore(runtime): remove development phase labels

* fix(persistence): normalize libpq SSL options for asyncpg connections

* docs(runtime): clarify producer fencing behavior in off mode

* test(persistence): align migration TLS probes with channel-binding rejection

* fix(runtime): authorize fenced Gateway history seeding

* fix(scheduler): preserve takeover identity in recovery publication

* fix(scheduler): report unconfirmed recovery publication

* fix(runtime): preserve history messages before the first human turn

* fix(runtime): clarify run creation failure diagnostic

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-02 15:26:42 +08:00

138 lines
6.6 KiB
Python

"""Migration 0037 adds the nullable project_documents.summary column and removes it again."""
from __future__ import annotations
import asyncio
import os
import uuid
import pytest
import sqlalchemy as sa
from alembic import command
from alembic.script import ScriptDirectory
from sqlalchemy.ext.asyncio import create_async_engine
from support.postgres import asyncpg_test_url
from deerflow.persistence import bootstrap
from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
REVISION = "0037_project_document_summaries"
PREVIOUS = "0036_run_idempotency_request"
CHANNEL_BINDINGS = "0038_channel_thread_bindings"
USER_DISABLED = "0039_user_disabled"
GROUP_NOTICE = "0040_scheduled_group_notice"
IM_NOTICE_PREFERENCES = "0041_im_notice_preferences"
CHANNEL_LEASES = "0042_channel_leases"
RUNTIME_IM_NOTICES = "0043_runtime_im_notices"
CHANNEL_RUNTIME_CONFIGS = "0044_channel_runtime_configs"
pytestmark = pytest.mark.asyncio
async def test_0037_chains_after_0036_in_single_migration_chain():
script = ScriptDirectory(str(bootstrap._MIGRATIONS_DIR))
assert len(script.get_heads()) == 1
assert REVISION in {revision.revision for revision in script.walk_revisions()}
assert script.get_revision(CHANNEL_BINDINGS).down_revision == REVISION
assert script.get_revision(USER_DISABLED).down_revision == CHANNEL_BINDINGS
assert script.get_revision(GROUP_NOTICE).down_revision == USER_DISABLED
assert script.get_revision(IM_NOTICE_PREFERENCES).down_revision == GROUP_NOTICE
assert script.get_revision(CHANNEL_LEASES).down_revision == IM_NOTICE_PREFERENCES
assert script.get_revision(RUNTIME_IM_NOTICES).down_revision == CHANNEL_LEASES
assert script.get_revision(CHANNEL_RUNTIME_CONFIGS).down_revision == RUNTIME_IM_NOTICES
assert script.get_revision(REVISION).down_revision == PREVIOUS
# alembic_version.version_num is VARCHAR(32).
assert len(REVISION) <= 32
def _engine(tmp_path, backend):
if backend == "sqlite":
return create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'migration.db'}"), None
uri = os.environ.get("TEST_POSTGRES_URI")
if not uri:
pytest.skip("requires TEST_POSTGRES_URI (real Postgres migration)")
# Translate libpq sslmode to asyncpg ssl rather than dropping it: a URI
# that requests verify-ca/verify-full must not silently fall back to an
# unverified connection (tests/AGENTS.md "PostgreSQL batch fixtures").
schema = f"doc_summaries_{uuid.uuid4().hex}"
return create_async_engine(asyncpg_test_url(uri), connect_args=build_asyncpg_connect_args(schema)), schema
@pytest.mark.parametrize("backend", ["sqlite", "postgres"])
async def test_0037_upgrade_keeps_rows_and_downgrade_drops_summary(tmp_path, backend):
engine, schema = _engine(tmp_path, backend)
cfg = bootstrap._get_alembic_config(engine, postgres_schema=schema or "")
try:
if schema:
async with engine.begin() as conn:
await conn.execute(sa.text(f'CREATE SCHEMA "{schema}"'))
await asyncio.to_thread(command.upgrade, cfg, PREVIOUS)
async with engine.begin() as conn:
await conn.execute(
sa.text(
"INSERT INTO project_documents (id, project_id, user_id, name, stored_relpath, sha256, size_bytes, created_at, updated_at) "
"VALUES ('d1','p1','u1','notes.txt','u1/p1/d1-notes.txt','ab12',10,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)"
)
)
async def doc_columns():
async with engine.connect() as conn:
return await conn.run_sync(lambda sync: {col["name"]: col for col in sa.inspect(sync).get_columns("project_documents")})
await asyncio.to_thread(command.upgrade, cfg, REVISION)
columns = await doc_columns()
assert columns["summary"]["nullable"] and columns["summary"]["default"] is None
async with engine.connect() as conn:
row = (await conn.execute(sa.text("SELECT name, size_bytes, summary FROM project_documents WHERE id='d1'"))).one()
# No backfill: the pre-existing row keeps a NULL summary.
assert tuple(row) == ("notes.txt", 10, None)
async with engine.begin() as conn:
await conn.execute(sa.text("UPDATE project_documents SET summary='one line' WHERE id='d1'"))
await asyncio.to_thread(command.downgrade, cfg, PREVIOUS)
assert "summary" not in await doc_columns()
async with engine.connect() as conn:
assert (await conn.execute(sa.text("SELECT name, size_bytes FROM project_documents WHERE id='d1'"))).one() == ("notes.txt", 10)
await asyncio.to_thread(command.upgrade, cfg, REVISION)
assert "summary" in await doc_columns()
finally:
if schema:
async with engine.begin() as conn:
await conn.execute(sa.text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
await engine.dispose()
def test_postgres_uri_tls_policy_is_preserved(tmp_path, monkeypatch):
"""The fixture must translate, never drop, an explicit sslmode (tests/AGENTS.md)."""
monkeypatch.setenv("TEST_POSTGRES_URI", "postgresql://u:p@db:5432/deerflow?sslmode=verify-full")
captured = {}
def capture_engine(url, **kwargs):
captured["url"] = url
raise RuntimeError("stop before database acquisition")
monkeypatch.setitem(globals(), "create_async_engine", capture_engine)
with pytest.raises(RuntimeError, match="stop before database acquisition"):
_engine(tmp_path, "postgres")
assert "ssl=verify-full" in captured["url"]
assert "sslmode" not in captured["url"]
assert "channel_binding" not in captured["url"]
def test_postgres_uri_conflicting_ssl_settings_rejected(tmp_path, monkeypatch):
monkeypatch.setenv("TEST_POSTGRES_URI", "postgresql://u:p@db:5432/deerflow?sslmode=require&ssl=disable")
monkeypatch.setitem(globals(), "create_async_engine", lambda *a, **k: None)
with pytest.raises(ValueError, match="Conflicting ssl and sslmode"):
_engine(tmp_path, "postgres")
@pytest.mark.parametrize("channel_binding", ["disable", "prefer", "require"])
async def test_postgres_uri_channel_binding_rejected_before_engine_creation(tmp_path, monkeypatch, channel_binding):
monkeypatch.setenv("TEST_POSTGRES_URI", f"postgresql://u:p@db:5432/deerflow?sslmode=verify-full&channel_binding={channel_binding}")
def reject_engine_creation(*args, **kwargs):
pytest.fail("Unsupported Channel Binding must be rejected before engine creation")
monkeypatch.setitem(globals(), "create_async_engine", reject_engine_creation)
with pytest.raises(ValueError, match="channel_binding"):
_engine(tmp_path, "postgres")