feat(docs): save chat answers as docs — edit screen, commit + push to the .env docs branch
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
"""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"
|
||||
)
|
||||
Reference in New Issue
Block a user