Files
brain-of-reese/tests/integration/test_migration_0006.py
T

195 lines
7.3 KiB
Python

"""Integration: migration 0006 (git_sources) schema contract.
Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the style of
``test_migration_0004.py`` / ``test_migration_0005.py`` (information_schema
assertions on the state the migration must leave). The tests target
revision ``0006`` explicitly so later migrations (0007, …) cannot break
them:
* upgrade 0005 → 0006 → ``git_sources`` exists with exactly the three
columns the phase locks in (``id UUID PK``, ``url TEXT NOT NULL``,
``added_at TIMESTAMPTZ NOT NULL`` default ``now()``) plus the unique
index ``uq_git_sources_url`` (duplicate URLs rejected with an
IntegrityError);
* an insert without ``added_at`` gets the server-stamped default;
* downgrade to 0005 → the table (and its index) is gone;
* upgrade back to 0006 → it is back (round-trip).
The ``alembic`` fixture guarantees the DB ends at head even if a test
fails or the process is interrupted.
"""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import pytest
from alembic.config import Config
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from alembic import command
from app.db import db_available
@pytest.fixture()
def alembic(db: Session) -> Iterator[Config]:
"""Real Alembic config bound to the dev DB (URL from app settings).
Starts at head (repairs an interrupted earlier run); teardown upgrades
to head no matter what happened, so the dev DB is never left below
head.
"""
if not db_available():
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
cfg.set_main_option("script_location", "alembic")
command.upgrade(cfg, "head")
try:
yield cfg
finally:
command.upgrade(cfg, "head")
def _column(db: Session, column: str) -> tuple[Any, ...] | None:
"""(data_type, is_nullable, column_default) for one git_sources column."""
row = db.execute(
text(
"SELECT data_type, is_nullable, column_default"
" FROM information_schema.columns"
" WHERE table_name = 'git_sources' AND column_name = :c"
),
{"c": column},
).fetchone()
return tuple(row) if row is not None else None
def _version(db: Session) -> str | None:
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
def _unique_url_index(db: Session) -> int:
"""1 iff ``uq_git_sources_url`` exists as a UNIQUE index."""
count: Any = db.execute(
text(
"SELECT count(*) FROM pg_indexes"
" WHERE tablename = 'git_sources' AND indexname = 'uq_git_sources_url'"
" AND indexdef ILIKE 'CREATE UNIQUE%'"
)
).scalar()
assert count is not None, "pg_indexes count must be an int"
return int(count)
def _insert_url(db: Session, url: str) -> None:
db.execute(
text("INSERT INTO git_sources (id, url) VALUES (gen_random_uuid(), :u)"),
{"u": url},
)
db.commit()
def test_upgrade_to_0006_creates_git_sources(db: Session, alembic: Config) -> None:
"""Upgrade 0005 → 0006: the table exists with the locked column types,
nullability, server default, primary key, and unique URL index."""
command.downgrade(alembic, "0005") # start from the pre-0006 state
assert _version(db) == "0005"
command.upgrade(alembic, "0006")
assert _version(db) == "0006", "alembic_version must be at 0006"
pk = db.execute(
text(
"SELECT column_name FROM information_schema.table_constraints tc"
" JOIN information_schema.key_column_usage kcu"
" ON tc.constraint_name = kcu.constraint_name"
" WHERE tc.table_name = 'git_sources' AND tc.constraint_type = 'PRIMARY KEY'"
)
).scalar()
assert pk == "id", "git_sources primary key must be id"
id_col = _column(db, "id")
assert id_col is not None, "git_sources.id is missing"
assert id_col[0] == "uuid", "git_sources.id must be UUID"
assert id_col[1] == "NO", "git_sources.id must be NOT NULL"
url = _column(db, "url")
assert url is not None, "git_sources.url is missing"
assert url[0] == "text", "git_sources.url must be TEXT"
assert url[1] == "NO", "git_sources.url must be NOT NULL"
added = _column(db, "added_at")
assert added is not None, "git_sources.added_at is missing"
assert added[0] == "timestamp with time zone", "git_sources.added_at must be TIMESTAMPTZ"
assert added[1] == "NO", "git_sources.added_at must be NOT NULL"
assert added[2] is not None and "now()" in added[2], (
"git_sources.added_at must have server default now()"
)
assert _unique_url_index(db) == 1, "uq_git_sources_url unique index is missing"
def test_added_at_defaults_to_now(db: Session, alembic: Config) -> None:
"""An insert without ``added_at`` gets the server-stamped default —
the API layer never sets it itself (phase 35 task 02)."""
command.upgrade(alembic, "head")
try:
_insert_url(db, "https://git.example.com/mig-test/added-at.git")
stamped = db.execute(
text(
"SELECT added_at IS NOT NULL FROM git_sources"
" WHERE url = 'https://git.example.com/mig-test/added-at.git'"
)
).scalar()
assert stamped is True, "git_sources.added_at must be stamped by the server"
finally:
db.execute(
text(
"DELETE FROM git_sources"
" WHERE url = 'https://git.example.com/mig-test/added-at.git'"
)
)
db.commit()
def test_duplicate_url_rejected(db: Session, alembic: Config) -> None:
"""The unique index is what the API's 409 relies on: a duplicate URL
raises IntegrityError."""
command.upgrade(alembic, "head")
try:
_insert_url(db, "https://git.example.com/mig-test/dup.git")
with pytest.raises(IntegrityError):
_insert_url(db, "https://git.example.com/mig-test/dup.git")
finally:
db.rollback() # the IntegrityError aborts the open transaction
db.execute(
text("DELETE FROM git_sources WHERE url = 'https://git.example.com/mig-test/dup.git'")
)
db.commit()
def test_downgrade_to_0005_drops_table(db: Session, alembic: Config) -> None:
"""Downgrade to 0005: the table (and its index) is dropped (A13 —
reversible)."""
command.downgrade(alembic, "0005")
assert _version(db) == "0005"
exists = db.execute(text("SELECT to_regclass('public.git_sources') IS NOT NULL")).scalar()
assert exists is False, "git_sources must be dropped by the downgrade"
def test_upgrade_round_trip_restores_table(db: Session, alembic: Config) -> None:
"""Downgrade to 0005, then upgrade back to 0006: table, defaults, and
the unique index are back."""
command.downgrade(alembic, "0005")
command.upgrade(alembic, "0006")
assert _version(db) == "0006", "round-trip upgrade must land at 0006"
added = _column(db, "added_at")
assert added is not None and added[2] is not None and "now()" in added[2], (
"git_sources.added_at must keep its now() default after the round-trip"
)
assert _unique_url_index(db) == 1, "uq_git_sources_url must be back after the round-trip"