"""Integration: migration 0024 (git_sources.branch) schema contract (phase 135, task 01). Drives the **real Alembic engine** against the live dev database (``podman compose up -d db``), mirroring the house pattern of ``test_migration_0021.py`` (information_schema assertions on the state the migration must leave). The tests target the 0023 → 0024 step explicitly so later migrations cannot break the pins: * upgrade 0023 → 0024 → ``branch`` exists with the full contract — TEXT, NULLABLE, no server default — while the 0023 ``git_sources`` schema (``url`` NOT NULL + the unique index, ``kind``, ``path``, ``ignore_paths``, ``include_hidden``, ``token``, ``added_at``) survives; * rows inserted while the DB is at 0023 backfill ``branch`` to NULL — ALL of them (the byte-identical guarantee: a pre-phase-135 row is a default-branch source by definition; no backfill, no default); * the ORM contract agrees: a freshly inserted ``GitSource`` with an explicit ``branch`` round-trips it through a fresh session, and a row without one reads ``branch is None``; * downgrade to 0023 → the column is GONE (A13) while the row + its ``url`` survive; upgrade back to 0024 → the column 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 import uuid from collections.abc import Iterator from typing import Any import pytest from alembic.config import Config from sqlalchemy import text from sqlalchemy.orm import Session from alembic import command from app.db import SessionLocal, db_available from app.models import GitSource URL_A = "https://github.com/mig0024/repo-a.git" URL_B = "https://github.com/mig0024/repo-b.git" BRANCH = "release/1.0" @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: # Release the test session's open transaction BEFORE the repair # DDL: an idle-in-transaction SELECT holds an ACCESS SHARE lock # on ``git_sources``, which would deadlock the repair's # ``ALTER TABLE`` (0024) forever. db.rollback() command.upgrade(cfg, "head") def _version(db: Session) -> str | None: return db.execute(text("SELECT version_num FROM alembic_version")).scalar() 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 _insert_sql(db: Session, url: str, token: str | None = None) -> uuid.UUID: """Insert one git_sources row with the PRE-0024 column set (the 0023 shape — the branch column, when present, is omitted so a NULL backfill is what the row reads).""" row_id = uuid.uuid4() db.execute( text( "INSERT INTO git_sources (id, url, kind, path, ignore_paths," " include_hidden, token)" " VALUES (:id, :u, 'git', NULL, '[]', false, :t)" ), {"id": row_id, "u": url, "t": token}, ) db.commit() return row_id def _delete(db: Session, row_id: uuid.UUID) -> None: db.execute(text("DELETE FROM git_sources WHERE id = :id"), {"id": row_id}) db.commit() def test_upgrade_to_0024_adds_branch(db: Session, alembic: Config) -> None: """Upgrade 0023 → 0024: ``branch`` exists with the full contract (TEXT, NULLABLE, no server default — NULL = the repo's default branch), is ABSENT at 0023, and every pre-0024 row (WITH and WITHOUT a stored token) backfills ``branch`` to NULL — the byte-identical guarantee, no backfill and no default — while the 0023 table contract (``url`` + unique index, ``kind``, ``ignore_paths``, ``include_hidden``, ``token``, ``added_at``) survives.""" command.downgrade(alembic, "0023") # start from the pre-0024 state assert _version(db) == "0023" assert _column(db, "branch") is None, "branch must be absent at 0023" pre_a = _insert_sql(db, URL_A) # the 0023 column set pre_b = _insert_sql(db, URL_B, token="ghp_mig0024secret") try: command.upgrade(alembic, "0024") assert _version(db) == "0024", "alembic_version must be at 0024" branch = _column(db, "branch") assert branch is not None, "git_sources.branch is missing" assert branch[0] == "text", "branch must be TEXT" assert branch[1] == "YES", "branch must be NULLABLE" assert branch[2] is None, ( "branch must carry NO server default — NULL is the" " default-branch value" ) # Every pre-0024 row backfilled to NULL (the repo's default # branch — the byte-identical guarantee for ALL existing rows). for pre_id, url in ((pre_a, URL_A), (pre_b, URL_B)): row = db.execute( text("SELECT url, token, branch FROM git_sources WHERE id = :id"), {"id": pre_id}, ).fetchone() assert row is not None and row[0] == url, ( "the pre-0024 row must survive the upgrade" ) assert row[2] is None, ( "every pre-0024 row must backfill branch to NULL — no" " backfill, no default" ) # The tokened row kept its credential (the upgrade is # branch-only). assert db.execute( text("SELECT token FROM git_sources WHERE id = :id"), {"id": pre_b}, ).scalar() == "ghp_mig0024secret", ( "a pre-0024 stored token must survive the branch upgrade" ) # A row written without the column stays NULL (no default to # fill it in — the Python-side default is None, same value). new_id = _insert_sql(db, URL_A.replace("repo-a", "repo-c")) try: omitted = db.execute( text("SELECT branch FROM git_sources WHERE id = :id"), {"id": new_id}, ).scalar() assert omitted is None, "an omitted branch must stay NULL" finally: _delete(db, new_id) # The 0023 schema survives the additive upgrade. url = _column(db, "url") assert url is not None and url[0] == "text" and url[1] == "NO", ( "git_sources.url (0006) must keep its 0023 contract" ) kind = _column(db, "kind") assert kind is not None and kind[0] == "text" and kind[1] == "NO", ( "git_sources.kind (0007) must survive the upgrade" ) ignore = _column(db, "ignore_paths") assert ignore is not None and ignore[0] == "jsonb" and ignore[1] == "NO", ( "git_sources.ignore_paths (0013) must survive the upgrade" ) hidden = _column(db, "include_hidden") assert hidden is not None and hidden[0] == "boolean" and hidden[1] == "NO", ( "git_sources.include_hidden (0019) must survive the upgrade" ) token = _column(db, "token") assert token is not None and token[0] == "text" and token[1] == "YES", ( "git_sources.token (0021) must survive the upgrade" ) added = _column(db, "added_at") assert added is not None and added[0] == "timestamp with time zone", ( "git_sources.added_at (0006) must survive the upgrade" ) assert added[1] == "NO" and "now()" in str(added[2]), ( "git_sources.added_at must keep its `now()` server default" ) index = db.execute( text( "SELECT indexname FROM pg_indexes" " WHERE tablename = 'git_sources'" " AND indexname = 'uq_git_sources_url'" ) ).scalar() assert index is not None, ( "the uq_git_sources_url unique index must survive the upgrade" ) finally: _delete(db, pre_a) _delete(db, pre_b) def test_orm_branch_round_trips(db: Session, alembic: Config) -> None: """The ORM contract agrees with the column contract: a freshly inserted ``GitSource`` with an explicit ``branch`` round-trips the value through a FRESH session, and a row without one reads ``branch is None`` (the NULL default-branch state).""" command.upgrade(alembic, "head") row_branched = GitSource(url=URL_A, kind="git", branch=BRANCH) row_default = GitSource(url=URL_B, kind="git") db.add(row_branched) db.add(row_default) db.commit() try: with SessionLocal() as fresh: reloaded_branched = fresh.get(GitSource, row_branched.id) assert reloaded_branched is not None, "the branched row must be readable" assert reloaded_branched.branch == BRANCH, ( "the explicit branch must round-trip through the DB" ) reloaded_default = fresh.get(GitSource, row_default.id) assert reloaded_default is not None, "the default row must be readable" assert reloaded_default.branch is None, ( "a row without a branch must read branch is None" ) finally: _delete(db, row_branched.id) _delete(db, row_default.id) def test_downgrade_to_0023_drops_the_column(db: Session, alembic: Config) -> None: """Downgrade 0024 → 0023: the branch column is gone (A13 — fully reversible) while the row + its ``url`` (and ``token``) survive, and the rest of the 0023 table contract (``url`` unique index, ``kind``, ``added_at``) is intact.""" command.upgrade(alembic, "head") row = GitSource(url=URL_A, kind="git", branch=BRANCH, token="ghp_mig0024secret") db.add(row) db.commit() try: command.downgrade(alembic, "0023") assert _version(db) == "0023" assert _column(db, "branch") is None, "branch must be dropped" surviving = db.execute( text( "SELECT url, kind, ignore_paths, include_hidden, token, added_at" " FROM git_sources WHERE id = :id" ), {"id": row.id}, ).fetchone() assert surviving is not None and surviving[0] == URL_A, ( "the row must survive the column drop" ) assert surviving[1] == "git" and surviving[2] == [] and surviving[3] is False, ( "kind + ignore_paths + include_hidden must survive the drop" ) assert surviving[4] == "ghp_mig0024secret", "token must survive the drop" assert surviving[5] is not None, "added_at must survive the drop" index = db.execute( text( "SELECT indexname FROM pg_indexes" " WHERE tablename = 'git_sources'" " AND indexname = 'uq_git_sources_url'" ) ).scalar() assert index is not None, "the unique index must survive the downgrade" finally: _delete(db, row.id) # Repair: the fixture teardown re-upgrades to head. def test_upgrade_round_trip_restores_the_column(db: Session, alembic: Config) -> None: """Downgrade to 0023, then upgrade back to 0024: ``branch`` is back with the full contract (TEXT, NULLABLE, no server default), and a row seeded at 0023 reads ``branch is None`` (the byte-identical guarantee on the round-trip).""" command.downgrade(alembic, "0023") pre_id = _insert_sql(db, URL_B) try: command.upgrade(alembic, "0024") assert _version(db) == "0024", "round-trip upgrade must land at 0024" branch = _column(db, "branch") assert branch is not None, "git_sources.branch must be back" assert branch[0] == "text", "branch must be TEXT after the round-trip" assert branch[1] == "YES", "branch must be NULLABLE after the round-trip" assert branch[2] is None, ( "branch must still carry NO server default after the round-trip" ) assert db.execute( text("SELECT branch FROM git_sources WHERE id = :id"), {"id": pre_id}, ).scalar() is None, ( "a row seeded at 0023 must read branch NULL after the round-trip" ) finally: _delete(db, pre_id)