"""Regression tests for the ResearchStrategy FK retarget (migration 0008). Pre-fix: ``ResearchStrategy.research_id`` was ``Integer FK research.id``, pointing at a dormant table that no production path uses. The live writer ``save_research_strategy`` passes ``research_history`` UUID strings, so every commit raised ``FOREIGN KEY constraint failed`` once v1.6.0 enabled ``PRAGMA foreign_keys``. Post-fix: the FK targets ``research_history.id`` (String(36)) with cascade delete. This module verifies both the schema-level repair and the live behavior the production code depends on. """ from __future__ import annotations import pytest from sqlalchemy import create_engine, event, inspect, text from sqlalchemy.orm import Session from local_deep_research.database.alembic_runner import run_migrations from local_deep_research.database.models.research import ResearchStrategy @pytest.fixture def fk_enforced_engine(tmp_path): """Migrated engine with PRAGMA foreign_keys=ON for every connection. Mirrors the production hook ``apply_performance_pragmas`` so the test actually exercises FK enforcement (off by default in a bare SQLite connection). """ db_path = tmp_path / "research_strategy_fk_regression.db" engine = create_engine(f"sqlite:///{db_path}") @event.listens_for(engine, "connect") def _enable_fk(dbapi_connection, _): dbapi_connection.execute("PRAGMA foreign_keys = ON") run_migrations(engine) yield engine engine.dispose() def _seed_research_history(engine, research_id: str) -> None: with engine.begin() as conn: conn.execute( text( "INSERT INTO research_history (id, query, mode, status, created_at) " "VALUES (:id, 'q', 'quick_summary', 'completed', '2026-01-01')" ), {"id": research_id}, ) class TestResearchStrategyFKTarget: """Schema-level: column type and FK target match the live writer.""" def test_research_id_is_varchar_36_fk_research_history( self, fk_enforced_engine ): inspector = inspect(fk_enforced_engine) cols = { c["name"]: c for c in inspector.get_columns("research_strategies") } assert "VARCHAR" in str(cols["research_id"]["type"]) or "CHAR" in str( cols["research_id"]["type"] ) fks = inspector.get_foreign_keys("research_strategies") research_id_fks = [ fk for fk in fks if "research_id" in fk["constrained_columns"] ] assert len(research_id_fks) == 1 assert research_id_fks[0]["referred_table"] == "research_history" assert research_id_fks[0]["referred_columns"] == ["id"] def test_cascade_on_delete(self, fk_enforced_engine): # Read the on_delete action from PRAGMA directly. SQLAlchemy's SQLite # reflection has historically been inconsistent about populating # ``options.ondelete``; PRAGMA always returns it. with fk_enforced_engine.connect() as conn: rows = conn.execute( text("PRAGMA foreign_key_list(research_strategies)") ).fetchall() research_id_fk = next(row for row in rows if row[3] == "research_id") # PRAGMA columns: id, seq, table, from, to, on_update, on_delete, match assert research_id_fk[6] == "CASCADE" class TestResearchStrategyLiveWrite: """Behavior: the live save path no longer raises FK errors.""" def test_save_strategy_with_research_history_uuid_succeeds( self, fk_enforced_engine ): """The exact pattern from save_research_strategy: insert by UUID.""" rid = "11111111-1111-1111-1111-111111111111" _seed_research_history(fk_enforced_engine, rid) with Session(fk_enforced_engine) as session: session.add( ResearchStrategy( research_id=rid, strategy_name="langgraph-agent" ) ) session.commit() with Session(fk_enforced_engine) as session: stored = ( session.query(ResearchStrategy).filter_by(research_id=rid).one() ) assert stored.strategy_name == "langgraph-agent" def test_orphan_strategy_insert_is_rejected(self, fk_enforced_engine): """FK enforcement is real: insert without parent row must fail.""" from sqlalchemy.exc import IntegrityError with Session(fk_enforced_engine) as session: session.add( ResearchStrategy( research_id="22222222-2222-2222-2222-222222222222", strategy_name="x", ) ) with pytest.raises(IntegrityError): session.commit() def test_cascade_delete_removes_strategy(self, fk_enforced_engine): rid = "33333333-3333-3333-3333-333333333333" _seed_research_history(fk_enforced_engine, rid) with Session(fk_enforced_engine) as session: session.add( ResearchStrategy(research_id=rid, strategy_name="standard") ) session.commit() with fk_enforced_engine.begin() as conn: conn.execute( text("DELETE FROM research_history WHERE id = :id"), {"id": rid}, ) with Session(fk_enforced_engine) as session: assert ( session.query(ResearchStrategy) .filter_by(research_id=rid) .first() is None )