"""Integration: migration 0008 (saved_chats) schema contract. Drives the **real Alembic engine** against the live dev database (``podman compose up -d db``), mirroring the house pattern of ``test_migration_0005.py`` / ``test_migration_0007.py`` (information_schema assertions on the state the migration must leave). The tests target revision ``0008`` explicitly so later migrations cannot break them: * upgrade 0007 → 0008 → a ``saved_chats`` table exists with ``id UUID`` PK, ``title VARCHAR(500) NOT NULL``, ``messages JSONB NOT NULL`` (the ``bor.chat.v1`` record list), and ``created_at`` / ``updated_at TIMESTAMPTZ NOT NULL`` — both stamped server-side by ``now()`` (an insert that omits them still lands with both set); * the ``updated_at`` ORM ``onupdate`` bumps the timestamp on a row update while ``created_at`` stays put (the phase-50 History page orders by it); * downgrade to 0007 → the table is gone; * upgrade back to 0008 → 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 import json import time import uuid from collections.abc import Iterator from datetime import datetime 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 from app.models import SavedChat TITLE_BASE = "Mig 0008" MESSAGE_SHAPE = [{"who": "user", "text": "How did I install gitlab?"}] @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 _table_exists(db: Session) -> bool: """1 iff ``saved_chats`` is a table in this database.""" count: Any = db.execute( text( "SELECT count(*) FROM information_schema.tables" " WHERE table_name = 'saved_chats'" ) ).scalar() assert count is not None, "information_schema count must be an int" return int(count) == 1 def _column(db: Session, column: str) -> tuple[Any, ...] | None: """(data_type, is_nullable, column_default) for one saved_chats column.""" row = db.execute( text( "SELECT data_type, is_nullable, column_default" " FROM information_schema.columns" " WHERE table_name = 'saved_chats' AND column_name = :c" ), {"c": column}, ).fetchone() return tuple(row) if row is not None else None def _pk_columns(db: Session) -> set[str]: """Primary-key columns of ``saved_chats`` (empty if it does not exist).""" rows = db.execute( text( "SELECT kcu.column_name" " FROM information_schema.table_constraints tc" " JOIN information_schema.key_column_usage kcu" " ON tc.constraint_name = kcu.constraint_name" " AND tc.table_schema = kcu.table_schema" " WHERE tc.table_name = 'saved_chats'" " AND tc.constraint_type = 'PRIMARY KEY'" ) ).fetchall() return {r[0] for r in rows} def _version(db: Session) -> str | None: return db.execute(text("SELECT version_num FROM alembic_version")).scalar() def _raw_insert(db: Session, title: str) -> uuid.UUID: """Insert one saved_chats row omitting the timestamps (server-stamped).""" 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": title, "m": json.dumps(MESSAGE_SHAPE)}, ).scalar_one() db.commit() return id def _delete(db: Session, id: uuid.UUID) -> None: db.execute(text("DELETE FROM saved_chats WHERE id = :i"), {"i": id}) db.commit() def test_upgrade_to_0008_creates_saved_chats(db: Session, alembic: Config) -> None: """Upgrade 0007 → 0008: ``saved_chats`` exists with the locked columns, types, nullability, PK, and server ``now()`` defaults.""" command.downgrade(alembic, "0007") # start from the pre-0008 state assert _version(db) == "0007" assert not _table_exists(db), "saved_chats must not exist before 0008" command.upgrade(alembic, "0008") assert _version(db) == "0008", "alembic_version must be at 0008" assert _table_exists(db), "saved_chats is missing after 0008" assert _pk_columns(db) == {"id"}, "saved_chats must have a single id PK" id_col = _column(db, "id") assert id_col is not None, "saved_chats.id is missing" assert id_col[0] == "uuid", "saved_chats.id must be UUID" assert id_col[1] == "NO", "saved_chats.id must be NOT NULL" title = _column(db, "title") assert title is not None, "saved_chats.title is missing" assert title[0] == "character varying", "saved_chats.title must be VARCHAR" assert title[1] == "NO", "saved_chats.title must be NOT NULL" messages = _column(db, "messages") assert messages is not None, "saved_chats.messages is missing" assert messages[0] == "jsonb", "saved_chats.messages must be JSONB" assert messages[1] == "NO", "saved_chats.messages must be NOT NULL" for column in ("created_at", "updated_at"): col = _column(db, column) assert col is not None, f"saved_chats.{column} is missing" assert col[0] == "timestamp with time zone", ( f"saved_chats.{column} must be TIMESTAMPTZ" ) assert col[1] == "NO", f"saved_chats.{column} must be NOT NULL" assert col[2] is not None and "now()" in col[2], ( f"saved_chats.{column} must default to now()" ) # Phase 51 (share_token) must not leak into this minimal migration. assert _column(db, "share_token") is None, ( "0008 stays minimal — share_token lands in 0009 (phase 51)" ) def test_server_timestamps_stamped_on_insert(db: Session, alembic: Config) -> None: """An insert that omits created_at/updated_at (the API's shape) still lands with both stamped by the server defaults.""" command.upgrade(alembic, "head") chat_id = _raw_insert(db, f"{TITLE_BASE}: server stamps") try: created_at, updated_at = db.execute( text("SELECT created_at, updated_at FROM saved_chats WHERE id = :i"), {"i": chat_id}, ).one() assert isinstance(created_at, datetime), "created_at must be server-stamped" assert isinstance(updated_at, datetime), "updated_at must be server-stamped" assert created_at.tzinfo is not None, "created_at must be timezone-aware" # Fresh row: nothing has updated it, so both stamps agree (now). assert (created_at - updated_at).total_seconds() < 5, ( "a fresh row must have created_at ≈ updated_at" ) finally: _delete(db, chat_id) def test_updated_at_bumps_on_row_update(db: Session, alembic: Config) -> None: """The ORM ``onupdate=func.now()`` (the History page's Updated column) bumps ``updated_at`` on a row update while ``created_at`` stays put.""" command.upgrade(alembic, "head") chat = SavedChat(title=f"{TITLE_BASE}: before update", messages=MESSAGE_SHAPE) db.add(chat) db.commit() try: created_before: datetime = chat.created_at updated_before: datetime = chat.updated_at assert created_before is not None and updated_before is not None time.sleep(0.1) # now() has µs resolution — make the bump observable chat.title = f"{TITLE_BASE}: after update" chat.messages = [ {"who": "user", "text": "How did I install gitlab?"}, {"who": "brain", "text": "You've got this!", "sources": []}, ] db.commit() db.expire(chat) created_after: datetime = chat.created_at updated_after: datetime = chat.updated_at assert created_after == created_before, "created_at must not move on update" assert updated_after > updated_before, ( "updated_at must bump on a row update (onupdate=func.now())" ) finally: db.expire_all() db.execute(text("DELETE FROM saved_chats WHERE id = :i"), {"i": chat.id}) db.commit() def test_downgrade_to_0007_drops_table(db: Session, alembic: Config) -> None: """Downgrade to 0007: ``saved_chats`` is dropped (A13 — reversible).""" command.downgrade(alembic, "0007") assert _version(db) == "0007" assert not _table_exists(db), "saved_chats must be dropped by the downgrade" assert _column(db, "id") is None, "saved_chats.id must be gone" def test_upgrade_round_trip_restores_table(db: Session, alembic: Config) -> None: """Downgrade to 0007, then upgrade back to 0008: the table is back with its locked columns and PK.""" command.downgrade(alembic, "0007") command.upgrade(alembic, "0008") assert _version(db) == "0008", "round-trip upgrade must land at 0008" assert _table_exists(db), "saved_chats must be back after the round-trip" assert _pk_columns(db) == {"id"}, "saved_chats.id PK must be back" messages = _column(db, "messages") assert messages is not None and messages[0] == "jsonb", ( "saved_chats.messages must be JSONB after the round-trip" ) col = _column(db, "updated_at") assert col is not None and col[2] is not None and "now()" in col[2], ( "saved_chats.updated_at must keep its now() default after the round-trip" )