323 lines
12 KiB
Python
323 lines
12 KiB
Python
"""Integration: migration 0011 (doc_drafts) schema contract.
|
|
|
|
Drives the **real Alembic engine** against the live dev database
|
|
(``podman compose up -d db``), mirroring the house pattern of
|
|
``test_migration_0010.py`` (information_schema / pg_indexes assertions
|
|
on the state the migration must leave). The tests target revision
|
|
``0011`` explicitly so later migrations cannot break them:
|
|
|
|
* upgrade 0010 → 0011 → the ``doc_drafts`` table exists with the full
|
|
column contract (``id`` UUID PK; ``token`` UUID NOT NULL + the UNIQUE
|
|
index ``ix_doc_drafts_token`` — the URL credential; ``title`` /
|
|
``path`` / ``body`` TEXT NOT NULL; ``status`` TEXT NOT NULL default
|
|
'draft'; ``branch`` / ``commit_sha`` TEXT NULL; ``created_at`` /
|
|
``updated_at`` TIMESTAMPTZ NOT NULL default now());
|
|
* inserted rows round-trip: an omitted ``status`` defaults to 'draft'
|
|
with NULL ``branch`` / ``commit_sha`` (the pre-push state) and both
|
|
timestamps are stamped server-side; explicit push-state values
|
|
round-trip verbatim;
|
|
* two identical tokens are rejected by the unique index (the token is
|
|
a unique handle — the share-token precedent, phase 51);
|
|
* downgrade to 0010 → the table and index are gone (A13 — reversible),
|
|
the rest of the schema (e.g. ``saved_chats.share_token``) survives;
|
|
* upgrade back to 0011 → the table and the unique index 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 _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 _unique_token_index(db: Session) -> int:
|
|
"""1 iff ``ix_doc_drafts_token`` exists as a UNIQUE index."""
|
|
count: Any = db.execute(
|
|
text(
|
|
"SELECT count(*) FROM pg_indexes"
|
|
" WHERE tablename = 'doc_drafts'"
|
|
" AND indexname = 'ix_doc_drafts_token'"
|
|
),
|
|
).scalar()
|
|
assert count is not None, "pg_indexes count must be an int"
|
|
is_unique: Any = db.execute(
|
|
text(
|
|
"SELECT indisunique FROM pg_index"
|
|
" WHERE indexrelid = (SELECT oid FROM pg_class WHERE relname = 'ix_doc_drafts_token')"
|
|
),
|
|
).scalar()
|
|
return int(count) if is_unique else 0
|
|
|
|
|
|
def _insert(
|
|
db: Session,
|
|
*,
|
|
token: uuid.UUID | None = None,
|
|
status: str | None = None,
|
|
branch: str | None = None,
|
|
commit_sha: str | None = None,
|
|
) -> uuid.UUID:
|
|
"""Insert one doc_drafts row. ``status=None`` omits the column
|
|
(server-default path); a ``token`` is always supplied — the
|
|
migration carries no server default (the ORM/API supplies it)."""
|
|
cols = ["id", "token", "title", "path", "body"]
|
|
params: dict[str, Any] = {
|
|
"t": "Mig 0011",
|
|
"p": "docs/mig-0011.md",
|
|
"b": "# Phase 59 migration probe\n",
|
|
}
|
|
if token is not None:
|
|
params["tok"] = token
|
|
if status is not None:
|
|
cols.append("status")
|
|
params["s"] = status
|
|
if branch is not None:
|
|
cols.append("branch")
|
|
params["br"] = branch
|
|
if commit_sha is not None:
|
|
cols.append("commit_sha")
|
|
params["sha"] = commit_sha
|
|
sql = (
|
|
f"INSERT INTO doc_drafts ({', '.join(cols)}) VALUES ("
|
|
"gen_random_uuid(), :tok, :t, :p, :b"
|
|
+ (", :s" if status is not None else "")
|
|
+ (", :br" if branch is not None else "")
|
|
+ (", :sha" if commit_sha is not None else "")
|
|
+ ") RETURNING id"
|
|
)
|
|
draft_id: uuid.UUID = db.execute(text(sql), params).scalar_one()
|
|
db.commit()
|
|
return draft_id
|
|
|
|
|
|
def _delete(db: Session, draft_id: uuid.UUID) -> None:
|
|
db.execute(text("DELETE FROM doc_drafts WHERE id = :i"), {"i": draft_id})
|
|
db.commit()
|
|
|
|
|
|
def test_upgrade_to_0011_adds_doc_drafts(db: Session, alembic: Config) -> None:
|
|
"""Upgrade 0010 → 0011: the table + the unique token index exist
|
|
with the full column contract; the table is absent at 0010."""
|
|
command.downgrade(alembic, "0010") # start from the pre-0011 state
|
|
assert _version(db) == "0010"
|
|
assert not _table_exists(db, "doc_drafts"), "doc_drafts must be absent at 0010"
|
|
assert _unique_token_index(db) == 0, "the token index must be absent at 0010"
|
|
|
|
command.upgrade(alembic, "0011")
|
|
assert _version(db) == "0011", "alembic_version must be at 0011"
|
|
assert _table_exists(db, "doc_drafts"), "doc_drafts must exist at 0011"
|
|
|
|
id_col = _column(db, "doc_drafts", "id")
|
|
assert id_col is not None, "doc_drafts.id is missing"
|
|
assert id_col[0] == "uuid", "doc_drafts.id must be UUID"
|
|
assert id_col[1] == "NO", "doc_drafts.id must be NOT NULL (PK)"
|
|
|
|
token = _column(db, "doc_drafts", "token")
|
|
assert token is not None, "doc_drafts.token is missing"
|
|
assert token[0] == "uuid", "doc_drafts.token must be UUID"
|
|
assert token[1] == "NO", "doc_drafts.token must be NOT NULL (no un-drafted state)"
|
|
assert _unique_token_index(db) == 1, "the unique token index is missing"
|
|
|
|
for name in ("title", "path", "body"):
|
|
col = _column(db, "doc_drafts", name)
|
|
assert col is not None, f"doc_drafts.{name} is missing"
|
|
assert col[0] == "text", f"doc_drafts.{name} must be TEXT"
|
|
assert col[1] == "NO", f"doc_drafts.{name} must be NOT NULL"
|
|
|
|
status = _column(db, "doc_drafts", "status")
|
|
assert status is not None, "doc_drafts.status is missing"
|
|
assert status[0] == "text", "doc_drafts.status must be TEXT"
|
|
assert status[1] == "NO", "doc_drafts.status must be NOT NULL"
|
|
assert str(status[2]).startswith("'draft'"), (
|
|
"doc_drafts.status must have server default 'draft'"
|
|
)
|
|
|
|
for name in ("branch", "commit_sha"):
|
|
col = _column(db, "doc_drafts", name)
|
|
assert col is not None, f"doc_drafts.{name} is missing"
|
|
assert col[0] == "text", f"doc_drafts.{name} must be TEXT"
|
|
assert col[1] == "YES", f"doc_drafts.{name} must be NULL until pushed"
|
|
|
|
for name in ("created_at", "updated_at"):
|
|
col = _column(db, "doc_drafts", name)
|
|
assert col is not None, f"doc_drafts.{name} is missing"
|
|
assert col[0] == "timestamp with time zone", (
|
|
f"doc_drafts.{name} must be TIMESTAMPTZ"
|
|
)
|
|
assert col[1] == "NO", f"doc_drafts.{name} must be NOT NULL"
|
|
assert str(col[2]).startswith("now("), (
|
|
f"doc_drafts.{name} must have server default now()"
|
|
)
|
|
|
|
|
|
def test_inserted_rows_round_trip_the_pre_push_and_pushed_states(
|
|
db: Session, alembic: Config
|
|
) -> None:
|
|
"""At 0011, an omitted status defaults to 'draft' with NULL
|
|
branch/commit_sha (the pre-push state) and both timestamps are
|
|
stamped server-side; explicit push-state values round-trip
|
|
verbatim."""
|
|
command.upgrade(alembic, "head")
|
|
draft_token = uuid.uuid4()
|
|
draft_id = _insert(db, token=draft_token)
|
|
pushed_token = uuid.uuid4()
|
|
pushed_id = _insert(
|
|
db,
|
|
token=pushed_token,
|
|
status="pushed",
|
|
branch="bor-docs",
|
|
commit_sha="a" * 40,
|
|
)
|
|
try:
|
|
row = db.execute(
|
|
text(
|
|
"SELECT token, status, branch, commit_sha, created_at, updated_at"
|
|
" FROM doc_drafts WHERE id = :i"
|
|
),
|
|
{"i": draft_id},
|
|
).fetchone()
|
|
assert row is not None, "the draft row must exist"
|
|
assert row[0] == draft_token, "the token must round-trip verbatim"
|
|
assert row[1] == "draft", "an omitted status must default to 'draft'"
|
|
assert row[2] is None and row[3] is None, (
|
|
"branch/commit_sha must be NULL before the push endpoint runs"
|
|
)
|
|
assert row[4] is not None and row[5] is not None, (
|
|
"created_at/updated_at must be stamped server-side"
|
|
)
|
|
|
|
pushed = db.execute(
|
|
text(
|
|
"SELECT status, branch, commit_sha FROM doc_drafts WHERE id = :i"
|
|
),
|
|
{"i": pushed_id},
|
|
).fetchone()
|
|
assert pushed is not None, "the pushed row must exist"
|
|
assert tuple(pushed) == ("pushed", "bor-docs", "a" * 40), (
|
|
"explicit push-state values must round-trip verbatim"
|
|
)
|
|
finally:
|
|
_delete(db, draft_id)
|
|
_delete(db, pushed_id)
|
|
|
|
|
|
def test_unique_index_rejects_duplicate_tokens(db: Session, alembic: Config) -> None:
|
|
"""Two identical tokens are rejected by the unique index — the
|
|
token is the unique URL credential (the share-token precedent,
|
|
phase 51); a distinct token still lands."""
|
|
command.upgrade(alembic, "head")
|
|
dup_token = uuid.uuid4()
|
|
first_id = _insert(db, token=dup_token)
|
|
other_id: uuid.UUID | None = None
|
|
try:
|
|
try:
|
|
_insert(db, token=dup_token)
|
|
except IntegrityError:
|
|
db.rollback() # the aborted transaction must not leak
|
|
else:
|
|
pytest.fail("a duplicate doc_drafts.token must be rejected")
|
|
|
|
# A different token is fine — only the exact duplicate is unique.
|
|
other_id = _insert(db, token=uuid.uuid4())
|
|
finally:
|
|
_delete(db, first_id)
|
|
if other_id is not None:
|
|
_delete(db, other_id)
|
|
|
|
|
|
def test_downgrade_to_0010_drops_the_table(db: Session, alembic: Config) -> None:
|
|
"""Downgrade to 0010: the table and the unique index are gone
|
|
(A13 — reversible) while the rest of the schema survives."""
|
|
command.downgrade(alembic, "0010")
|
|
assert _version(db) == "0010"
|
|
assert not _table_exists(db, "doc_drafts"), "doc_drafts must be dropped"
|
|
assert _unique_token_index(db) == 0, "the token index must be dropped"
|
|
|
|
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"
|
|
)
|
|
meta = _column(db, "sources_meta", "version")
|
|
assert meta is not None and meta[0] == "integer", (
|
|
"sources_meta.version must survive the downgrade"
|
|
)
|
|
|
|
|
|
def test_upgrade_round_trip_restores_the_table(db: Session, alembic: Config) -> None:
|
|
"""Downgrade to 0010, then upgrade back to 0011: the table and the
|
|
unique index are back."""
|
|
command.downgrade(alembic, "0010")
|
|
command.upgrade(alembic, "0011")
|
|
assert _version(db) == "0011", "round-trip upgrade must land at 0011"
|
|
|
|
assert _table_exists(db, "doc_drafts"), "doc_drafts must be back"
|
|
assert _unique_token_index(db) == 1, "the unique token index must be back"
|
|
|
|
status = _column(db, "doc_drafts", "status")
|
|
assert status is not None and status[1] == "NO", (
|
|
"status must be TEXT NOT NULL after the round-trip"
|
|
)
|
|
assert str(status[2]).startswith("'draft'"), (
|
|
"status must default to 'draft' after the round-trip"
|
|
)
|