**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`.
500 lines
21 KiB
Python
500 lines
21 KiB
Python
"""Phase 122 E2E (Playwright): standalone image documents — the user path.
|
||
|
||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||
|
||
uv run pytest tests/e2e/test_image_documents.py -v --no-cov
|
||
|
||
The suite's app instance runs with ``BOR_IMAGES=true`` (module env
|
||
override — the conftest per-suite-app pattern, leak guards included):
|
||
the standalone PNG arrives through the REAL admin upload flow (a zip
|
||
with a single image member — phase 90's no-scan contract: the upload
|
||
unpacks + registers, the RAG page's "Sync sources" button scans), the
|
||
mock's vision model (``IMAGE_DESCRIPTION_MODE`` branch —
|
||
``tests/e2e/mock_llm.py``) writes the description, and the document is
|
||
first-class at every surface:
|
||
|
||
* the Sources page lists it with the 48px thumbnail (the image bytes
|
||
route, alt = the summary);
|
||
* the document viewer renders the image with the description below it
|
||
(the summary panel is suppressed — the summary IS the description);
|
||
* a grounded chat question shows the compact inline figure in the
|
||
answer's sources block (image + the summary caption, the "shown in
|
||
the chat nicely" contract).
|
||
|
||
The negative case drives a SECOND module app with ``BOR_IMAGES``
|
||
forced ``false`` (the DEFAULT contract — an operator's local ``.env``
|
||
cannot leak the toggle in either direction: both apps pin the value
|
||
explicitly): the same upload + sync produces NO image document (the
|
||
walk is blind to the file, the source row stays a 0-document source).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import zipfile
|
||
from collections.abc import Iterator
|
||
from pathlib import Path
|
||
|
||
import httpx
|
||
import pytest
|
||
from playwright.sync_api import Browser, Page, expect
|
||
from sqlalchemy import select, text
|
||
|
||
from app.config import Settings
|
||
from app.db import SessionLocal
|
||
from app.models import Document
|
||
from app.rag.agent import IMAGE_DOC_MARKER
|
||
from e2e.auth_helpers import login
|
||
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
|
||
from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER
|
||
|
||
REPO = Path(__file__).resolve().parents[2]
|
||
GIT_SOURCES_URL = "/git-sources.html"
|
||
SOURCE_NAME = "e2e-image" # the archive stem (archive_source_name)
|
||
DOC_PATH = "pic.png"
|
||
|
||
#: The grounded question, carrying the house scripted-read call
|
||
#: (``SUMMARY_SEED_READ_TRIGGER`` — the phase-119 A1 convention: a
|
||
#: zero-read grounded turn chips NOTHING, so the image doc must be
|
||
#: READ to earn its citation chip + figure): the mock emits the
|
||
#: scripted ``read e2e-image/pic.png``, then echoes the tool result.
|
||
#: Grounding: the mock's token-overlap cosine against the
|
||
#: ``IMAGE_DESCRIPTION_ANSWER`` description is ≈0.31 (over the
|
||
#: mock-calibrated gate (0.30), and the FTS leg corroborates —
|
||
#: homelab/network/diagram all hit — the 0.15 floor backstop).
|
||
IMAGE_QUESTION = (
|
||
"Read the suggested document: read e2e-image/pic.png — "
|
||
"what is shown in the homelab network diagram?"
|
||
)
|
||
|
||
#: A real 1×1 transparent PNG (the unit/integration suites' fixture —
|
||
#: the pipeline is content-agnostic, the well-formed bytes keep the
|
||
#: upload + serve + render path honest).
|
||
PNG_1X1 = base64.b64decode(
|
||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
|
||
"AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
|
||
)
|
||
|
||
UPLOAD_TIMEOUT_MS = 30_000
|
||
SYNC_TIMEOUT_MS = 60_000
|
||
SYNCED_LABEL = re.compile(r"^Synced \d{1,2}:\d{2}$")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Module apps: images ON (the story) and images forced OFF (the default
|
||
# contract). Each owns its port + its scratch upload/source/image dirs.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _app_env(mock_llm: int, app_port: int, *, images: bool, scratch: Path) -> dict[str, str]:
|
||
"""The conftest app env (leak guards included) with the phase-122
|
||
knobs: ``BOR_IMAGES`` pinned EXPLICITLY (true for the story app,
|
||
false for the default app — process env ranks above an operator's
|
||
local gitignored ``.env``, so the contract under test cannot leak
|
||
in either direction) and the image/upload homes in the suite's
|
||
scratch dir (the app under test must not write image copies into
|
||
the owner's real ``~/bor-sources``)."""
|
||
env = dict(os.environ)
|
||
env.pop("DEBUGPY", None)
|
||
env["BOR_ENVIRONMENT"] = "e2e"
|
||
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||
env["BOR_LLM_BASE_URL"] = (
|
||
"https://aipi.reeseapps.com/v1"
|
||
if USE_REAL_LLM
|
||
else f"http://127.0.0.1:{mock_llm}/v1"
|
||
)
|
||
# Mock-calibrated gate (conftest pattern) — the story's question
|
||
# grounds on these values (see IMAGE_QUESTION).
|
||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
|
||
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
|
||
# Phase 67: instant retry waits + the code-default budget.
|
||
env["BOR_LLM_RETRY_DELAY"] = "0"
|
||
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
|
||
env.setdefault(
|
||
"BOR_DATABASE_URL",
|
||
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||
)
|
||
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||
# Leak guards (conftest pattern).
|
||
env["BOR_GIT_SOURCES"] = ""
|
||
env["BOR_DOCS_REPO"] = ""
|
||
env["BOR_SUGGESTIONS"] = json.dumps(
|
||
Settings.model_fields["suggestions"].default
|
||
)
|
||
env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default
|
||
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
|
||
# Phase 122: the toggle under test + the suite-private homes.
|
||
env["BOR_IMAGES"] = "true" if images else "false"
|
||
env["BOR_UPLOAD_DIR"] = str(scratch / "uploads")
|
||
env["BOR_SOURCES_DIR"] = str(scratch / "checkouts")
|
||
env["BOR_IMAGE_DIR"] = str(scratch / "images")
|
||
return env
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def app_server(mock_llm: int, tmp_path_factory: pytest.TempPathFactory) -> Iterator[str]:
|
||
"""The story app — ``BOR_IMAGES=true`` (the module env override;
|
||
the conftest session app is never started in this isolated run,
|
||
so no port clash)."""
|
||
scratch = tmp_path_factory.mktemp("bor_image_on")
|
||
port = int(os.environ.get("E2E_APP_PORT_IMAGES", "8150"))
|
||
proc = subprocess.Popen(
|
||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
|
||
cwd=REPO,
|
||
env=_app_env(mock_llm, port, images=True, scratch=scratch),
|
||
)
|
||
try:
|
||
_wait_http(f"http://127.0.0.1:{port}/api/health")
|
||
yield f"http://127.0.0.1:{port}"
|
||
finally:
|
||
proc.terminate()
|
||
try:
|
||
proc.wait(timeout=10)
|
||
except subprocess.TimeoutExpired:
|
||
proc.kill()
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def app_url(app_server: str) -> str:
|
||
return app_server
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def default_app_server(
|
||
mock_llm: int, tmp_path_factory: pytest.TempPathFactory
|
||
) -> Iterator[str]:
|
||
"""The default-contract app — ``BOR_IMAGES=false`` (the LOCKED A3
|
||
default; only the negative test starts it)."""
|
||
scratch = tmp_path_factory.mktemp("bor_image_off")
|
||
port = int(os.environ.get("E2E_APP_PORT_IMAGES_OFF", "8151"))
|
||
proc = subprocess.Popen(
|
||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
|
||
cwd=REPO,
|
||
env=_app_env(mock_llm, port, images=False, scratch=scratch),
|
||
)
|
||
try:
|
||
_wait_http(f"http://127.0.0.1:{port}/api/health")
|
||
yield f"http://127.0.0.1:{port}"
|
||
finally:
|
||
proc.terminate()
|
||
try:
|
||
proc.wait(timeout=10)
|
||
except subprocess.TimeoutExpired:
|
||
proc.kill()
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def default_app_url(default_app_server: str) -> str:
|
||
return default_app_server
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def zip_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||
"""The fixture archive: ONE standalone PNG at the root — source
|
||
``e2e-image``, document path ``pic.png``."""
|
||
root = tmp_path_factory.mktemp("bor_image_zip")
|
||
(root / DOC_PATH).write_bytes(PNG_1X1)
|
||
archive = root / f"{SOURCE_NAME}.zip"
|
||
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
|
||
zf.write(root / DOC_PATH, arcname=DOC_PATH)
|
||
return archive
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# DB + UI helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _truncate_all() -> None:
|
||
"""Fresh KB + registry per test (the E2E isolation pattern): the
|
||
suites share one Postgres, and a leftover row would corrupt the
|
||
counts. ``sources_meta`` (the KB generation counter) resets with
|
||
the KB — the sync re-creates the row."""
|
||
with SessionLocal() as db:
|
||
db.execute(text(
|
||
"TRUNCATE chunks, documents, query_log, kb_overview, "
|
||
"git_sources, sources_meta, folder_summaries"
|
||
))
|
||
db.commit()
|
||
|
||
|
||
def _health_db(app_url: str) -> bool:
|
||
try:
|
||
return httpx.get(f"{app_url}/api/health", timeout=5).json()["db"] == "up"
|
||
except Exception: # noqa: BLE001 — unreachable is the skip case
|
||
return False
|
||
|
||
|
||
def _upload_zip(page: Page, app_url: str, archive: Path) -> None:
|
||
"""The real admin upload flow: form login → the git-sources page →
|
||
pick the archive → submit → the terminal result line (phase 90:
|
||
"Uploaded <source> — press Sync sources to import it.")."""
|
||
login(page, app_url, next=GIT_SOURCES_URL)
|
||
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
|
||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||
page.set_input_files("#archive-upload-file", str(archive))
|
||
page.click("#archive-upload-btn")
|
||
result = page.locator("#archive-upload-result")
|
||
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
|
||
expect(result).to_have_text(
|
||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it.",
|
||
timeout=UPLOAD_TIMEOUT_MS,
|
||
)
|
||
|
||
|
||
def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
|
||
"""The RAG page's "Sync sources" button (the scan — phase 90 A3):
|
||
click → "Syncing…" → the terminal "Synced HH:MM" label + the fresh
|
||
counts in #sync-result (the never-stale contract, test_git_source_
|
||
dates' lifecycle wait)."""
|
||
page.goto(app_url + "/sources.html")
|
||
btn = page.locator("#sync-btn")
|
||
expect(btn).to_be_visible(timeout=30_000)
|
||
expect(page.locator("#sync-label")).to_have_text(
|
||
re.compile(r"^Sync sources$|^Synced \d{1,2}:\d{2}$")
|
||
)
|
||
btn.click()
|
||
expect(btn).to_be_disabled()
|
||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
||
expect(btn).to_be_enabled()
|
||
expect(page.locator("#sync-result")).to_have_text(expected_result)
|
||
|
||
|
||
def _drill_to_source(page: Page, name: str) -> None:
|
||
"""Top level → the source (the file table then holds its direct
|
||
files — ours is at the source root)."""
|
||
page.locator("#folders-tbody .folder-link").first.wait_for(state="visible")
|
||
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
|
||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=30_000)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# The module seed: upload + sync the fixture image through the real UI
|
||
# (the story E2E truncate/re-import-per-module pattern).
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def seeded(
|
||
app_url: str,
|
||
browser: Browser,
|
||
zip_path: Path,
|
||
) -> Iterator[dict[str, str]]:
|
||
"""Upload the single-PNG zip through the admin UI, then scan it
|
||
with the RAG page's Sync button (the mock's vision model describes
|
||
the image in-process). Yields the seeded doc's identity (the bytes
|
||
route + the description) for the render assertions. The module
|
||
app's ``/api/config`` flag is asserted first — the env override
|
||
must have reached the app under test, or the whole premise of the
|
||
suite is void."""
|
||
if not _health_db(app_url):
|
||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||
cfg = httpx.get(f"{app_url}/api/config", timeout=5).json()
|
||
assert cfg["images"] is True, "the module app must run with BOR_IMAGES=true"
|
||
|
||
_truncate_all()
|
||
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
||
try:
|
||
_upload_zip(page, app_url, zip_path)
|
||
_sync_via_ui(page, app_url, expected_result="1 added")
|
||
with SessionLocal() as db:
|
||
doc = db.scalar(
|
||
select(Document).where(
|
||
Document.source == SOURCE_NAME, Document.path == DOC_PATH
|
||
)
|
||
)
|
||
assert doc is not None, "the uploaded image must become a document"
|
||
assert doc.is_image is True and doc.image_path is not None
|
||
# content == summary == the mock's description (the ONLY
|
||
# embedded text of the doc — the pipeline contract, LOCKED A3).
|
||
assert doc.content == IMAGE_DESCRIPTION_ANSWER
|
||
assert doc.summary == IMAGE_DESCRIPTION_ANSWER
|
||
finally:
|
||
page.close()
|
||
yield {
|
||
"image_url": f"/api/documents/{doc.id}/image",
|
||
"description": IMAGE_DESCRIPTION_ANSWER,
|
||
}
|
||
_truncate_all()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1. Sources page: the image doc is listed with its thumbnail
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_sources_page_lists_the_image_doc(
|
||
page: Page, app_url: str, seeded: dict[str, str]
|
||
) -> None:
|
||
"""The KB total is the image doc (1 doc, 2 chunks — the one
|
||
content chunk + the phase-30 ``is_summary`` chunk), the drill-down
|
||
tree reaches it, and its row carries the FIXED 48px thumbnail box:
|
||
a lazy ``<img>`` from the bytes route (not the glyph fallback —
|
||
the fixture PNG is served), ``alt`` = the summary (the WCAG
|
||
contract)."""
|
||
login(page, app_url) # lands on /sources.html
|
||
expect(page.locator("#stat-docs")).to_have_text("1")
|
||
expect(page.locator("#stat-chunks")).to_have_text("2")
|
||
expect(page.locator("#sources-empty")).to_be_hidden()
|
||
|
||
_drill_to_source(page, SOURCE_NAME)
|
||
row = page.locator("#docs-tbody tr", has_text=DOC_PATH)
|
||
expect(row).to_have_count(1)
|
||
|
||
box = row.locator(".kb-doc-thumb")
|
||
expect(box).to_be_visible()
|
||
img = row.locator(".kb-doc-thumb-img")
|
||
expect(img).to_have_count(1)
|
||
expect(img).to_be_visible(timeout=15_000) # the fetch resolves — no glyph
|
||
expect(img).to_have_attribute("src", seeded["image_url"])
|
||
expect(img).to_have_attribute("loading", "lazy")
|
||
expect(img).to_have_attribute("alt", seeded["description"])
|
||
expect(row.locator(".kb-doc-thumb-glyph")).to_have_count(0)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2. Document viewer: the image renders, the description below it
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_document_viewer_renders_image_and_description(
|
||
page: Page, app_url: str, seeded: dict[str, str]
|
||
) -> None:
|
||
"""The Sources row's path link opens the same-page document modal
|
||
(phase 26): the ``is_image`` content block renders the PERSISTENT
|
||
bytes first (``.doc-image-img`` from the bytes route, ``alt`` =
|
||
the summary), and the description follows in the normal content
|
||
slot (``pre.doc-raw`` — the plain-content path). The labeled
|
||
Summary panel is SUPPRESSED for the verbatim-description case
|
||
(summary === content — the importer invariant; the panel would
|
||
duplicate the text right below the image)."""
|
||
login(page, app_url)
|
||
_drill_to_source(page, SOURCE_NAME)
|
||
page.locator(f'#docs-tbody a.doc-link:text-is("{DOC_PATH}")').click()
|
||
|
||
modal = page.locator("#doc-modal")
|
||
expect(modal).to_be_visible(timeout=30_000)
|
||
expect(page.locator("#doc-modal-title")).to_have_text("pic")
|
||
|
||
img = modal.locator(".doc-image-img")
|
||
expect(img).to_have_count(1)
|
||
expect(img).to_be_visible(timeout=15_000) # the bytes route serves the PNG
|
||
expect(img).to_have_attribute("src", seeded["image_url"])
|
||
expect(img).to_have_attribute("alt", seeded["description"])
|
||
|
||
# The description below the image (the doc's readable content IS
|
||
# the vision description).
|
||
expect(modal.locator("pre.doc-raw")).to_have_text(seeded["description"])
|
||
# The verbatim case: no duplicate Summary panel.
|
||
expect(modal.locator(".doc-summary")).to_have_count(0)
|
||
# No "Image unavailable" note — the copy is intact.
|
||
expect(modal.locator(".doc-image-unavailable")).to_have_count(0)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 3. Chat: a grounded question shows the inline image in the sources
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_chat_sources_block_shows_the_inline_image(
|
||
page: Page, app_url: str, seeded: dict[str, str]
|
||
) -> None:
|
||
"""A question the mock grounds on the image doc (cosine ≈0.31,
|
||
FTS-corroborated), scripted to READ it (phase 119 A1 — a zero-read
|
||
turn chips nothing): the mock's echo carries the agent ``read``
|
||
result VERBATIM — including the task-05 ``IMAGE_DOC_MARKER`` line
|
||
(the model saw the description, not raw bytes) — and the answer's
|
||
sources block carries the citation chip (the READ doc clears the
|
||
usefulness floor) AND the compact inline figure — the ``<img>``
|
||
from the bytes route, the visible caption + ``alt`` settling to
|
||
the document's summary (the figure fetches it from the content
|
||
endpoint — the frame carries no summary), the "shown in the chat
|
||
nicely" contract."""
|
||
login(page, app_url, next="/")
|
||
expect(page.locator("#kb-banner")).to_be_hidden()
|
||
|
||
page.fill("#message-input", IMAGE_QUESTION)
|
||
page.click("#send-btn")
|
||
|
||
bubble = page.locator(".msg.brain .bubble").last
|
||
# The scripted read's echoed result — the marker line (task 05)
|
||
# and the description itself (the model's view of the image).
|
||
expect(bubble).to_contain_text(IMAGE_DOC_MARKER, timeout=30_000)
|
||
expect(bubble).to_contain_text(seeded["description"], timeout=30_000)
|
||
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
|
||
expect(page.locator("#send-label")).to_have_text("Send")
|
||
|
||
# The citation chip (the text affordance stays — the figure is
|
||
# additive, not a replacement).
|
||
chip = page.locator(".msg.brain .source-chip")
|
||
expect(chip).to_have_count(1)
|
||
expect(chip).to_have_text(f"{SOURCE_NAME}/{DOC_PATH}")
|
||
|
||
# The inline figure: image + caption, both settling to the summary
|
||
# (the content fetch resolves the alt/caption after the title).
|
||
fig = page.locator(".msg.brain .source-image")
|
||
expect(fig).to_have_count(1)
|
||
img = fig.locator(".source-image-img")
|
||
expect(img).to_be_visible(timeout=15_000)
|
||
expect(img).to_have_attribute("src", seeded["image_url"])
|
||
expect(img).to_have_attribute("alt", seeded["description"], timeout=15_000)
|
||
caption = fig.locator(".source-image-caption")
|
||
expect(caption).to_have_text(seeded["description"], timeout=15_000)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 4. The default contract: BOR_IMAGES off (the default) → the same
|
||
# upload + sync produces NO image document
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_default_env_upload_produces_no_image_doc(
|
||
page: Page, default_app_url: str, zip_path: Path
|
||
) -> None:
|
||
"""The off-by-default contract end-to-end: with ``BOR_IMAGES``
|
||
false, the SAME upload + sync is byte-identical to the pre-phase
|
||
walk — the PNG is invisible to the scan (0 files, 0 added), the
|
||
source row stays a registered 0-document source, and NO documents
|
||
row (let alone an ``is_image`` one) exists. The file still lands
|
||
on disk (the unpack is unchanged — only the walk filter changes)."""
|
||
if not _health_db(default_app_url):
|
||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||
cfg = httpx.get(f"{default_app_url}/api/config", timeout=5).json()
|
||
assert cfg["images"] is False, "the default app must run with images off"
|
||
|
||
_truncate_all()
|
||
try:
|
||
_upload_zip(page, default_app_url, zip_path)
|
||
_sync_via_ui(
|
||
page, default_app_url, expected_result="0 added · 0 unchanged"
|
||
)
|
||
|
||
# No document at all — the image file is not even a "file" to
|
||
# the images-off walk (not unknown, not indexed).
|
||
with SessionLocal() as db:
|
||
assert (
|
||
db.scalar(select(Document).where(Document.is_image.is_(True)))
|
||
is None
|
||
)
|
||
assert db.scalar(select(Document).where(Document.source == SOURCE_NAME)) is None
|
||
|
||
# The Sources page: 0 docs, the source row (registered) drills
|
||
# to an EMPTY file table.
|
||
page.goto(default_app_url + "/sources.html")
|
||
expect(page.locator("#stat-docs")).to_have_text("0")
|
||
expect(page.locator("#stat-chunks")).to_have_text("0")
|
||
page.locator("#folders-tbody .folder-link").first.wait_for(state="visible")
|
||
page.click(f'#folders-tbody a.folder-link:text-is("{SOURCE_NAME}")')
|
||
expect(page.locator("#docs-tbody tr")).to_have_count(0)
|
||
finally:
|
||
_truncate_all()
|