deer-flow/backend/tests/test_migration_0033_batch_result_artifact.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

125 lines
6.7 KiB
Python

"""Nullable batch evidence migration preserves legacy report rows."""
import asyncio
import os
import uuid
import pytest
import sqlalchemy as sa
from alembic import command
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
from deerflow.persistence.subagent_batches.model import SubagentBatchItemRow
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ["sqlite", "postgres"])
async def test_upgrade_downgrade_and_reupgrade_preserve_report(tmp_path, backend):
schema = None
if backend == "postgres":
uri = os.environ.get("TEST_POSTGRES_URI")
if not uri:
pytest.skip("requires TEST_POSTGRES_URI (real Postgres batch evidence migration)")
schema = f"batch_evidence_{uuid.uuid4().hex}"
engine = create_async_engine(asyncpg_test_url(uri), connect_args=build_asyncpg_connect_args(schema))
else:
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'batch.db'}")
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(bootstrap._upgrade, cfg, "0032_activity_and_task_events")
async with engine.begin() as conn:
await conn.execute(
sa.text(
"INSERT INTO subagent_batches (id,user_id,thread_id,submission_key,title,subagent_type,status,total_items,max_live_items,max_running_items,max_attempts,execution_spec,created_at,updated_at) "
"VALUES ('b','u','t','k','title','general-purpose','completed',1,1,1,2,'{}',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)"
)
)
await conn.execute(
sa.text(
"INSERT INTO subagent_batch_items (id,batch_id,item_key,position,prompt,status,attempt,result,result_truncated,created_at,updated_at) "
"VALUES ('i','b','k',0,'p','succeeded',1,'old report',FALSE,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)"
)
)
await bootstrap.bootstrap_schema(engine, backend=backend, postgres_schema=schema or "")
await bootstrap.bootstrap_schema(engine, backend=backend, postgres_schema=schema or "")
async with engine.connect() as conn:
row = (await conn.execute(sa.text("SELECT result,status,result_artifact FROM subagent_batch_items"))).one()
assert tuple(row) == ("old report", "succeeded", None)
snapshot = {"knowledge_sources": {"version": 1, "sources": [{"id": "a" * 32 + "-1", "provider": "ragflow", "dataset_id": "kb", "document_id": "doc", "chunk_id": "chunk", "text": "Captured evidence ☃"}], "omitted_count": 0}}
async with engine.begin() as conn:
await conn.execute(sa.update(SubagentBatchItemRow).where(SubagentBatchItemRow.id == "i").values(result_artifact=snapshot))
async with engine.connect() as conn:
assert (await conn.execute(sa.select(SubagentBatchItemRow.result_artifact).where(SubagentBatchItemRow.id == "i"))).scalar_one() == snapshot
await asyncio.to_thread(command.downgrade, cfg, "0032_activity_and_task_events")
async with engine.connect() as conn:
columns = await conn.run_sync(lambda sync: {column["name"] for column in sa.inspect(sync).get_columns("subagent_batch_items")})
assert "result_artifact" not in columns
assert (await conn.execute(sa.text("SELECT result FROM subagent_batch_items"))).scalar_one() == "old report"
await bootstrap.bootstrap_schema(engine, backend=backend, postgres_schema=schema or "")
async with engine.connect() as conn:
assert (await conn.execute(sa.text("SELECT result_artifact FROM subagent_batch_items"))).scalar_one() is None
finally:
if schema:
async with engine.begin() as conn:
await conn.execute(sa.text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
await engine.dispose()
class _StopBeforeDatabase(Exception):
"""End a connection-contract probe before acquiring database resources."""
@pytest.mark.asyncio
@pytest.mark.parametrize("sslmode", [None, "disable", "allow", "prefer", "require", "verify-ca", "verify-full"])
async def test_postgres_migration_setup_preserves_tls_policy(monkeypatch, tmp_path, sslmode):
uri = "postgresql://user:password@localhost:5432/test?target_session_attrs=read-write"
if sslmode is not None:
uri += f"&sslmode={sslmode}"
monkeypatch.setenv("TEST_POSTGRES_URI", uri)
def capture_engine(url, **kwargs):
parsed = sa.engine.make_url(url)
_args, connect_options = parsed.get_dialect()().create_connect_args(parsed)
assert connect_options["target_session_attrs"] == "read-write"
assert "sslmode" not in connect_options and "channel_binding" not in connect_options
if sslmode is None:
assert "ssl" not in connect_options
else:
assert connect_options.get("ssl") == sslmode
raise _StopBeforeDatabase
monkeypatch.setitem(globals(), "create_async_engine", capture_engine)
with pytest.raises(_StopBeforeDatabase):
await test_upgrade_downgrade_and_reupgrade_preserve_report(tmp_path, "postgres")
@pytest.mark.asyncio
async def test_postgres_migration_setup_rejects_conflicting_tls_policy(monkeypatch, tmp_path):
monkeypatch.setenv("TEST_POSTGRES_URI", "postgresql://user:password@localhost:5432/test?ssl=require&sslmode=disable")
def reject_engine_creation(*args, **kwargs):
pytest.fail("Conflicting TLS options must be rejected before engine creation")
monkeypatch.setitem(globals(), "create_async_engine", reject_engine_creation)
with pytest.raises(ValueError, match="Conflicting ssl and sslmode"):
await test_upgrade_downgrade_and_reupgrade_preserve_report(tmp_path, "postgres")
@pytest.mark.asyncio
@pytest.mark.parametrize("channel_binding", ["disable", "prefer", "require"])
async def test_postgres_migration_setup_rejects_channel_binding_before_engine_creation(monkeypatch, tmp_path, channel_binding):
monkeypatch.setenv("TEST_POSTGRES_URI", f"postgresql://user:password@localhost:5432/test?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"):
await test_upgrade_downgrade_and_reupgrade_preserve_report(tmp_path, "postgres")