261 lines
10 KiB
Python
261 lines
10 KiB
Python
"""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"
|