"""Tests for migration 0009: default search.fetch.mode 'full' → 'summary_focus_query'. Pins the upgrade/downgrade semantics: - Rows with the legacy default ``"full"`` get flipped. - Rows users explicitly chose (``summary_focus``, ``summary_focus_query``, ``disabled``) are left untouched. - Other settings keys with value ``"full"`` are not affected. - Idempotency: a second upgrade is a no-op once values are migrated. The on-disk encoding is JSON-text (``"full"`` with the surrounding quotes), so the test inserts through SQLAlchemy's JSON column type to match production storage exactly. """ import json import pytest from alembic import command from sqlalchemy import create_engine, text from local_deep_research.database.alembic_runner import ( get_alembic_config, ) def _run_upgrade_to(engine, revision): config = get_alembic_config(engine) with engine.begin() as conn: config.attributes["connection"] = conn command.upgrade(config, revision) def _run_downgrade_to(engine, revision): config = get_alembic_config(engine) with engine.begin() as conn: config.attributes["connection"] = conn command.downgrade(config, revision) def _seed_setting(engine, key, value): """Insert a setting matching production's JSON-text storage. SQLAlchemy's JSON column writes ``json.dumps(value)``; we mirror that explicitly so raw SQL produces the same on-disk bytes the migration expects to match in its WHERE clause. """ with engine.begin() as conn: conn.execute( text( "INSERT INTO settings " "(key, value, type, name, ui_element, visible, editable) " "VALUES (:key, :value, 'search', :name, 'select', 1, 1)" ), {"key": key, "value": json.dumps(value), "name": key}, ) def _read_setting(engine, key): with engine.begin() as conn: row = conn.execute( text("SELECT value FROM settings WHERE key = :key"), {"key": key}, ).fetchone() if row is None: return None raw = row[0] # JSON column round-trips through json.loads on read, but raw text() # bypasses the column type — so decode manually. return json.loads(raw) if isinstance(raw, str) else raw @pytest.fixture def migrated_to_0008_engine(tmp_path): """Database fully migrated through 0008 (the revision before 0009).""" db_path = tmp_path / "test_0009.db" engine = create_engine(f"sqlite:///{db_path}") _run_upgrade_to(engine, "0008") yield engine engine.dispose() class TestMigration0009Upgrade: def test_full_value_is_migrated_to_summary_focus_query( self, migrated_to_0008_engine ): engine = migrated_to_0008_engine with engine.begin() as conn: conn.execute( text("DELETE FROM settings WHERE key = 'search.fetch.mode'") ) _seed_setting(engine, "search.fetch.mode", "full") _run_upgrade_to(engine, "0009") assert ( _read_setting(engine, "search.fetch.mode") == "summary_focus_query" ) @pytest.mark.parametrize( "explicit_value", ["summary_focus", "summary_focus_query", "disabled"], ) def test_explicit_non_full_choices_are_preserved( self, migrated_to_0008_engine, explicit_value ): engine = migrated_to_0008_engine with engine.begin() as conn: conn.execute( text("DELETE FROM settings WHERE key = 'search.fetch.mode'") ) _seed_setting(engine, "search.fetch.mode", explicit_value) _run_upgrade_to(engine, "0009") assert _read_setting(engine, "search.fetch.mode") == explicit_value def test_other_keys_with_full_value_are_not_touched( self, migrated_to_0008_engine ): engine = migrated_to_0008_engine # An unrelated key that happens to hold 'full' must not be flipped. _seed_setting(engine, "test.unrelated.mode", "full") _run_upgrade_to(engine, "0009") assert _read_setting(engine, "test.unrelated.mode") == "full" def test_upgrade_is_idempotent(self, migrated_to_0008_engine): """Running the upgrade a second time is a no-op (already at head).""" engine = migrated_to_0008_engine with engine.begin() as conn: conn.execute( text("DELETE FROM settings WHERE key = 'search.fetch.mode'") ) _seed_setting(engine, "search.fetch.mode", "full") _run_upgrade_to(engine, "0009") # Second invocation: nothing left to do; alembic short-circuits. _run_upgrade_to(engine, "0009") assert ( _read_setting(engine, "search.fetch.mode") == "summary_focus_query" ) def test_no_settings_row_does_not_error(self, migrated_to_0008_engine): """If the row never existed, the migration is a clean no-op.""" engine = migrated_to_0008_engine with engine.begin() as conn: conn.execute( text("DELETE FROM settings WHERE key = 'search.fetch.mode'") ) _run_upgrade_to(engine, "0009") assert _read_setting(engine, "search.fetch.mode") is None class TestMigration0009Downgrade: def test_downgrade_reverts_summary_focus_query_to_full( self, migrated_to_0008_engine ): engine = migrated_to_0008_engine with engine.begin() as conn: conn.execute( text("DELETE FROM settings WHERE key = 'search.fetch.mode'") ) _seed_setting(engine, "search.fetch.mode", "full") _run_upgrade_to(engine, "0009") _run_downgrade_to(engine, "0008") assert _read_setting(engine, "search.fetch.mode") == "full" @pytest.mark.parametrize("preserved_value", ["summary_focus", "disabled"]) def test_downgrade_preserves_other_explicit_choices( self, migrated_to_0008_engine, preserved_value ): engine = migrated_to_0008_engine with engine.begin() as conn: conn.execute( text("DELETE FROM settings WHERE key = 'search.fetch.mode'") ) _seed_setting(engine, "search.fetch.mode", preserved_value) _run_upgrade_to(engine, "0009") _run_downgrade_to(engine, "0008") assert _read_setting(engine, "search.fetch.mode") == preserved_value class TestMigration0009HeadAlignment: def test_0009_chains_correctly_to_0008(self): """0009 (default_fetch_mode_summary) chains directly off 0008. Originally this asserted ``get_head_revision() == "0009"``, but a later migration added 0010 (chat tables, including the partial unique chat-in-progress index) on top, so head moved past 0009. The substantive invariant the original test was protecting — that 0009 is correctly anchored in the chain — survives by checking down_revision instead. """ from alembic.config import Config from alembic.script import ScriptDirectory from local_deep_research.database.alembic_runner import ( get_migrations_dir, ) config = Config() config.set_main_option("script_location", str(get_migrations_dir())) script = ScriptDirectory.from_config(config) rev_0009 = script.get_revision("0009") assert rev_0009.down_revision == "0008"