"""Tests for migration 0006: Journal Quality System. This migration consolidates what were originally five separate revisions (0006-0010) into a single atomic change. It creates the ``papers`` and ``paper_appearances`` tables, adds the ``name_lower`` / ``score_source`` / ``quality_model`` columns + indexes to ``journals``, and adds the ``ix_research_resources_research_id`` FK index. Tests cover: - New table creation (``papers``, ``paper_appearances``) with the correct columns, uniques, and FK cascade actions. - New column creation on ``journals`` with ``name_lower`` backfill (Python ``str.lower`` on existing rows). - Named index creation and idempotency. - Upgrade → downgrade → upgrade roundtrip (schema restored, no leftover objects). - Timestamp columns use ``UtcDateTime`` with ``utcnow()`` server defaults (enforced by the pre-commit hook but worth a runtime check in case the hook is bypassed). """ import pytest from alembic import command from sqlalchemy import create_engine, inspect, text from local_deep_research.database.alembic_runner import ( get_alembic_config, get_head_revision, run_migrations, ) # --------------------------------------------------------------------------- # # Helpers # # --------------------------------------------------------------------------- # 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 _table_exists(engine, name): return inspect(engine).has_table(name) def _get_columns(engine, table_name): insp = inspect(engine) if not insp.has_table(table_name): return {} return {col["name"]: col for col in insp.get_columns(table_name)} def _get_indexes_by_name(engine, table_name): insp = inspect(engine) if not insp.has_table(table_name): return {} return { idx["name"]: idx["column_names"] for idx in insp.get_indexes(table_name) if idx["name"] is not None } def _get_unique_column_sets(engine, table_name): insp = inspect(engine) if not insp.has_table(table_name): return [] return [ set(u["column_names"]) for u in insp.get_unique_constraints(table_name) ] def _get_fk_ondelete(engine, table_name): """Return {column_name: ondelete_action} for all FKs in the table.""" insp = inspect(engine) if not insp.has_table(table_name): return {} out = {} for fk in insp.get_foreign_keys(table_name): cols = fk.get("constrained_columns") or [] if not cols: continue ondelete = (fk.get("options") or {}).get("ondelete") out[cols[0]] = ondelete return out # --------------------------------------------------------------------------- # # Fixtures # # --------------------------------------------------------------------------- # @pytest.fixture def fresh_engine(tmp_path): """Brand-new SQLite database — no migrations applied yet.""" db_path = tmp_path / "fresh_0006_test.db" engine = create_engine(f"sqlite:///{db_path}") yield engine engine.dispose() @pytest.fixture def migrated_to_0005_engine(tmp_path): """Database stamped at 0005 (just before the journal-quality schema).""" db_path = tmp_path / "migrated_0005_test.db" engine = create_engine(f"sqlite:///{db_path}") _run_upgrade_to(engine, "0005") yield engine engine.dispose() @pytest.fixture def fully_migrated_engine(tmp_path): """Database stamped at head (includes 0006).""" db_path = tmp_path / "fully_migrated_0006_test.db" engine = create_engine(f"sqlite:///{db_path}") run_migrations(engine) yield engine engine.dispose() # --------------------------------------------------------------------------- # # Tests — new tables # # --------------------------------------------------------------------------- # class TestMigration0006PapersTable: """Creation of the ``papers`` table with the right shape.""" def test_papers_table_exists(self, fully_migrated_engine): assert _table_exists(fully_migrated_engine, "papers") def test_papers_has_expected_columns(self, fully_migrated_engine): cols = _get_columns(fully_migrated_engine, "papers") expected = { "id", "doi", "arxiv_id", "pmid", "journal_id", "container_title", "year", "metadata", "created_at", "updated_at", } assert expected.issubset(set(cols)) def test_papers_has_no_journal_quality_column(self, fully_migrated_engine): """Quality is resolved live (journals.quality + bundled ref DB) so there is no frozen per-Paper ``journal_quality`` column — re-adding it would re-introduce the staleness footgun the migration design explicitly avoids. """ cols = _get_columns(fully_migrated_engine, "papers") assert "journal_quality" not in cols def test_papers_year_is_indexed(self, fully_migrated_engine): """Year is promoted out of the metadata JSON blob to a first-class integer column so the dashboard can filter/group by year without paying for json_extract on every row. """ indexes = _get_indexes_by_name(fully_migrated_engine, "papers") assert "idx_papers_year" in indexes assert indexes["idx_papers_year"] == ["year"] def test_papers_year_nullable(self, fully_migrated_engine): """Year is nullable — many sources lack a publication year.""" cols = _get_columns(fully_migrated_engine, "papers") assert cols["year"]["nullable"] is True def test_container_title_nullable(self, fully_migrated_engine): """container_title is nullable — populated by the write path only when the filter scored the result, never required. """ cols = _get_columns(fully_migrated_engine, "papers") assert cols["container_title"]["nullable"] is True def test_identifier_columns_are_unique(self, fully_migrated_engine): """doi / arxiv_id / pmid each carry a single-column UNIQUE constraint — that's the mechanism dedup relies on. """ unique_sets = _get_unique_column_sets(fully_migrated_engine, "papers") assert {"doi"} in unique_sets assert {"arxiv_id"} in unique_sets assert {"pmid"} in unique_sets def test_identifier_columns_are_nullable(self, fully_migrated_engine): """Papers without identifiers are still inserted — the UNIQUE constraint must tolerate multiple NULLs (standard SQLite behavior). """ cols = _get_columns(fully_migrated_engine, "papers") assert cols["doi"]["nullable"] is True assert cols["arxiv_id"]["nullable"] is True assert cols["pmid"]["nullable"] is True def test_journal_id_has_fk_with_set_null(self, fully_migrated_engine): """journal_id → journals.id with ON DELETE SET NULL. Deleting a journal leaves orphan Paper rows rather than cascading the delete — paper provenance is worth preserving even if the upstream journal is removed. """ fks = _get_fk_ondelete(fully_migrated_engine, "papers") assert fks.get("journal_id", "").upper() == "SET NULL" def test_idx_papers_journal_exists(self, fully_migrated_engine): indexes = _get_indexes_by_name(fully_migrated_engine, "papers") assert "idx_papers_journal" in indexes assert indexes["idx_papers_journal"] == ["journal_id"] def test_idx_papers_container_title_exists(self, fully_migrated_engine): """Dashboard GROUP BY container_title needs an index to be cheap.""" indexes = _get_indexes_by_name(fully_migrated_engine, "papers") assert "idx_papers_container_title" in indexes assert indexes["idx_papers_container_title"] == ["container_title"] class TestMigration0006PaperAppearancesTable: """Creation of the ``paper_appearances`` join table.""" def test_paper_appearances_table_exists(self, fully_migrated_engine): assert _table_exists(fully_migrated_engine, "paper_appearances") def test_paper_appearances_has_expected_columns( self, fully_migrated_engine ): cols = _get_columns(fully_migrated_engine, "paper_appearances") expected = { "id", "paper_id", "resource_id", "source_engine", "created_at", } assert expected.issubset(set(cols)) def test_resource_id_is_unique(self, fully_migrated_engine): """One resource appears in exactly one paper_appearance row — enforced at the schema level so dedup bugs can't double-count. """ unique_sets = _get_unique_column_sets( fully_migrated_engine, "paper_appearances" ) assert {"resource_id"} in unique_sets def test_fks_use_cascade_ondelete(self, fully_migrated_engine): """Both FKs on paper_appearances cascade: deleting a paper or the research resource must also drop the join row so we don't accrue dangling rows pointing at missing parents. """ fks = _get_fk_ondelete(fully_migrated_engine, "paper_appearances") assert fks.get("paper_id", "").upper() == "CASCADE" assert fks.get("resource_id", "").upper() == "CASCADE" def test_paper_id_is_indexed(self, fully_migrated_engine): """An index on paper_id must exist for the papers→appearances join hot path. The exact index name depends on who wins the race between alembic's named index and any ORM bootstrap that also declares a backing index, so we check by column coverage rather than by name. """ indexes = _get_indexes_by_name( fully_migrated_engine, "paper_appearances" ) assert any(cols == ["paper_id"] for cols in indexes.values()), ( f"no index covering paper_id found; got {indexes}" ) def test_paper_appearances_paper_id_indexed_after_migration( self, fully_migrated_engine ): """The migration path must produce the named index ``ix_paper_appearances_paper_id``. create_all() produces the same name (from ``index=True`` on citation.py:159), so both paths agree. A missing named index here means alembic-only installs are running full scans on the paper→appearance join. """ indexes = _get_indexes_by_name( fully_migrated_engine, "paper_appearances" ) assert "ix_paper_appearances_paper_id" in indexes, ( f"named index ix_paper_appearances_paper_id missing from " f"migration path; got {list(indexes.keys())}" ) assert indexes["ix_paper_appearances_paper_id"] == ["paper_id"] # --------------------------------------------------------------------------- # # Tests — journals columns + name_lower backfill # # --------------------------------------------------------------------------- # class TestMigration0006JournalsColumns: """Column additions and the ``name_lower`` backfill.""" def test_new_columns_present(self, fully_migrated_engine): cols = _get_columns(fully_migrated_engine, "journals") assert "name_lower" in cols assert "score_source" in cols assert "quality_model" in cols def test_indexes_present(self, fully_migrated_engine): """0006 creates ``ix_journals_quality_model``. It does NOT create a non-unique index on ``name_lower``: the ``uq_journals_name_lower`` UNIQUE constraint already provides the backing index and a second B-tree on the same column would be pure write-amplification. """ indexes = _get_indexes_by_name(fully_migrated_engine, "journals") assert "ix_journals_quality_model" in indexes assert "ix_journals_name_lower" not in indexes, ( "ix_journals_name_lower is redundant with uq_journals_name_lower" " — must not be created by 0006." ) assert "ix_journals_name" not in indexes, ( "ix_journals_name is redundant with UNIQUE on Journal.name" " — must not be created via model.create_all() either." ) def test_name_lower_backfill_preserves_diacritics( self, migrated_to_0005_engine ): """Python ``str.lower`` on an existing row should produce the same normalized form the runtime insert path emits — diacritics must survive unchanged. """ engine = migrated_to_0005_engine with engine.begin() as conn: conn.execute( text( "INSERT INTO journals (name, quality, quality_analysis_time) " "VALUES ('Café Scientifique', 5, 0)" ) ) _run_upgrade_to(engine, "0006") with engine.connect() as conn: row = conn.execute( text( "SELECT name_lower FROM journals " "WHERE name = 'Café Scientifique'" ) ).fetchone() assert row is not None assert row[0] == "café scientifique" def test_name_lower_unique_constraint_exists(self, fully_migrated_engine): """UNIQUE on ``name_lower`` is the defence against two rows with different-cased ``name`` values splitting the journal cache — e.g. ``"Nature Medicine"`` vs ``"NATURE MEDICINE"`` both passing the ``name`` UNIQUE check while agreeing on ``name_lower``.""" unique_sets = _get_unique_column_sets(fully_migrated_engine, "journals") assert {"name_lower"} in unique_sets def test_migration_dedupes_name_lower_collisions_before_unique( self, migrated_to_0005_engine ): """If pre-0006 rows collide on ``name_lower`` (possible because 0005's schema had no UNIQUE constraint), the migration must dedupe before adding the new UNIQUE constraint, otherwise the ALTER TABLE would fail. The surviving row is the HIGHEST- quality one — the best LLM verdict wins — with ties broken by lowest id. """ engine = migrated_to_0005_engine with engine.begin() as conn: # Pre-0006 schema has no name_lower column yet, so collisions # emerge after backfill. Insert two rows with different- # cased ``name`` values — both pass the ``name`` UNIQUE but # will produce the same name_lower. conn.execute( text( "INSERT INTO journals (name, quality, quality_analysis_time) " "VALUES ('Nature Medicine', 8, 1000)" ) ) conn.execute( text( "INSERT INTO journals (name, quality, quality_analysis_time) " "VALUES ('NATURE MEDICINE', 9, 2000)" ) ) # Run 0006 — backfill + dedupe + UNIQUE. _run_upgrade_to(engine, "0006") with engine.connect() as conn: rows = conn.execute( text( "SELECT id, name, name_lower, quality FROM journals " "WHERE name_lower = 'nature medicine' ORDER BY id" ) ).fetchall() assert len(rows) == 1, ( f"Expected dedupe to leave exactly 1 row, got {len(rows)}: {rows}" ) # Highest quality wins — that's the second insert (quality=9). assert rows[0][1] == "NATURE MEDICINE" assert rows[0][3] == 9 def test_dedupe_prefers_highest_quality_across_nfkc_variants( self, migrated_to_0005_engine ): """Case-fold dedupe must ALSO collapse NFKC compatibility variants (e.g. "Physics Letters™" vs "Physics Letters TM"), and the surviving row must be the highest-quality one. """ engine = migrated_to_0005_engine with engine.begin() as conn: conn.execute( text( "INSERT INTO journals (name, quality, quality_analysis_time) " "VALUES ('Physics Letters\u2122', 5, 1000)" ) ) conn.execute( text( "INSERT INTO journals (name, quality, quality_analysis_time) " "VALUES ('physics lettersTM', 8, 2000)" ) ) _run_upgrade_to(engine, "0006") with engine.connect() as conn: rows = conn.execute( text( "SELECT name, name_lower, quality FROM journals " "WHERE name_lower = 'physics letterstm'" ) ).fetchall() assert len(rows) == 1, f"Expected 1 row after NFKC dedupe, got {rows}" assert rows[0][2] == 8 def test_backfill_nfkc_roundtrip(self, migrated_to_0005_engine): """Backfilled ``name_lower`` must match scoring.normalize_name output. U+2122 (™) NFKC-decomposes to "TM"; bare .lower() would leave it intact. This test locks NFKC semantics against silent regression to bare lowercase. """ engine = migrated_to_0005_engine with engine.begin() as conn: conn.execute( text( "INSERT INTO journals (name, quality, quality_analysis_time) " "VALUES ('Physics Letters\u2122', 7, 1000)" ) ) _run_upgrade_to(engine, "0006") with engine.connect() as conn: row = conn.execute( text("SELECT name, name_lower FROM journals WHERE quality = 7") ).fetchone() # Expected: NFKC(Physics Letters™) → "Physics LettersTM" → lower → "physics letterstm" assert row[1] == "physics letterstm", ( f"Expected NFKC-normalized name_lower, got {row[1]!r}" ) # NOTE: no test for name=NULL rows because the 0005 schema has # NOT NULL on journals.name (Journal model, journal.py:37). The # ``row.name is not None`` skip in the migration's dedupe is # defensive against a scenario the schema disallows; we cannot # seed it in a regression test. # --------------------------------------------------------------------------- # # Tests — research_resources index # # --------------------------------------------------------------------------- # class TestMigration0006ResearchResourcesIndex: def test_research_id_index_exists(self, fully_migrated_engine): indexes = _get_indexes_by_name( fully_migrated_engine, "research_resources" ) assert "ix_research_resources_research_id" in indexes assert indexes["ix_research_resources_research_id"] == ["research_id"] def test_research_resources_index_present_via_create_all( self, fresh_engine ): """The model MUST declare the index (via __table_args__) so a fresh ``Base.metadata.create_all()`` path produces the same ``ix_research_resources_research_id`` as the migration path. Without this, create_all-style installs (dev setups, test fixtures bypassing migrations) have no research_id index and run full-table scans on 20+ call sites. """ from local_deep_research.database.models.base import Base Base.metadata.create_all(fresh_engine) indexes = _get_indexes_by_name(fresh_engine, "research_resources") assert "ix_research_resources_research_id" in indexes, ( f"named index missing after create_all; got {list(indexes.keys())}" ) assert indexes["ix_research_resources_research_id"] == ["research_id"] # --------------------------------------------------------------------------- # # Tests — idempotency + roundtrip # # --------------------------------------------------------------------------- # class TestMigration0006Idempotency: """Running the migration twice must be a no-op (no errors, same schema). The migration uses ``_table_exists`` / ``_column_exists`` / ``_index_exists`` guards for exactly this reason. """ def test_double_migrate_no_error(self, fresh_engine): run_migrations(fresh_engine) # Second run should succeed silently. run_migrations(fresh_engine) head = get_head_revision() assert head is not None and head.isdigit() and len(head) == 4 def test_rerun_recreates_dropped_papers_indexes( self, migrated_to_0005_engine ): """Regression: if ``papers`` exists but one of its named indexes was dropped (partial migration, manual intervention, etc.), a rerun of 0006 must recreate the missing index rather than silently skip it. Prior behaviour gated all three ``idx_papers_*`` creations on ``if not _table_exists('papers')``, so the rerun path could never converge. """ engine = migrated_to_0005_engine _run_upgrade_to(engine, "0006") # Simulate drift: drop one of the named indexes. with engine.begin() as conn: conn.execute(text("DROP INDEX idx_papers_year")) assert "idx_papers_year" not in _get_indexes_by_name(engine, "papers") # Stamp back to 0005 without touching the schema, then re-upgrade. # The table still exists, so the rerun must hit the index-guard path. config = get_alembic_config(engine) with engine.begin() as conn: config.attributes["connection"] = conn command.stamp(config, "0005") _run_upgrade_to(engine, "0006") indexes = _get_indexes_by_name(engine, "papers") assert "idx_papers_year" in indexes assert indexes["idx_papers_year"] == ["year"] # The other two indexes must also still be present (not re-created # twice, not dropped). assert "idx_papers_journal" in indexes assert "idx_papers_container_title" in indexes class TestMigration0006Roundtrip: """Upgrade 0005 → 0006 → downgrade → upgrade again. Downgrade must remove every object the upgrade created; the second upgrade must recreate them without error. This is the cheapest way to catch missing drops in ``downgrade()``. """ def test_downgrade_then_upgrade_restores_schema( self, migrated_to_0005_engine ): engine = migrated_to_0005_engine _run_upgrade_to(engine, "0006") assert _table_exists(engine, "papers") assert _table_exists(engine, "paper_appearances") assert "name_lower" in _get_columns(engine, "journals") _run_downgrade_to(engine, "0005") assert not _table_exists(engine, "papers") assert not _table_exists(engine, "paper_appearances") assert "name_lower" not in _get_columns(engine, "journals") assert "score_source" not in _get_columns(engine, "journals") assert "quality_model" not in _get_columns(engine, "journals") # Index on research_resources is dropped too. rr_indexes = _get_indexes_by_name(engine, "research_resources") assert "ix_research_resources_research_id" not in rr_indexes _run_upgrade_to(engine, "0006") assert _table_exists(engine, "papers") assert _table_exists(engine, "paper_appearances") assert "name_lower" in _get_columns(engine, "journals") def test_downgrade_preserves_journals_data(self, migrated_to_0005_engine): """The 0005 baseline columns (id, name, quality, quality_analysis_time) must survive a downgrade. Only the three columns added by 0006 (name_lower, score_source, quality_model) are dropped. The downgrade docstring promises this; this test enforces it. """ engine = migrated_to_0005_engine with engine.begin() as conn: conn.execute( text( "INSERT INTO journals (name, quality, quality_analysis_time) " "VALUES ('Nature', 10, 1234567890)" ) ) _run_upgrade_to(engine, "0006") _run_downgrade_to(engine, "0005") with engine.connect() as conn: row = conn.execute( text( "SELECT name, quality, quality_analysis_time " "FROM journals WHERE name = 'Nature'" ) ).fetchone() assert row is not None, "journals row lost on downgrade" assert row[0] == "Nature" assert row[1] == 10 assert row[2] == 1234567890