Talent-Lens / tests /unit /test_migration_coverage.py
riezqidr's picture
test(infra): add reproducer for missing rubric migration
015302b
Raw
History Blame Contribute Delete
5.23 kB
"""Migration coverage for the ORM models, without a database.
`tests/integration/test_migrations.py` already proves the schema and the models
agree — by running `compare_metadata` against a live PostgreSQL. That is the
stronger check and it stays authoritative. It is also unavailable on a machine
with no database, which is exactly where a model gets added and its migration
forgotten.
These tests close that window. They read `Base.metadata` and the Alembic
revision scripts as text, so they run anywhere and fail the moment a table is
declared in code without a revision that creates it.
"""
from __future__ import annotations
import re
from pathlib import Path
_VERSIONS = Path(__file__).resolve().parents[2] / "serving" / "app" / "migrations" / "versions"
def _revision_sources() -> dict[Path, str]:
"""Return every revision script keyed by path, excluding caches."""
return {path: path.read_text(encoding="utf-8") for path in sorted(_VERSIONS.glob("*.py"))}
def _declared_tables() -> set[str]:
"""Return the table names the ORM declares."""
import app.models # noqa: F401 — importing registers every table on Base
from app.db import Base
return set(Base.metadata.tables)
def test_revision_scripts_exist() -> None:
"""Anti-vacuous guard: the checks below are meaningless with no scripts."""
assert _revision_sources(), f"no Alembic revision scripts found under {_VERSIONS}"
def test_every_orm_table_is_created_by_some_migration() -> None:
"""A table in the models with no migration is a deployment that cannot boot.
The models are the schema the code queries; the migrations are the schema a
deployment actually gets. When they diverge the failure surfaces as an
UndefinedTable error in production rather than a test failure here.
"""
sources = _revision_sources()
declared = _declared_tables()
assert declared, "Base.metadata is empty — models did not import"
missing = sorted(
table
for table in declared
if not any(
re.search(rf"""create_table\(\s*["']{re.escape(table)}["']""", source)
or re.search(rf"""CREATE TABLE (?:IF NOT EXISTS )?{re.escape(table)}\b""", source)
for source in sources.values()
)
)
assert not missing, (
f"tables declared in app.models with no migration creating them: {missing} — "
"add an Alembic revision before merging"
)
def test_the_revision_graph_has_a_single_head() -> None:
"""Two heads make `upgrade head` ambiguous and the deployment order undefined.
Parsed from source rather than via `ScriptDirectory` so this needs no
database URL and no Alembic config to resolve.
"""
sources = _revision_sources()
revisions: set[str] = set()
parents: set[str] = set()
for source in sources.values():
found = re.search(r"""^revision:\s*str\s*=\s*["']([^"']+)["']""", source, re.MULTILINE)
if found:
revisions.add(found.group(1))
down = re.search(r"""^down_revision:[^=]*=\s*["']([^"']+)["']""", source, re.MULTILINE)
if down:
parents.add(down.group(1))
heads = sorted(revisions - parents)
assert len(heads) == 1, f"expected exactly one migration head, found {heads}"
def test_every_declared_parent_revision_exists() -> None:
"""A `down_revision` naming a deleted script breaks `upgrade` on a fresh database."""
sources = _revision_sources()
revisions: set[str] = set()
parents: dict[str, str] = {}
for path, source in sources.items():
found = re.search(r"""^revision:\s*str\s*=\s*["']([^"']+)["']""", source, re.MULTILINE)
if found:
revisions.add(found.group(1))
down = re.search(r"""^down_revision:[^=]*=\s*["']([^"']+)["']""", source, re.MULTILINE)
if down:
parents[path.name] = down.group(1)
dangling = sorted(
f"{name} -> {parent}" for name, parent in parents.items() if parent not in revisions
)
assert not dangling, f"down_revision points at a revision that does not exist: {dangling}"
def test_tenant_scoped_tables_are_indexed_on_tenant_id() -> None:
"""Tenant isolation that costs a sequential scan does not survive real data.
Every repository filters on `tenant_id`, so an unindexed tenant column turns
each read into a full-table scan. Checked against the ORM metadata, which is
what the integration suite verifies against the live schema.
"""
declared_tables = _declared_tables()
from app.db import Base
assert declared_tables, "Base.metadata is empty — models did not import"
unindexed: list[str] = []
for name, table in Base.metadata.tables.items():
if "tenant_id" not in table.columns:
continue
indexed = {column.name for index in table.indexes for column in index.columns} | {
column.name
for constraint in table.constraints
for column in getattr(constraint, "columns", [])
}
if "tenant_id" not in indexed:
unindexed.append(name)
assert not unindexed, (
f"tables carrying tenant_id with no index covering it: {sorted(unindexed)}"
)