7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
634 lines
25 KiB
Python
634 lines
25 KiB
Python
"""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
|