231 lines
8.4 KiB
Python
231 lines
8.4 KiB
Python
"""Integration: migration 0009 (saved_chats.share_token) schema contract.
|
|
|
|
Drives the **real Alembic engine** against the live dev database
|
|
(``podman compose up -d db``), mirroring the house pattern of
|
|
``test_migration_0008.py`` (information_schema / pg_indexes assertions
|
|
on the state the migration must leave). The tests target revision
|
|
``0009`` explicitly so later migrations cannot break them:
|
|
|
|
* upgrade 0008 → 0009 → ``saved_chats.share_token`` exists as
|
|
``UUID`` **NULLable** (NULL = not shared) and the UNIQUE index
|
|
``ix_saved_chats_share_token`` exists; pre-0009 rows come back
|
|
unshared (NULL);
|
|
* the NULLs-distinct behavior (the phase-38 ``git_sources.path``
|
|
precedent): two rows may both carry NULL, while two identical
|
|
non-NULL tokens are rejected by the unique index;
|
|
* downgrade to 0008 → the column and the index are gone (A13 —
|
|
reversible), the rest of the table survives;
|
|
* upgrade back to 0009 → both are 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.exc import IntegrityError
|
|
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 _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 _unique_token_index(db: Session) -> int:
|
|
"""1 iff ``ix_saved_chats_share_token`` exists as a UNIQUE index."""
|
|
count: Any = db.execute(
|
|
text(
|
|
"SELECT count(*) FROM pg_indexes"
|
|
" WHERE tablename = 'saved_chats'"
|
|
" AND indexname = 'ix_saved_chats_share_token'"
|
|
" AND indexdef ILIKE 'CREATE UNIQUE%'"
|
|
)
|
|
).scalar()
|
|
assert count is not None, "pg_indexes count must be an int"
|
|
return int(count)
|
|
|
|
|
|
def _insert(db: Session, token: uuid.UUID | None) -> uuid.UUID:
|
|
"""Insert one saved_chats row with an explicit ``share_token``."""
|
|
chat_id: uuid.UUID = db.execute(
|
|
text(
|
|
"INSERT INTO saved_chats (id, title, messages, share_token)"
|
|
" VALUES (gen_random_uuid(), :t, CAST(:m AS jsonb), :tok)"
|
|
" RETURNING id"
|
|
),
|
|
{
|
|
"t": "Mig 0009",
|
|
"m": '[{"who": "user", "text": "How did I install gitlab?"}]',
|
|
"tok": token,
|
|
},
|
|
).scalar_one()
|
|
db.commit()
|
|
return chat_id
|
|
|
|
|
|
def _legacy_insert(db: Session) -> uuid.UUID:
|
|
"""Insert one row WITHOUT the ``share_token`` column — the only
|
|
possible shape at revision 0008 (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 0009",
|
|
"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_0009_adds_share_token(db: Session, alembic: Config) -> None:
|
|
"""Upgrade 0008 → 0009: the column is UUID + NULLable, the unique
|
|
index exists, and a pre-0009 row comes back unshared (NULL)."""
|
|
command.downgrade(alembic, "0008") # start from the pre-0009 state
|
|
assert _version(db) == "0008"
|
|
assert _column(db, "share_token") is None, "share_token must be absent at 0008"
|
|
assert _unique_token_index(db) == 0, "the index must be absent at 0008"
|
|
|
|
# A pre-0009 row (no share_token in the INSERT — the column does
|
|
# not exist at 0008): its data must survive the additive migration.
|
|
legacy = _legacy_insert(db)
|
|
try:
|
|
command.upgrade(alembic, "0009")
|
|
assert _version(db) == "0009", "alembic_version must be at 0009"
|
|
|
|
col = _column(db, "share_token")
|
|
assert col is not None, "saved_chats.share_token is missing"
|
|
assert col[0] == "uuid", "share_token must be UUID"
|
|
assert col[1] == "YES", "share_token must be NULLable (NULL = not shared)"
|
|
assert _unique_token_index(db) == 1, "the unique token index is missing"
|
|
|
|
token = db.execute(
|
|
text("SELECT share_token FROM saved_chats WHERE id = :i"), {"i": legacy}
|
|
).scalar_one()
|
|
assert token is None, "a pre-0009 row must upgrade as unshared (NULL)"
|
|
finally:
|
|
_delete(db, legacy)
|
|
|
|
|
|
def test_unique_index_treats_nulls_as_distinct(db: Session, alembic: Config) -> None:
|
|
"""NULLs are distinct under the unique index (the phase-38
|
|
``git_sources.path`` precedent): any number of unshared chats
|
|
coexist."""
|
|
command.upgrade(alembic, "head")
|
|
a = _insert(db, None)
|
|
b = _insert(db, None)
|
|
try:
|
|
count = db.execute(
|
|
text(
|
|
"SELECT count(*) FROM saved_chats"
|
|
" WHERE id IN (:a, :b) AND share_token IS NULL"
|
|
),
|
|
{"a": a, "b": b},
|
|
).scalar_one()
|
|
assert count == 2, "two NULL tokens must coexist (NULLs are distinct)"
|
|
finally:
|
|
_delete(db, a)
|
|
_delete(db, b)
|
|
|
|
|
|
def test_unique_index_rejects_duplicate_tokens(db: Session, alembic: Config) -> None:
|
|
"""Two identical non-NULL tokens are rejected by the unique index —
|
|
the share link is a unique handle (and a distinct token still lands).
|
|
"""
|
|
command.upgrade(alembic, "head")
|
|
token = uuid.uuid4()
|
|
a = _insert(db, token)
|
|
b: uuid.UUID | None = None
|
|
try:
|
|
try:
|
|
_insert(db, token)
|
|
except IntegrityError:
|
|
db.rollback() # the aborted transaction must not leak
|
|
else:
|
|
pytest.fail("a duplicate non-NULL share_token must be rejected")
|
|
|
|
# A different token is fine — only the exact duplicate is unique.
|
|
b = _insert(db, uuid.uuid4())
|
|
finally:
|
|
_delete(db, a)
|
|
if b is not None:
|
|
_delete(db, b)
|
|
|
|
|
|
def test_downgrade_to_0008_drops_share_token(db: Session, alembic: Config) -> None:
|
|
"""Downgrade to 0008: the column and the index are gone (A13 —
|
|
reversible) while the rest of the table survives."""
|
|
command.downgrade(alembic, "0008")
|
|
assert _version(db) == "0008"
|
|
assert _column(db, "share_token") is None, "share_token must be dropped"
|
|
assert _unique_token_index(db) == 0, "the unique index must be dropped"
|
|
|
|
id_col = _column(db, "id")
|
|
assert id_col is not None and id_col[0] == "uuid", (
|
|
"saved_chats.id must survive the downgrade"
|
|
)
|
|
|
|
|
|
def test_upgrade_round_trip_restores_share_token(db: Session, alembic: Config) -> None:
|
|
"""Downgrade to 0008, then upgrade back to 0009: the column and the
|
|
unique index are back."""
|
|
command.downgrade(alembic, "0008")
|
|
command.upgrade(alembic, "0009")
|
|
assert _version(db) == "0009", "round-trip upgrade must land at 0009"
|
|
|
|
col = _column(db, "share_token")
|
|
assert col is not None, "share_token must be back after the round-trip"
|
|
assert col[0] == "uuid" and col[1] == "YES", (
|
|
"share_token must be UUID + NULLable after the round-trip"
|
|
)
|
|
assert _unique_token_index(db) == 1, "the unique index must be back"
|