Files
brain-of-reese/tests/integration/test_migration_0022.py
T
ducoterra a19d78d284
Build and Push Containers / build-and-push-app (push) Successful in 1m57s
Build and Push Containers / build-and-push-db (push) Failing after 13s
phase: 122_image_documents
**Phase 122 (image documents) — final verification pass: all green. No code changes were needed; defects found: none.**

**Verified (implementation already complete in working tree, reviewed end-to-end):**
- Toggle (`BOR_IMAGES`/`BOR_IMAGE_EXTENSIONS`/`BOR_IMAGE_DIR`, off by default) + `GET /api/config` `images` flag
- Ingest: bytes digest, `image_dir` persistent copy, `content = summary = vision description` (chat-model call; only text embedded), fail-soft skip + `images_failed` counter
- Serve/display: `/api/documents/{id}/image` route (404 matrix), viewer `<img>` + description, Sources 48px lazy thumbnails, chat inline source figure (alt = summary), agent `read` marker
- Prune guard: images-off syncs never prune `is_image` docs

**Test / lint / coverage (exact commands & outcomes):**
- `uv run pytest` → exit 0 (green; note: pytest 9.1.1 `-q` omits the final count line in output — exit code authoritative)
- `uv run pytest --cov=app --cov-report=term-missing` → **2715 passed, exit 0, TOTAL 99%** (>90% gate)
- `uv run ruff check . && uv run pyright` → "All checks passed!" / "0 errors, 0 warnings, 0 informations"
- `uv run pytest tests/e2e/test_image_documents.py -v --no-cov` → **4 passed, exit 0** (isolation)

**Completion criteria:** (1) images=true → described/embedded/displayed docs: ✅ (E2E + integration) · (2) images=false byte-identical + image docs survive sync: ✅ (E2E negative app + unit/integration) · (3) viewer + chat rendering with alt text; failed description skips + logs, sync completes: ✅ · (4) test/lint/coverage gates: ✅ · (5) commit + phase move: deferred to harness per this pass's rules (working tree left uncommitted).

**Notable deviation (pre-existing, documented in code):** image route uses `require_user` (phase-79 posture, same gate as the document content endpoint) rather than the phase text's "public" parenthetical — matches the endpoint it mirrors.

**Next pending phase:** `123_chat_image_questions`.
2026-09-25 01:54:23 -04:00

345 lines
14 KiB
Python

"""Integration: migration 0022 (documents.is_image + image_path) schema
contract (phase 122, task 02).
Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the house pattern of
``test_migration_0021.py`` (information_schema assertions on the state
the migration must leave). The tests target the 0021 → 0022 step
explicitly so later migrations cannot break the pins:
* upgrade 0021 → 0022 → ``is_image`` exists with the full contract —
BOOLEAN, NOT NULL, server default ``false`` — and ``image_path`` —
TEXT, NULLABLE, no server default — while the 0021 ``documents``
schema (``content``/``content_hash`` NOT NULL, ``summary`` NULLABLE,
``created_at`` + ``created_at_manual``, the (source, path) unique
constraint — asserted column-based, since the suite's table
self-heal renames copied constraints) survives;
* a ``documents`` row inserted while the DB is at 0021 backfills
``is_image`` to ``false`` and ``image_path`` to NULL (every
pre-phase-122 row is a text doc — the LOCKED A3 default);
* the ORM contract agrees: a freshly inserted ``Document`` without the
image fields reads ``is_image is False`` / ``image_path is None``,
and one with them round-trips through a fresh session;
* downgrade to 0021 → both columns are GONE (A13) while the row
survives; upgrade back to 0022 → the columns 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.orm import Session
from alembic import command
from app.db import SessionLocal, db_available
from app.models import Document
SOURCE = "mig0022"
PATH_TEXT = "notes/readme.md"
PATH_IMAGE = "notes/diagram.png"
@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:
# Release the test session's open transaction BEFORE the repair
# DDL: an idle-in-transaction SELECT holds an ACCESS SHARE lock
# on ``documents``, which would deadlock the repair's
# ``ALTER TABLE`` (0022) forever.
db.rollback()
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 documents
column."""
row = db.execute(
text(
"SELECT data_type, is_nullable, column_default"
" FROM information_schema.columns"
" WHERE table_name = 'documents' AND column_name = :c"
),
{"c": column},
).fetchone()
return tuple(row) if row is not None else None
def _insert_sql(db: Session, path: str) -> uuid.UUID:
"""Insert one documents row with the PRE-0022 column set (the 0021
shape — the image columns, when present, are omitted so their
backfill is what the row reads)."""
row_id = uuid.uuid4()
db.execute(
text(
"INSERT INTO documents (id, source, path, full_path, title,"
" content, content_hash)"
" VALUES (:id, :s, :p, :f, :t, :c, :h)"
),
{
"id": row_id,
"s": SOURCE,
"p": path,
"f": f"/tmp/{SOURCE}/{path}",
"t": path.rsplit("/", 1)[-1],
"c": "content",
"h": "0" * 64,
},
)
db.commit()
return row_id
def _delete(db: Session, row_id: uuid.UUID) -> None:
db.execute(text("DELETE FROM documents WHERE id = :id"), {"id": row_id})
db.commit()
def _unique_column_sets(db: Session) -> set[tuple[str, ...]]:
"""The column tuples of every UNIQUE constraint on ``documents``.
Column-based (not name-based): the integration suite's table
self-heal (``tests/integration/conftest.py``) rewrites bloated
tables via ``CREATE TABLE (LIKE …)``, which renames copied
constraints (PG auto-names them) — the (source, path) uniqueness
contract is what must hold, not the original name.
"""
rows = db.execute(
text(
"SELECT (SELECT string_agg(a.attname, ',' ORDER BY k.ord)"
" FROM unnest(c.conkey) WITH ORDINALITY k(attnum, ord)"
" JOIN pg_attribute a"
" ON a.attrelid = c.conrelid AND a.attnum = k.attnum)"
" FROM pg_constraint c"
" WHERE c.contype = 'u' AND c.conrelid = 'documents'::regclass"
)
).fetchall()
return {tuple(r[0].split(",")) for r in rows}
def test_upgrade_to_0022_adds_image_columns(db: Session, alembic: Config) -> None:
"""Upgrade 0021 → 0022: ``is_image`` exists with the full contract
(BOOLEAN, NOT NULL, server default ``false`` — every pre-phase-122
row is a text doc) and ``image_path`` (TEXT, NULLABLE, no server
default — NULL for text docs), both ABSENT at 0021; a pre-0022 row
backfills ``is_image`` to ``false`` + ``image_path`` to NULL; and
the 0021 table contract survives the additive upgrade."""
command.downgrade(alembic, "0021") # start from the pre-0022 state
assert _version(db) == "0021"
assert _column(db, "is_image") is None, "is_image must be absent at 0021"
assert _column(db, "image_path") is None, "image_path must be absent at 0021"
pre_id = _insert_sql(db, PATH_TEXT) # the 0021 column set
try:
command.upgrade(alembic, "0022")
assert _version(db) == "0022", "alembic_version must be at 0022"
is_image = _column(db, "is_image")
assert is_image is not None, "documents.is_image is missing"
assert is_image[0] == "boolean", "is_image must be BOOLEAN"
assert is_image[1] == "NO", "is_image must be NOT NULL"
assert is_image[2] == "false", (
"is_image must carry the `false` server default — every"
" pre-phase-122 row is a text doc"
)
image_path = _column(db, "image_path")
assert image_path is not None, "documents.image_path is missing"
assert image_path[0] == "text", "image_path must be TEXT"
assert image_path[1] == "YES", "image_path must be NULLABLE"
assert image_path[2] is None, (
"image_path must carry NO server default — NULL is the"
" text-doc value"
)
# The pre-0022 row backfilled to (false, NULL) — a text doc.
row = db.execute(
text("SELECT is_image, image_path FROM documents WHERE id = :id"),
{"id": pre_id},
).fetchone()
assert row is not None, "the pre-0022 row must survive the upgrade"
assert row[0] is False, "the backfilled is_image must be false"
assert row[1] is None, "the backfilled image_path must be NULL"
# A row written without the image columns reads the same
# (the Python-side defaults are False/None — same values).
new_id = _insert_sql(db, PATH_IMAGE)
try:
backfilled = db.execute(
text("SELECT is_image, image_path FROM documents WHERE id = :id"),
{"id": new_id},
).fetchone()
assert backfilled == (False, None), (
"an omitted image state must read (false, NULL)"
)
finally:
_delete(db, new_id)
# The 0021 schema survives the additive upgrade.
content = _column(db, "content")
assert content is not None and content[0] == "text" and content[1] == "NO", (
"documents.content (0001) must keep its 0021 contract"
)
hash_col = _column(db, "content_hash")
assert (
hash_col is not None
and hash_col[0] == "character varying"
and hash_col[1] == "NO"
), "documents.content_hash (0001) must survive the upgrade"
summary = _column(db, "summary")
assert summary is not None and summary[0] == "text" and summary[1] == "YES", (
"documents.summary (phase 30) must survive the upgrade"
)
created = _column(db, "created_at")
assert created is not None and created[0] == "timestamp with time zone"
assert created[1] == "NO" and "now()" in str(created[2]), (
"documents.created_at (0020) must keep its `now()` server default"
)
manual = _column(db, "created_at_manual")
assert manual is not None and manual[0] == "boolean" and manual[1] == "NO"
assert manual[2] == "false", (
"documents.created_at_manual (0020) must keep its `false` default"
)
assert ("source", "path") in _unique_column_sets(db), (
"the (source, path) unique constraint must survive the upgrade"
)
finally:
_delete(db, pre_id)
def test_orm_image_fields_round_trip(db: Session, alembic: Config) -> None:
"""The ORM contract agrees with the column contract: a freshly
inserted ``Document`` WITHOUT the image fields reads ``is_image is
False`` / ``image_path is None`` (the text-doc default state), and
one WITH them round-trips the pair through a FRESH session."""
command.upgrade(alembic, "head")
text_doc = Document(
source=SOURCE,
path=PATH_TEXT,
full_path=f"/tmp/{SOURCE}/{PATH_TEXT}",
title="readme",
content="# readme\n",
content_hash="1" * 64,
)
image_doc = Document(
source=SOURCE,
path=PATH_IMAGE,
full_path=f"/tmp/{SOURCE}/{PATH_IMAGE}",
title="diagram",
content="A description of the diagram.",
content_hash="2" * 64,
is_image=True,
image_path="/srv/bor-images/diagram.png",
)
db.add(text_doc)
db.add(image_doc)
db.commit()
try:
with SessionLocal() as fresh:
reloaded_text = fresh.get(Document, text_doc.id)
assert reloaded_text is not None, "the text row must be readable"
assert reloaded_text.is_image is False, (
"an omitted is_image must read the False default"
)
assert reloaded_text.image_path is None, (
"an omitted image_path must read NULL"
)
reloaded_image = fresh.get(Document, image_doc.id)
assert reloaded_image is not None, "the image row must be readable"
assert reloaded_image.is_image is True
assert reloaded_image.image_path == "/srv/bor-images/diagram.png"
finally:
_delete(db, text_doc.id)
_delete(db, image_doc.id)
def test_downgrade_to_0021_drops_the_columns(db: Session, alembic: Config) -> None:
"""Downgrade 0022 → 0021: both image columns are gone (A13 — fully
reversible) while the row + its 0021 columns survive, and the rest
of the 0021 table contract (``content``, ``content_hash``,
``created_at``) is intact."""
command.upgrade(alembic, "head")
row = Document(
source=SOURCE,
path=PATH_IMAGE,
full_path=f"/tmp/{SOURCE}/{PATH_IMAGE}",
title="diagram",
content="A description of the diagram.",
content_hash="3" * 64,
is_image=True,
image_path="/srv/bor-images/diagram.png",
)
db.add(row)
db.commit()
try:
command.downgrade(alembic, "0021")
assert _version(db) == "0021"
assert _column(db, "is_image") is None, "is_image must be dropped"
assert _column(db, "image_path") is None, "image_path must be dropped"
surviving = db.execute(
text(
"SELECT source, path, title, content, content_hash, created_at"
" FROM documents WHERE id = :id"
),
{"id": row.id},
).fetchone()
assert surviving is not None, "the row must survive the column drops"
assert surviving[0] == SOURCE and surviving[1] == PATH_IMAGE
assert surviving[3] == "A description of the diagram."
assert surviving[4] == "3" * 64
assert surviving[5] is not None, "created_at must survive the drops"
assert ("source", "path") in _unique_column_sets(db), (
"the (source, path) unique constraint must survive the downgrade"
)
finally:
_delete(db, row.id)
# Repair: the fixture teardown re-upgrades to head.
def test_upgrade_round_trip_restores_the_columns(db: Session, alembic: Config) -> None:
"""Downgrade to 0021, then upgrade back to 0022: both columns are
back with the full contract (``is_image`` BOOLEAN NOT NULL default
``false``; ``image_path`` TEXT NULLABLE no default)."""
command.downgrade(alembic, "0021")
command.upgrade(alembic, "0022")
assert _version(db) == "0022", "round-trip upgrade must land at 0022"
is_image = _column(db, "is_image")
assert is_image is not None, "documents.is_image must be back"
assert is_image[0] == "boolean", "is_image must be BOOLEAN after the round-trip"
assert is_image[1] == "NO", "is_image must be NOT NULL after the round-trip"
assert is_image[2] == "false", (
"is_image must still carry the `false` server default"
)
image_path = _column(db, "image_path")
assert image_path is not None, "documents.image_path must be back"
assert image_path[0] == "text"
assert image_path[1] == "YES"
assert image_path[2] is None, "image_path must still carry NO server default"