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

256 lines
9.8 KiB
Python

"""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"
)