All 4 task files exist in `complete/`, and only `00_phase.md` remains in `todo/`. This is the final verification pass. I'll read through the task files and check the current state of the code.
517 lines
22 KiB
Python
517 lines
22 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 _ready_for_sync_line(source: str) -> re.Pattern[str]:
|
||
"""Phase 154 (task 02): the ready-for-sync line carrying the
|
||
completion stamp — "Uploaded <source> (<stamp>) — press Sync
|
||
sources to import it." (the fresh 202 settle). In E2E the stamp
|
||
is ALWAYS the same-day ``HH:MM`` form (the run settles within
|
||
seconds; the date-qualified forms are unit-pinned on a fixed
|
||
clock — E2E never depends on the wall clock)."""
|
||
return re.compile(
|
||
rf"^Uploaded {re.escape(source)} \(\d{{1,2}}:\d{{2}}\)"
|
||
r" — press Sync sources to import it\.$"
|
||
)
|
||
|
||
|
||
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 A3,
|
||
phase 154 task 02: "Uploaded <source> (<stamp>) — press Sync
|
||
sources to import it." — the completion stamp)."""
|
||
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(
|
||
_ready_for_sync_line(SOURCE_NAME),
|
||
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-progress (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-progress-text")).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 (the READ doc clears the
|
||
usefulness floor) as the compact figure in the images row
|
||
(phase 130 task 01: NO chip for an image doc — the figure IS the
|
||
citation) — 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")
|
||
|
||
# Phase 130 (task 01, TODO L6): NO chip for the image doc — its
|
||
# figure is the citation — and the figure sits in its own images
|
||
# cluster (.msg-meta.msg-images), not interleaved with a chip.
|
||
expect(page.locator(".msg.brain .source-chip")).to_have_count(0)
|
||
row = page.locator(".msg.brain .msg-meta.msg-images")
|
||
expect(row).to_have_count(1)
|
||
expect(row).to_have_attribute("aria-label", "Source images")
|
||
|
||
# The inline figure: image + caption, both settling to the summary
|
||
# (the content fetch resolves the alt/caption after the title).
|
||
fig = row.locator(".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()
|