File size: 5,226 Bytes
015302b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
"""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)}"
    )