feat(chat): share a chat by link — anonymous read-only /shared/<token> page, share/unshare
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user