"""Integration: migration 0010 (sources_meta + saved_chats.sources_version). Drives the **real Alembic engine** against the live dev database (``podman compose up -d db``), mirroring the house pattern of ``test_migration_0009.py`` (information_schema / pg catalog assertions on the state the migration must leave). The tests target revision ``0010`` explicitly so later migrations cannot break them: * upgrade 0009 → 0010 → the single-row ``sources_meta`` table exists (``id`` Integer PK default 1, ``version`` Integer NOT NULL default 0, ``updated_at`` TIMESTAMPTZ NOT NULL default now()) with its **seed row** (id 1, version 0), and ``saved_chats.sources_version`` is Integer NOT NULL default 0 — a pre-0010 row comes back stamped 0 (the pre-counter KB, phase-53 locked decision 2); * inserted rows round-trip the stamp (default and explicit); * downgrade to 0009 → column + table gone (A13 — reversible), the rest of ``saved_chats`` survives; * upgrade back to 0010 → table, seed row, and column are all 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 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 _version(db: Session) -> str | None: return db.execute(text("SELECT version_num FROM alembic_version")).scalar() def _table_exists(db: Session, table: str) -> bool: count: Any = db.execute( text( "SELECT count(*) FROM information_schema.tables" " WHERE table_schema = 'public' AND table_name = :t" ), {"t": table}, ).scalar() assert count is not None, "information_schema count must be an int" return int(count) == 1 def _column(db: Session, table: str, column: str) -> tuple[Any, ...] | None: """(data_type, is_nullable, column_default) for one table column.""" row = db.execute( text( "SELECT data_type, is_nullable, column_default" " FROM information_schema.columns" " WHERE table_name = :t AND column_name = :c" ), {"t": table, "c": column}, ).fetchone() return tuple(row) if row is not None else None def _seed_row(db: Session) -> tuple[int, int] | None: """The (id, version) of the ``sources_meta`` row with id 1.""" row = db.execute( text("SELECT id, version FROM sources_meta WHERE id = 1") ).fetchone() return tuple(row) if row is not None else None def _insert(db: Session, version: int | None) -> uuid.UUID: """Insert one saved_chats row, optionally with an explicit stamp.""" if version is None: sql = ( "INSERT INTO saved_chats (id, title, messages)" " VALUES (gen_random_uuid(), :t, CAST(:m AS jsonb))" " RETURNING id" ) params: dict[str, Any] = {} else: sql = ( "INSERT INTO saved_chats (id, title, messages, sources_version)" " VALUES (gen_random_uuid(), :t, CAST(:m AS jsonb), :v)" " RETURNING id" ) params = {"v": version} params.update( {"t": "Mig 0010", "m": '[{"who": "user", "text": "How did I install gitlab?"}]'} ) chat_id: uuid.UUID = db.execute(text(sql), params).scalar_one() db.commit() return chat_id def _legacy_insert(db: Session) -> uuid.UUID: """Insert one row WITHOUT the ``sources_version`` column — the only possible shape at revision 0009 (the column does not exist yet).""" chat_id: uuid.UUID = db.execute( text( "INSERT INTO saved_chats (id, title, messages)" " VALUES (gen_random_uuid(), :t, CAST(:m AS jsonb))" " RETURNING id" ), { "t": "Mig 0010", "m": '[{"who": "user", "text": "How did I install gitlab?"}]', }, ).scalar_one() db.commit() return chat_id def _delete(db: Session, chat_id: uuid.UUID) -> None: db.execute(text("DELETE FROM saved_chats WHERE id = :i"), {"i": chat_id}) db.commit() def test_upgrade_to_0010_adds_sources_meta_and_stamp( db: Session, alembic: Config ) -> None: """Upgrade 0009 → 0010: the seeded counter table and the NOT NULL stamp column exist; a pre-0010 row comes back stamped 0 (the pre-counter KB).""" command.downgrade(alembic, "0009") # start from the pre-0010 state assert _version(db) == "0009" assert not _table_exists(db, "sources_meta"), "sources_meta must be absent at 0009" assert _column(db, "saved_chats", "sources_version") is None, ( "sources_version must be absent at 0009" ) # A pre-0010 row (no sources_version in the INSERT — the column does # not exist at 0009): its data must survive the additive migration. legacy = _legacy_insert(db) try: command.upgrade(alembic, "0010") assert _version(db) == "0010", "alembic_version must be at 0010" id_col = _column(db, "sources_meta", "id") assert id_col is not None, "sources_meta.id is missing" assert id_col[0] == "integer", "sources_meta.id must be INTEGER" assert id_col[1] == "NO", "sources_meta.id must be NOT NULL (PK)" assert id_col[2] == "1", "sources_meta.id must default to 1" ver_col = _column(db, "sources_meta", "version") assert ver_col is not None, "sources_meta.version is missing" assert ver_col[0] == "integer", "sources_meta.version must be INTEGER" assert ver_col[1] == "NO", "sources_meta.version must be NOT NULL" assert ver_col[2] == "0", "sources_meta.version must default to 0" updated = _column(db, "sources_meta", "updated_at") assert updated is not None, "sources_meta.updated_at is missing" assert updated[0] == "timestamp with time zone", ( "sources_meta.updated_at must be TIMESTAMPTZ" ) assert updated[1] == "NO", "sources_meta.updated_at must be NOT NULL" assert str(updated[2]).startswith("now("), ( "sources_meta.updated_at must have server default now()" ) assert _seed_row(db) == (1, 0), "the seed row (id 1, version 0) is missing" stamp = _column(db, "saved_chats", "sources_version") assert stamp is not None, "saved_chats.sources_version is missing" assert stamp[0] == "integer", "sources_version must be INTEGER" assert stamp[1] == "NO", "sources_version must be NOT NULL" assert stamp[2] == "0", "sources_version must default to 0" row = db.execute( text("SELECT title, sources_version FROM saved_chats WHERE id = :i"), {"i": legacy}, ).fetchone() assert row is not None, "the pre-0010 row must survive the upgrade" assert row[1] == 0, "a pre-0010 row must upgrade stamped 0 (pre-counter KB)" finally: _delete(db, legacy) def test_inserted_rows_round_trip_the_stamp(db: Session, alembic: Config) -> None: """At 0010, an omitted stamp defaults to 0 and an explicit stamp round-trips verbatim.""" command.upgrade(alembic, "head") default_id = _insert(db, None) explicit_id = _insert(db, 7) try: rows = db.execute( text("SELECT sources_version FROM saved_chats WHERE id IN (:a, :b)"), {"a": default_id, "b": explicit_id}, ).all() stamps = {row[0] for row in rows} assert stamps == {0, 7}, "default stamp 0 and explicit stamp 7 must round-trip" default_stamp = db.execute( text("SELECT sources_version FROM saved_chats WHERE id = :i"), {"i": default_id}, ).scalar_one() assert default_stamp == 0, "an omitted stamp must default to 0" finally: _delete(db, default_id) _delete(db, explicit_id) def test_downgrade_to_0009_drops_both(db: Session, alembic: Config) -> None: """Downgrade to 0009: the stamp column and the counter table are gone (A13 — reversible) while the rest of ``saved_chats`` survives.""" command.downgrade(alembic, "0009") assert _version(db) == "0009" assert _column(db, "saved_chats", "sources_version") is None, ( "sources_version must be dropped" ) assert not _table_exists(db, "sources_meta"), "sources_meta must be dropped" id_col = _column(db, "saved_chats", "id") assert id_col is not None and id_col[0] == "uuid", ( "saved_chats.id must survive the downgrade" ) token_col = _column(db, "saved_chats", "share_token") assert token_col is not None and token_col[0] == "uuid", ( "saved_chats.share_token must survive the downgrade" ) def test_upgrade_round_trip_restores_both(db: Session, alembic: Config) -> None: """Downgrade to 0009, then upgrade back to 0010: the counter table (with a fresh seed row) and the stamp column are back.""" command.downgrade(alembic, "0009") command.upgrade(alembic, "0010") assert _version(db) == "0010", "round-trip upgrade must land at 0010" assert _table_exists(db, "sources_meta"), "sources_meta must be back" assert _seed_row(db) == (1, 0), "the seed row must be re-seeded on upgrade" stamp = _column(db, "saved_chats", "sources_version") assert stamp is not None, "sources_version must be back after the round-trip" assert stamp[0] == "integer" and stamp[1] == "NO", ( "sources_version must be INTEGER NOT NULL after the round-trip" ) assert stamp[2] == "0", "sources_version must default to 0 after the round-trip"