**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`.
345 lines
14 KiB
Python
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"
|