All gates green. Verification complete. **Phase 123 — final verification pass (all 4 tasks already in `complete/`)** - Verified the full implementation is in the working tree: `app/api/chat_images.py` (upload/serve pair), `ChatRequest.image`/`ChatMessage.image` (path-validated, omitted-when-None), toggle-off + stale-file hinted error frames, `build_user_content` multimodal build at both sites (chat.py deflected branch + `run_agent`), config-gated composer attach/preview/upload-then-send, restore + shared rendering, CSP `img-src 'self' data:` carve-out, mock-LLM capture buffer. - `uv run pytest` → **2796 passed**, exit 0 (unit + integration). - `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (29/4615 missed; phase-123 modules 99–100%). - `uv run pytest tests/e2e/test_chat_image_questions.py -v --no-cov` → **5 passed** in isolation. - `uv run ruff check . && uv run pyright` → clean (0 errors). **Completion criteria:** (1) attach→send→multimodal text+image to the model, bubble/reload/shared all render it, saved chat stores the PATH with `"base64" not in json.dumps(stored)` — **verified** (E2E tests 1–4 + integration round-trip); (2) `BOR_IMAGES=false` — control hidden, exact hinted error frame, zero model calls / no query_log row — **verified** (E2E test 5 + integration); (3) text-only byte-identical (`content` stays a plain `str`) — **verified** (unit + integration); (4) all gates green — **verified**; (5) commit + phase move — left to the harness per pipeline rules (no `git add`/`commit` run). No defects found; no live-infrastructure changes (repo + local dev DB only). **Next pending phase: none** — 123 is the last phase in `todo/`.
1171 lines
48 KiB
Python
1171 lines
48 KiB
Python
"""Unit: image questions (phase 123, task 01 — attach an image to a
|
||
question; TODO L6).
|
||
|
||
The server-side contract pinned here:
|
||
|
||
* ``POST /api/chat-images`` (user-gated like the chat turn it feeds)
|
||
stores the question image as ``<uuid4().hex>.<ext>`` under
|
||
``chat_image_dir`` — the extension gate reuses the phase-122 image
|
||
frozenset (the extension is the source of truth, the Content-Type
|
||
header a hint), the bytes are streamed with the
|
||
``chat_image_max_mb`` cap (413, fixed detail naming the cap, never
|
||
echoing the filename) — and returns the served path, the ONLY value
|
||
``ChatRequest.image`` accepts (never a data URL).
|
||
* ``GET /api/chat-images/{filename}`` (PUBLIC — the saved chat's id is
|
||
already its credential, phase 55 A1; the uuid filename has no
|
||
enumeration value) serves the bytes with the phase-122 mime map +
|
||
``Cache-Control: private, max-age=3600``; a malformed name OR a
|
||
missing file is one fixed 404 (no traversal, no shape-hinting 422).
|
||
* ``ChatRequest.image`` (stored path, pattern-validated with a fixed
|
||
422 detail — no echo) and ``ChatMessage.image`` (on the USER record
|
||
only; omitted when ``None`` — a text-only saved payload stays
|
||
byte-identical to pre-phase, the phase-50 contract).
|
||
* The turn pipeline: image + toggle OFF → the phase-114 error frame
|
||
WITH the hint (no model call, no record); image + the stored file
|
||
deleted out-of-band → the same frame shape, no hint; a valid image →
|
||
the multimodal user content list (text part + image_url data URL) at
|
||
BOTH construction sites (this module's deflected-branch ``messages``
|
||
list and the grounded branch's ``run_agent`` — the flow pinned in
|
||
``app.rag.agent``); ``image=None`` → the plain-string content,
|
||
byte-identical to pre-phase.
|
||
|
||
Endpoint-level chat tests use the ``test_embed_question_length.py``
|
||
wiring (a recording fake LLM, a fake retriever, a fake DB session,
|
||
patched settings) so the whole validate → build contract runs without
|
||
a stack; the upload/serve tests point ``chat_image_dir`` at a tmp
|
||
directory (the raw-string ``expanduser`` convention).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import io
|
||
import json
|
||
import re
|
||
import uuid
|
||
from collections.abc import Iterator
|
||
from datetime import UTC, datetime
|
||
from pathlib import Path
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
from pydantic import ValidationError
|
||
|
||
from app.api import chat as chat_api
|
||
from app.api import chat_images
|
||
from app.config import Settings
|
||
from app.main import app as fastapi_app
|
||
from app.models import Document
|
||
from app.rag import summarizer
|
||
from app.rag.llm import StreamPiece
|
||
from app.rag.retriever import RetrievedChunk
|
||
from app.schemas import ChatMessage, ChatRequest
|
||
from tests.conftest import ADMIN_PASSWORD
|
||
|
||
if TYPE_CHECKING:
|
||
from app.rag.scaffolding import ScaffoldingFilter
|
||
|
||
#: A real 1×1 transparent PNG (the phase-122 fixture bytes) — the
|
||
#: server is content-agnostic (extension + size gates only), but a
|
||
#: well-formed fixture keeps the data-URL pin honest.
|
||
PNG_1X1 = base64.b64decode(
|
||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
|
||
)
|
||
|
||
#: A well-formed stored path (the upload response's shape) for one hex
|
||
#: per test module — the validators key on the SHAPE, not a specific
|
||
#: uuid, so a fixed value pins the pattern deterministically.
|
||
GOOD_PATH = "/api/chat-images/" + "a" * 32 + ".png"
|
||
GOOD_PATHS = {
|
||
GOOD_PATH,
|
||
"/api/chat-images/" + "B" * 32 + ".jpeg", # uppercase hex is accepted too
|
||
"/api/chat-images/" + "0" * 32 + ".jpg",
|
||
"/api/chat-images/" + "1" * 32 + ".webp",
|
||
"/api/chat-images/" + "2" * 32 + ".gif",
|
||
"/api/chat-images/" + "3" * 32 + ".bmp",
|
||
}
|
||
BAD_PATHS = {
|
||
"data:image/png;base64,iVBORw0KGgo", # a data URL — the anti-pattern
|
||
"/api/chat-images/" + "a" * 31 + ".png", # 31 hex chars — not a uuid4().hex
|
||
"/api/chat-images/" + "a" * 33 + ".png", # 33 hex chars
|
||
"/api/chat-images/" + "g" * 32 + ".png", # non-hex chars
|
||
"/api/chat-images/" + "a" * 32 + ".txt", # a wrong extension
|
||
"/api/chat-images/" + "a" * 32 + ".giff", # near-miss extension
|
||
"/api/chat-images/" + "a" * 32, # no extension at all
|
||
"photo.png", # a bare filename — not a served path
|
||
"/api/chat-images/../etc/passwd", # a traversal
|
||
"/api/chat-images/" + "a" * 32 + ".png?x=1", # a query string is not part of a path
|
||
}
|
||
|
||
|
||
# ---------- settings (phase 123 task 01, work item 1) ----------
|
||
|
||
|
||
def test_chat_image_settings_defaults() -> None:
|
||
"""The ``image_dir`` convention: a sibling of phase 122's document-
|
||
image dir, and the A5 ~10 MB cap."""
|
||
assert Settings.model_fields["chat_image_dir"].default == (
|
||
"~/bor-sources/chat-images"
|
||
)
|
||
assert Settings.model_fields["chat_image_max_mb"].default == 10
|
||
s = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
|
||
assert s.chat_image_dir == "~/bor-sources/chat-images"
|
||
assert s.chat_image_max_mb == 10
|
||
|
||
|
||
@pytest.mark.parametrize("bad", [0, -1, -10])
|
||
def test_chat_image_max_mb_rejects_zero_and_negative(bad: int) -> None:
|
||
"""``<= 0`` would reject every question-image upload — a typo that
|
||
must fail loudly at startup (the ``upload_max_mb`` precedent)."""
|
||
with pytest.raises(ValidationError, match="chat_image_max_mb must be > 0"):
|
||
Settings(_env_file=None, chat_image_max_mb=bad) # pyright: ignore[reportCallIssue]
|
||
|
||
|
||
@pytest.mark.parametrize("good", [1, 10, 512])
|
||
def test_chat_image_max_mb_accepts_positive(good: int) -> None:
|
||
"""A deployment with a smaller-cap vision model lowers the cap via
|
||
``BOR_CHAT_IMAGE_MAX_MB`` — env-tunable, no code change."""
|
||
s = Settings(_env_file=None, chat_image_max_mb=good) # pyright: ignore[reportCallIssue]
|
||
assert s.chat_image_max_mb == good
|
||
|
||
|
||
def test_chat_image_settings_env_overrides(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""The ``BOR_``-prefixed env vars drive the pair (the house
|
||
convention): a deployment moves the store (a shared volume) or
|
||
lowers the cap through ``.env`` alone — no code change."""
|
||
monkeypatch.setenv("BOR_CHAT_IMAGE_DIR", "/srv/bor/chat-images")
|
||
monkeypatch.setenv("BOR_CHAT_IMAGE_MAX_MB", "42")
|
||
s = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
|
||
assert s.chat_image_dir == "/srv/bor/chat-images"
|
||
assert s.chat_image_max_mb == 42
|
||
|
||
|
||
# ---------- the shared data-URL helper (assert the import, not a copy)
|
||
|
||
|
||
def test_the_data_url_helper_is_imported_not_copied() -> None:
|
||
"""Phase 123 (task 04): the multimodal builder and the phase-122
|
||
``describe_image`` path share ONE data-URL helper —
|
||
``app.rag.summarizer.image_data_url``. The identity pin catches a
|
||
regression that would silently fork the construction (a local
|
||
copy is a different function object): one map, one truth, both
|
||
call sites."""
|
||
assert chat_api.image_data_url is summarizer.image_data_url
|
||
# and the describe path really calls it (the phase-122 call site
|
||
# inside summarizer.py's describe_image — a re-implementation would
|
||
# build the string inline and this line would fail).
|
||
src = (Path(__file__).resolve().parents[2] / "app" / "rag" / "summarizer.py").read_text(
|
||
encoding="utf-8"
|
||
)
|
||
assert re.search(r"data_url = image_data_url\(data, mime\)", src)
|
||
|
||
|
||
# ---------- ChatRequest.image (the stored-path pattern guard) ----------
|
||
|
||
|
||
def test_image_validator_accepts_well_formed_paths() -> None:
|
||
"""Every well-formed stored path parses — the six extensions,
|
||
lower- AND uppercase hex (``uuid4().hex`` is lowercase; the schema
|
||
pattern allows both, per the pinned design regex)."""
|
||
for path in GOOD_PATHS:
|
||
request = ChatRequest(message="What is in this image?", image=path)
|
||
assert request.image == path
|
||
|
||
|
||
@pytest.mark.parametrize("bad", sorted(BAD_PATHS))
|
||
def test_image_validator_rejects_wrong_shapes(bad: str) -> None:
|
||
"""A data URL, a wrong extension, a traversal, a mistyped uuid —
|
||
all 422 with ONE fixed detail (no echo of the input)."""
|
||
with pytest.raises(ValidationError, match="image must be an uploaded chat image path"):
|
||
ChatRequest(message="What is in this image?", image=bad) # type: ignore[call-arg]
|
||
|
||
|
||
def test_image_validator_defaults_to_none() -> None:
|
||
"""``image=None`` (every pre-phase / text-only request) stays
|
||
untouched — no validation, byte-identical request shape. An
|
||
EXPLICIT ``null`` (a client that always sends the key) passes the
|
||
validator as ``None`` too (the null-safe restore convention)."""
|
||
request = ChatRequest(message="How is my homelab set up?")
|
||
assert request.image is None
|
||
explicit = ChatRequest.model_validate({"message": "hi", "image": None})
|
||
assert explicit.image is None
|
||
|
||
|
||
def test_image_over_500_chars_is_rejected() -> None:
|
||
"""The path bound (500) — a data URL smuggled past the pattern
|
||
would be caught by it, but the bound is the size ceiling anyway."""
|
||
with pytest.raises(ValidationError, match="at most 500 characters"):
|
||
ChatRequest(message="hi", image="x" * 501) # type: ignore[call-arg]
|
||
|
||
|
||
def test_chat_endpoint_422s_a_non_path_image(client: TestClient) -> None:
|
||
"""The schema guard is the boundary: a data URL never reaches the
|
||
turn (422 JSON — not an SSE frame)."""
|
||
r = client.post(
|
||
"/api/chat",
|
||
json={"message": "What is this?", "image": "data:image/png;base64,AAAA"},
|
||
)
|
||
assert r.status_code == 422
|
||
|
||
|
||
# ---------- build_user_content (the multimodal builder, unit level) ----------
|
||
|
||
|
||
def _stored_image(
|
||
tmp_path: Path, name: str = GOOD_PATH.rsplit("/", 1)[-1], data: bytes = PNG_1X1
|
||
) -> Path:
|
||
"""One stored question image in a tmp ``chat_image_dir``."""
|
||
root = tmp_path / "chat-images"
|
||
root.mkdir(parents=True, exist_ok=True)
|
||
(root / name).write_bytes(data)
|
||
return root
|
||
|
||
|
||
def test_build_user_content_none_is_the_plain_string(tmp_path: Path) -> None:
|
||
"""``image=None`` → the plain question string (byte-identical to
|
||
pre-phase — the multimodal branch is inert)."""
|
||
settings = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
|
||
assert chat_api.build_user_content("How is my homelab set up?", None, settings) == (
|
||
"How is my homelab set up?"
|
||
)
|
||
|
||
|
||
def test_build_user_content_set_is_the_multimodal_list(tmp_path: Path) -> None:
|
||
"""A stored path → the OpenAI-compatible content list: the text
|
||
part + the image part (a data URL built server-side from the stored
|
||
bytes + the phase-122 mime map — one map, one truth)."""
|
||
root = _stored_image(tmp_path)
|
||
settings = Settings(_env_file=None, chat_image_dir=str(root)) # pyright: ignore[reportCallIssue]
|
||
content = chat_api.build_user_content("What is in this image?", GOOD_PATH, settings)
|
||
expected_b64 = base64.b64encode(PNG_1X1).decode("ascii")
|
||
assert content == [
|
||
{"type": "text", "text": "What is in this image?"},
|
||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{expected_b64}"}},
|
||
]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("ext", "mime"),
|
||
[
|
||
(".jpg", "image/jpeg"),
|
||
(".jpeg", "image/jpeg"),
|
||
(".webp", "image/webp"),
|
||
(".gif", "image/gif"),
|
||
(".bmp", "image/bmp"),
|
||
],
|
||
)
|
||
def test_build_user_content_mime_follows_the_phase_122_map(
|
||
tmp_path: Path, ext: str, mime: str
|
||
) -> None:
|
||
"""Every accepted extension rides its phase-122 data-URL mime (the
|
||
``IMAGE_MIMES`` map — the describe path's one map, one truth)."""
|
||
name = "c" * 32 + ext
|
||
root = _stored_image(tmp_path, name=name)
|
||
settings = Settings(_env_file=None, chat_image_dir=str(root)) # pyright: ignore[reportCallIssue]
|
||
content = chat_api.build_user_content("q", f"/api/chat-images/{name}", settings)
|
||
assert isinstance(content, list)
|
||
url = content[1]["image_url"]["url"] # type: ignore[index]
|
||
assert url.startswith(f"data:{mime};base64,")
|
||
|
||
|
||
# ---------- POST /api/chat-images (the upload endpoint) ----------
|
||
|
||
|
||
@pytest.fixture()
|
||
def image_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||
"""Point the store at a tmp dir (the raw-string ``expanduser``
|
||
convention — the setting is patched, not the filesystem home; the
|
||
monkeypatch fixture reverts it)."""
|
||
root = tmp_path / "chat-images"
|
||
monkeypatch.setattr(
|
||
chat_images,
|
||
"get_settings",
|
||
lambda: Settings(_env_file=None, chat_image_dir=str(root)), # pyright: ignore[reportCallIssue]
|
||
)
|
||
return root
|
||
|
||
|
||
def _upload(client: TestClient, filename: str, data: bytes, mime: str = "image/png") -> Any:
|
||
return client.post(
|
||
"/api/chat-images",
|
||
files={"file": (filename, io.BytesIO(data), mime)},
|
||
)
|
||
|
||
|
||
def test_upload_stores_a_uuid_named_file_and_returns_its_path(
|
||
client: TestClient, image_dir: Path
|
||
) -> None:
|
||
"""The happy path: a well-formed upload lands as
|
||
``<uuid4().hex>.<ext>`` in the store (created on demand) and the
|
||
response is exactly the served path — the value the chat request
|
||
accepts."""
|
||
r = _upload(client, "my photo.png", PNG_1X1)
|
||
assert r.status_code == 200
|
||
path = r.json()["path"]
|
||
assert re.fullmatch(r"/api/chat-images/[0-9a-f]{32}\.png", path)
|
||
assert set(r.json()) == {"path"} # the response shape — nothing else
|
||
stored = image_dir / path.rsplit("/", 1)[-1]
|
||
assert stored.is_file()
|
||
assert stored.read_bytes() == PNG_1X1 # the exact bytes
|
||
# the store holds exactly the one file (no temp litter)
|
||
assert [p.name for p in image_dir.iterdir()] == [stored.name]
|
||
|
||
|
||
def test_upload_normalizes_an_uppercase_extension(
|
||
client: TestClient, image_dir: Path
|
||
) -> None:
|
||
"""The extension is lowercased into the stored name (``Photo.JPG``
|
||
→ ``.jpg``) — the serve route's mime map is lowercase-keyed."""
|
||
r = _upload(client, "Photo.JPG", PNG_1X1, mime="image/jpeg")
|
||
assert r.status_code == 200
|
||
assert r.json()["path"].endswith(".jpg")
|
||
assert (image_dir / r.json()["path"].rsplit("/", 1)[-1]).is_file()
|
||
|
||
|
||
def test_upload_rejects_a_non_image_extension_with_the_fixed_detail(
|
||
client: TestClient, image_dir: Path
|
||
) -> None:
|
||
"""The EXTENSION is the source of truth (the Content-Type header is
|
||
a hint): a non-image extension is a 422 naming the accepted set —
|
||
a fixed detail, no filename echo — and NOTHING is written (not
|
||
even the store dir)."""
|
||
r = _upload(client, "evil.exe", b"MZ", mime="application/octet-stream")
|
||
assert r.status_code == 422
|
||
assert r.json()["detail"] == (
|
||
"only .bmp, .gif, .jpeg, .jpg, .png, .webp images are accepted"
|
||
)
|
||
assert not image_dir.exists() # the dir is created on the accepted path only
|
||
|
||
|
||
def test_upload_rejects_a_name_without_an_extension(
|
||
client: TestClient, image_dir: Path
|
||
) -> None:
|
||
"""No extension → not in the phase-122 set → the same 422."""
|
||
r = _upload(client, "README", b"not an image")
|
||
assert r.status_code == 422
|
||
assert not image_dir.exists()
|
||
|
||
|
||
def test_upload_413s_over_cap_with_the_fixed_detail(
|
||
client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""The byte cap (``chat_image_max_mb`` — 1 MiB here) is enforced
|
||
while streaming: over-cap is a 413 naming the cap (never the
|
||
filename), no file is stored, and no temp litter survives."""
|
||
root = tmp_path / "chat-images"
|
||
monkeypatch.setattr(
|
||
chat_images,
|
||
"get_settings",
|
||
lambda: Settings(
|
||
_env_file=None, # pyright: ignore[reportCallIssue]
|
||
chat_image_dir=str(root),
|
||
chat_image_max_mb=1,
|
||
),
|
||
)
|
||
r = _upload(client, "big.png", b"\x00" * (1024 * 1024 + 1))
|
||
assert r.status_code == 413
|
||
assert r.json()["detail"] == "the image exceeds the 1 MB limit"
|
||
if root.exists(): # nothing stored, no temp litter
|
||
assert list(root.iterdir()) == []
|
||
# at-or-under the cap passes (the boundary is inclusive)
|
||
r2 = _upload(client, "ok.png", b"\x00" * (1024 * 1024))
|
||
assert r2.status_code == 200
|
||
|
||
|
||
def test_upload_requires_a_user(client: TestClient, image_dir: Path) -> None:
|
||
"""The upload is user-gated exactly like the chat turn it feeds —
|
||
an anonymous caller gets the 401 before any byte is received."""
|
||
anon = TestClient(fastapi_app)
|
||
r = _upload(anon, "a.png", PNG_1X1)
|
||
assert r.status_code == 401
|
||
assert not image_dir.exists()
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("ext", "mime"),
|
||
[
|
||
(".png", "image/png"),
|
||
(".jpg", "image/jpeg"),
|
||
(".jpeg", "image/jpeg"),
|
||
(".webp", "image/webp"),
|
||
(".gif", "image/gif"),
|
||
(".bmp", "image/bmp"),
|
||
],
|
||
)
|
||
def test_upload_accepts_every_one_of_the_six_extensions(
|
||
client: TestClient, image_dir: Path, ext: str, mime: str
|
||
) -> None:
|
||
"""The FULL accepted set (the phase-122 frozenset — one set, one
|
||
truth with the serve route's mime map and the client pre-check):
|
||
every extension of the six lands stored + served, nothing in the
|
||
set is silently missing."""
|
||
r = _upload(client, f"photo{ext}", PNG_1X1, mime=mime)
|
||
assert r.status_code == 200, r.text
|
||
path = r.json()["path"]
|
||
assert path.endswith(ext)
|
||
assert (image_dir / path.rsplit("/", 1)[-1]).is_file()
|
||
|
||
|
||
# ---------- GET /api/chat-images/{filename} (the serve route) ----------
|
||
|
||
|
||
def test_serve_returns_the_exact_bytes_with_mime_and_cache(
|
||
client: TestClient, image_dir: Path
|
||
) -> None:
|
||
"""A stored file streams back byte-identical with the phase-122
|
||
mime map's Content-Type + the phase-122 serve-route cache header."""
|
||
r = _upload(client, "a.png", PNG_1X1)
|
||
path = r.json()["path"]
|
||
got = client.get(path)
|
||
assert got.status_code == 200
|
||
assert got.content == PNG_1X1
|
||
assert got.headers["content-type"].startswith("image/png")
|
||
assert got.headers["cache-control"] == "private, max-age=3600"
|
||
|
||
|
||
def test_serve_is_public_like_saved_chat_content(client: TestClient, image_dir: Path) -> None:
|
||
"""The image is part of a saved chat's content (phase 55 A1 — the
|
||
chat's id is already its credential): an anonymous visitor with the
|
||
path can render it (the shared page's fidelity)."""
|
||
r = _upload(client, "a.png", PNG_1X1)
|
||
anon = TestClient(fastapi_app)
|
||
got = anon.get(r.json()["path"])
|
||
assert got.status_code == 200
|
||
assert got.content == PNG_1X1
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"bad",
|
||
[
|
||
"a" * 31 + ".png", # 31 hex chars — not the stored shape
|
||
"a" * 33 + ".png", # 33 hex chars
|
||
"a" * 32 + ".txt", # a wrong extension
|
||
"a" * 32, # no extension
|
||
"not-a-uuid.png", # not hex at all
|
||
"..png", # a traversal-shaped name
|
||
],
|
||
)
|
||
def test_serve_404s_a_malformed_filename(client: TestClient, image_dir: Path, bad: str) -> None:
|
||
"""The regex guard: anything that is not ``<uuid-hex>.<ext>`` 404s
|
||
with the ONE fixed detail — no 422 that would hint at accepted
|
||
shapes, no traversal by construction."""
|
||
r = client.get(f"/api/chat-images/{bad}")
|
||
assert r.status_code == 404
|
||
assert r.json() == {"detail": "chat image not found"}
|
||
|
||
|
||
def test_serve_404s_a_missing_file(client: TestClient, image_dir: Path) -> None:
|
||
"""Well-formed name, file deleted out-of-band (the stale-path
|
||
edge) → the same fixed 404 (the viewer's fallback renders the
|
||
"image unavailable" note — never a broken icon)."""
|
||
r = client.get(f"/api/chat-images/{'d' * 32}.png")
|
||
assert r.status_code == 404
|
||
assert r.json() == {"detail": "chat image not found"}
|
||
|
||
|
||
# ---------- ChatMessage.image (persistence shape) ----------
|
||
|
||
|
||
def test_chat_message_image_defaults_to_none_and_is_omitted() -> None:
|
||
"""A record without an image serializes WITHOUT the key (ABSENT,
|
||
never ``null``) — a text-only saved payload is byte-identical to
|
||
pre-phase-123 (the phase-50 round-trip contract)."""
|
||
m = ChatMessage(who="user", text="How is my homelab set up?")
|
||
assert m.image is None
|
||
dumped = m.model_dump()
|
||
assert "image" not in dumped # ABSENT, never ``null`` (the phase-50 contract)
|
||
# and every pre-phase key set is untouched
|
||
assert set(dumped) == {
|
||
"who",
|
||
"text",
|
||
"sources",
|
||
"related",
|
||
"deflected",
|
||
"suggestions",
|
||
"thinking",
|
||
"tools",
|
||
"stopped",
|
||
"failed",
|
||
"error",
|
||
}
|
||
|
||
|
||
def test_chat_message_carries_the_stored_path_on_the_user_record() -> None:
|
||
"""The user record carries the PATH (never base64, LOCKED A5) and
|
||
it round-trips losslessly through the saved/shared shape."""
|
||
m = ChatMessage(who="user", text="What is in this image?", image=GOOD_PATH)
|
||
dumped = m.model_dump()
|
||
assert dumped["image"] == GOOD_PATH
|
||
rt = ChatMessage.model_validate(dumped)
|
||
assert rt.image == GOOD_PATH
|
||
# an explicit ``null`` (a legacy/corrupted payload) parses too —
|
||
# the restore path is null-safe for every optional key
|
||
assert ChatMessage.model_validate({"who": "user", "text": "x", "image": None}).image is None
|
||
|
||
|
||
def test_chat_message_image_bound_is_500() -> None:
|
||
"""The path bound: 500 fits (the upload path is ~52 chars — the
|
||
bound is headroom, not a squeeze), 501 422s."""
|
||
assert ChatMessage(who="user", text="x", image="a" * 500).image == "a" * 500
|
||
with pytest.raises(ValidationError, match="at most 500 characters"):
|
||
ChatMessage(who="user", text="x", image="a" * 501)
|
||
|
||
|
||
def test_chat_message_extra_forbid_boundary_is_intact() -> None:
|
||
"""``extra="forbid"`` still rejects unknown keys (a corrupted or
|
||
HTML-shaped payload 422s at the boundary) — adding ``image``
|
||
changed nothing about the KEYS contract."""
|
||
with pytest.raises(ValidationError):
|
||
ChatMessage(who="user", text="x", bogus="<b>") # type: ignore[call-arg]
|
||
with pytest.raises(ValidationError):
|
||
ChatMessage(who="alien", text="x") # type: ignore[call-arg]
|
||
|
||
|
||
# ---------- the turn pipeline (validate → multimodal build) ----------
|
||
|
||
|
||
class _RecordingLLM:
|
||
"""Records every ``embed_one`` input and the messages of each
|
||
request (the ``test_embed_question_length.py`` fake) — streams a
|
||
canned answer, never emits tool calls (a grounded turn through the
|
||
agent loop ends after the single request)."""
|
||
|
||
def __init__(self, answer: str = "Here is what the image shows.") -> None:
|
||
self.settings = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
|
||
self.embedded: list[str] = []
|
||
self.answer = answer
|
||
self.seen: list[list[dict[str, Any]]] = []
|
||
|
||
async def embed_one(self, text: str) -> list[float]:
|
||
self.embedded.append(text)
|
||
return [0.0] * 768
|
||
|
||
async def chat_stream(
|
||
self,
|
||
messages: list[dict[str, Any]],
|
||
tools: list[dict[str, Any]] | None = None,
|
||
scaffolding: ScaffoldingFilter | None = None,
|
||
):
|
||
self.seen.append(messages)
|
||
for i in range(0, len(self.answer), 12):
|
||
yield StreamPiece("content", self.answer[i : i + 12])
|
||
|
||
|
||
class _FakeSteeringResult:
|
||
def all(self) -> list[Any]:
|
||
return []
|
||
|
||
|
||
class _FakeSession:
|
||
"""Stands in for the DB session (the ``test_embed_question_length``
|
||
fake) — records what was written (the error-path convention is a
|
||
NO-WRITE)."""
|
||
|
||
def __init__(self) -> None:
|
||
self.added: list[Any] = []
|
||
self.commits = 0
|
||
|
||
def __enter__(self) -> _FakeSession:
|
||
return self
|
||
|
||
def __exit__(self, *args: Any) -> None:
|
||
pass
|
||
|
||
def add(self, obj: Any) -> None:
|
||
self.added.append(obj)
|
||
|
||
def commit(self) -> None:
|
||
self.commits += 1
|
||
|
||
def scalars(self, _stmt: Any) -> _FakeSteeringResult:
|
||
return _FakeSteeringResult()
|
||
|
||
def get(self, model: Any, pk: Any) -> Any:
|
||
return None
|
||
|
||
def execute(self, *args: Any, **kwargs: Any) -> list[Any]:
|
||
return []
|
||
|
||
|
||
def _doc(title: str, content: str) -> Document:
|
||
return Document(
|
||
id=uuid.uuid4(),
|
||
source="Homelab",
|
||
path=f"{title.lower().replace(' ', '-')}.md",
|
||
full_path="/tmp/doc.md",
|
||
title=title,
|
||
content=content,
|
||
content_hash="0" * 64,
|
||
created_at=datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC),
|
||
)
|
||
|
||
|
||
def _chunk(doc: Document, score: float) -> RetrievedChunk:
|
||
return RetrievedChunk(
|
||
chunk_id=uuid.uuid4(),
|
||
position=0,
|
||
content=doc.content[:32],
|
||
score=score,
|
||
document=doc,
|
||
cosine=score,
|
||
fts_hit=False,
|
||
is_summary=False,
|
||
)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _admin_signed_in(client: TestClient) -> None:
|
||
"""``POST /api/chat`` and ``POST /api/chat-images`` are user-gated
|
||
— every test here runs as the signed-in ADMIN (the TestClient
|
||
cookie jar carries the session; ``require_user`` passes the admin
|
||
session without a DB lookup)."""
|
||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
|
||
|
||
|
||
@pytest.fixture()
|
||
def turn_env(
|
||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||
) -> Iterator[tuple[_FakeSession, _RecordingLLM, Path]]:
|
||
"""``POST /api/chat`` with the LLM, retriever, session, and
|
||
settings all faked — the images toggle ON by default here (each
|
||
test that needs it off patches the settings again), the store is a
|
||
tmp dir, and the code-default thresholds apply (0.9 chunk grounded,
|
||
0.29 chunk deflected, against the conftest-calibrated 0.30
|
||
floor... the gate threshold is patched to the mock's calibration)."""
|
||
session = _FakeSession()
|
||
llm = _RecordingLLM()
|
||
store = tmp_path / "chat-images"
|
||
store.mkdir()
|
||
(store / GOOD_PATH.rsplit("/", 1)[-1]).write_bytes(PNG_1X1)
|
||
settings = Settings(
|
||
_env_file=None, # pyright: ignore[reportCallIssue]
|
||
relevance_threshold=0.30,
|
||
images=True,
|
||
chat_image_dir=str(store),
|
||
llm_retry_delay=0.01,
|
||
)
|
||
monkeypatch.setattr(chat_api, "db_available", lambda: True)
|
||
monkeypatch.setattr(chat_api, "SessionLocal", lambda: session)
|
||
monkeypatch.setattr(chat_api, "get_settings", lambda: settings)
|
||
monkeypatch.setitem(fastapi_app.dependency_overrides, chat_api.get_llm, lambda: llm)
|
||
yield session, llm, store
|
||
fastapi_app.dependency_overrides.clear()
|
||
|
||
|
||
def _retrieve_with(cosine: float) -> Any:
|
||
doc = _doc("Kubernetes", "k" * 80)
|
||
chunks = [_chunk(doc, cosine)]
|
||
|
||
def retrieve(_db: Any, _question: str, _vec: list[float]) -> list[RetrievedChunk]:
|
||
return chunks
|
||
|
||
return retrieve
|
||
|
||
|
||
def _ask(
|
||
client: TestClient, message: str, image: str | None = None
|
||
) -> list[dict[str, Any]]:
|
||
body: dict[str, Any] = {"message": message}
|
||
if image is not None:
|
||
body["image"] = image
|
||
with client.stream("POST", "/api/chat", json=body) as r:
|
||
assert r.status_code == 200
|
||
frames: list[dict[str, Any]] = []
|
||
buf = ""
|
||
for part in r.iter_text():
|
||
buf += part
|
||
while "\n\n" in buf:
|
||
frame, buf = buf.split("\n\n", 1)
|
||
frame = frame.strip()
|
||
if frame.startswith("data:"):
|
||
frames.append(json.loads(frame.removeprefix("data:").strip()))
|
||
assert buf.strip() == ""
|
||
return frames
|
||
|
||
|
||
def test_image_with_toggle_off_settles_the_hinted_frame_without_any_model_call(
|
||
client: TestClient,
|
||
turn_env: tuple[_FakeSession, _RecordingLLM, Path],
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""LOCKED A5 gate: ``images`` false with an image set → the
|
||
phase-114 error frame with the EXACT detail + hint, ONE frame
|
||
(terminal — no ``done``), NO model call (zero embeds, zero
|
||
requests), NO record (the rejected turn saves nothing)."""
|
||
session, llm, _store = turn_env
|
||
monkeypatch.setattr(
|
||
chat_api,
|
||
"get_settings",
|
||
lambda: Settings(
|
||
_env_file=None, # pyright: ignore[reportCallIssue]
|
||
relevance_threshold=0.30,
|
||
images=False,
|
||
chat_image_dir=str(_store),
|
||
),
|
||
)
|
||
monkeypatch.setattr(chat_api, "retrieve", _retrieve_with(0.90))
|
||
|
||
frames = _ask(client, "What is in this image?", GOOD_PATH)
|
||
|
||
assert [f["type"] for f in frames] == ["error"]
|
||
assert frames[0] == {
|
||
"type": "error",
|
||
"detail": "Image support is turned off on this server.",
|
||
"hint": "Enable BOR_IMAGES in the server's .env (and restart) to ask with an image.",
|
||
}
|
||
assert llm.embedded == [] # NO model call (the embed never ran)
|
||
assert llm.seen == []
|
||
assert session.added == [] # NO query_log row (the error-path convention)
|
||
|
||
|
||
def test_image_with_a_missing_stored_file_settles_the_stale_frame(
|
||
client: TestClient,
|
||
turn_env: tuple[_FakeSession, _RecordingLLM, Path],
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""The stale-path edge (the file was deleted out-of-band): the
|
||
SAME frame shape, a different detail, NO hint (the banner's
|
||
default copy is the honest fallback) — and again NO model call,
|
||
NO record."""
|
||
session, llm, _store = turn_env
|
||
stale = "/api/chat-images/" + "e" * 32 + ".png" # well-formed, absent
|
||
monkeypatch.setattr(chat_api, "retrieve", _retrieve_with(0.90))
|
||
|
||
frames = _ask(client, "What is in this image?", stale)
|
||
|
||
assert [f["type"] for f in frames] == ["error"]
|
||
assert frames[0] == {
|
||
"type": "error",
|
||
"detail": "That image is no longer available.",
|
||
"hint": None,
|
||
}
|
||
assert llm.embedded == []
|
||
assert llm.seen == []
|
||
assert session.added == []
|
||
|
||
|
||
def test_text_only_question_is_byte_identical_with_images_enabled(
|
||
client: TestClient,
|
||
turn_env: tuple[_FakeSession, _RecordingLLM, Path],
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""``image=None`` with the toggle ON: the user message is the PLAIN
|
||
STRING (not a list) — the multimodal branch is inert, and the turn
|
||
completes normally (the toggle only gates image turns)."""
|
||
_session, llm, _store = turn_env
|
||
monkeypatch.setattr(chat_api, "retrieve", _retrieve_with(0.29))
|
||
|
||
frames = _ask(client, "How is my Kubernetes cluster set up?")
|
||
|
||
assert [f["type"] for f in frames][-1] == "done"
|
||
assert llm.seen[0][-1] == {"role": "user", "content": "How is my Kubernetes cluster set up?"}
|
||
|
||
|
||
def test_deflected_image_turn_delivers_the_multimodal_list(
|
||
client: TestClient,
|
||
turn_env: tuple[_FakeSession, _RecordingLLM, Path],
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Construction site 1 (the deflected branch's ``messages`` list):
|
||
the mock client RECEIVES the content list — the text part + the
|
||
image_url part whose data URL decodes to the stored bytes (the
|
||
phase-122 mime map, the shared data-URL helper)."""
|
||
_session, llm, _store = turn_env
|
||
monkeypatch.setattr(chat_api, "retrieve", _retrieve_with(0.29))
|
||
|
||
frames = _ask(client, "What is in this image?", GOOD_PATH)
|
||
|
||
assert [f["type"] for f in frames][-1] == "done"
|
||
assert len(llm.seen) == 1
|
||
user = llm.seen[0][-1]
|
||
assert user["role"] == "user"
|
||
assert user["content"] == [
|
||
{"type": "text", "text": "What is in this image?"},
|
||
{
|
||
"type": "image_url",
|
||
"image_url": {
|
||
"url": f"data:image/png;base64,{base64.b64encode(PNG_1X1).decode('ascii')}"
|
||
},
|
||
},
|
||
]
|
||
|
||
|
||
def test_grounded_image_turn_delivers_the_multimodal_list_through_run_agent(
|
||
client: TestClient,
|
||
turn_env: tuple[_FakeSession, _RecordingLLM, Path],
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Construction site 2 (the grounded branch): ``run_agent`` builds
|
||
its own ``[system, *history, user]`` from the value chat.py passes
|
||
(the pinned flow) — the mock receives the SAME multimodal list,
|
||
with the tools offered (the agent loop ran)."""
|
||
_session, llm, _store = turn_env
|
||
monkeypatch.setattr(chat_api, "retrieve", _retrieve_with(0.90))
|
||
|
||
frames = _ask(client, "What is in this image?", GOOD_PATH)
|
||
|
||
assert frames[-1]["type"] == "done"
|
||
assert frames[-1]["deflected"] is False
|
||
assert len(llm.seen) == 1
|
||
user = llm.seen[0][-1]
|
||
assert user["role"] == "user"
|
||
assert user["content"] == [
|
||
{"type": "text", "text": "What is in this image?"},
|
||
{
|
||
"type": "image_url",
|
||
"image_url": {
|
||
"url": f"data:image/png;base64,{base64.b64encode(PNG_1X1).decode('ascii')}"
|
||
},
|
||
},
|
||
]
|
||
assert llm.seen[0][0]["role"] == "system"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Frontend source pins (tasks 02 + 03) — the house-style source
|
||
# assertions (see tests/unit/test_pinned_composer.py): the composer's
|
||
# attach control (hidden by default, config-gated reveal, the A8
|
||
# upload-before-send ordering), the user record's `image` key (the
|
||
# stored PATH — A5: never base64), the ONE bubble-image renderer
|
||
# (live + restore + shared, the onerror degradation), and the redo's
|
||
# text-only re-ask (A7).
|
||
# ---------------------------------------------------------------------------
|
||
|
||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||
INDEX_HTML = FRONTEND / "index.html"
|
||
APP_JS = FRONTEND / "assets" / "app.js"
|
||
SHARED_JS = FRONTEND / "assets" / "shared.js"
|
||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||
|
||
|
||
def _index_html() -> str:
|
||
return INDEX_HTML.read_text(encoding="utf-8")
|
||
|
||
|
||
def _app_js() -> str:
|
||
return APP_JS.read_text(encoding="utf-8")
|
||
|
||
|
||
def _shared_js() -> str:
|
||
return SHARED_JS.read_text(encoding="utf-8")
|
||
|
||
|
||
def _css() -> str:
|
||
return STYLES_CSS.read_text(encoding="utf-8")
|
||
|
||
|
||
def test_the_attach_control_is_hidden_by_default_in_the_markup() -> None:
|
||
"""The default-off contract (A5): the paperclip button, the hidden
|
||
file input, and the preview strip all ship HIDDEN in the static
|
||
markup — with the flag off (the default) nothing is ever revealed,
|
||
so the rendered flag-off DOM is the pre-phase one. The accessible
|
||
names ride aria-label (the SVGs are decorative)."""
|
||
html = _index_html()
|
||
assert (
|
||
'<button type="button" class="attach-btn" id="attach-btn" hidden'
|
||
' aria-label="Attach an image">' in html
|
||
)
|
||
assert (
|
||
'<input type="file" id="attach-file"'
|
||
' accept="image/png,image/jpeg,image/webp,image/gif,image/bmp" hidden>'
|
||
in html
|
||
)
|
||
assert '<div class="attach-preview" id="attach-preview" hidden>' in html
|
||
assert (
|
||
'class="attach-preview-remove" id="attach-remove"'
|
||
' aria-label="Remove the attached image">' in html
|
||
)
|
||
# the thumbnail is decorative — the filename beside it is the
|
||
# readable label (the alt="" ships in the markup).
|
||
assert '<img class="attach-preview-img" alt="">' in html
|
||
|
||
|
||
def test_the_attach_reveal_is_gated_on_the_config_flag() -> None:
|
||
"""The reveal reads `images` from the boot /api/config — the brand
|
||
boot's ONE config request (no second round-trip). The `!== true`
|
||
guard degrades quietly: off (the default), a missing key, or an
|
||
unanswered fetch all leave the button hidden for good, and the API
|
||
contract still enforces the toggle server-side."""
|
||
js = _app_js()
|
||
assert "const bootConfig = await (window.BOR_CONFIG_PROMISE ?? Promise.resolve());" in js
|
||
m = re.search(r"if \(attachBtn\) attachBtn\.hidden = bootConfig\?\.images !== true;", js)
|
||
assert m, "the attach reveal must be gated on the settled config's images flag"
|
||
|
||
|
||
def test_the_client_precheck_matches_the_servers_six_extensions() -> None:
|
||
"""ONE list of the six, on both sides: the client pre-check (the
|
||
file NAME's extension — the accept attribute is advisory) and the
|
||
server's phase-122 frozenset (the upload endpoint's authority).
|
||
The client list must stay the code default's six — a drift here
|
||
would either block a valid pick or waste a round-trip on a 422."""
|
||
js = _app_js()
|
||
m = re.search(r"const ATTACHABLE_IMAGE_EXTENSIONS = \[(.*?)\];", js)
|
||
assert m, "the attachable-extension pre-check list must exist"
|
||
client_set = {tok.strip().strip('"') for tok in m.group(1).split(",")}
|
||
assert client_set == {
|
||
"bmp",
|
||
"gif",
|
||
"jpeg",
|
||
"jpg",
|
||
"png",
|
||
"webp",
|
||
}
|
||
# and it equals the server's default set (dots stripped — the
|
||
# client list is dotless, the server's is dotted).
|
||
server_set = {
|
||
e.lstrip(".")
|
||
for e in Settings(_env_file=None).image_extension_set # pyright: ignore[reportCallIssue]
|
||
}
|
||
assert client_set == server_set
|
||
# the file input's accept attribute names the same six (mime form).
|
||
m = re.search(r'<input type="file" id="attach-file"\s+accept="([^"]+)"', _index_html())
|
||
assert m, "the attach file input must carry the six-mime accept attribute"
|
||
accept_set = {mime.rsplit("/", 1)[-1] for mime in m.group(1).split(",")}
|
||
# image/jpeg appears twice (jpg + jpeg) — the extension SET is the six.
|
||
assert accept_set == {"png", "jpeg", "webp", "gif", "bmp"}
|
||
|
||
|
||
def test_the_upload_happens_before_the_send_and_blocks_on_failure() -> None:
|
||
"""LOCKED A8, ordered in ``handleSend``: the attached File goes to
|
||
``POST /api/chat-images`` as the FIRST await of the send — BEFORE
|
||
the input is cleared and BEFORE ``runTurn`` — and a null path (any
|
||
failure: 413 over-cap, 422 a bad extension, 5xx, network drop)
|
||
RETURNS immediately: the send is BLOCKED, the typed question stays
|
||
exactly where the user left it, and the attachment stays for the
|
||
retry (the banner says what failed)."""
|
||
js = _app_js()
|
||
start = js.index("async function handleSend")
|
||
end = js.index("async function runTurn")
|
||
body = js[start:end]
|
||
upload = body.index("await uploadAttachedImage(attachedImage.file)")
|
||
clear = body.index('input.value = ""')
|
||
turn = body.index("await runTurn(text, { reask: false, image });")
|
||
assert upload < clear < turn, (
|
||
"A8 ordering: the upload must precede the input clear and the turn"
|
||
)
|
||
# the block line sits between the upload and the clear — a null
|
||
# path never reaches the turn, and the double-fire guard means a
|
||
# second submit mid-upload is a no-op (the first owns the send).
|
||
block = body.index("if (path === null) return;")
|
||
assert upload < block < clear
|
||
assert "if (attachUpload) return;" in body
|
||
# the upload endpoint itself (multipart, the session cookie rides
|
||
# the browser) with the phase-114-style out-of-turn banner copy.
|
||
assert 'res = await fetch("/api/chat-images", { method: "POST", body: form });' in js
|
||
assert "Couldn't attach the image — " in js
|
||
assert "Couldn't attach the image — try again." in js
|
||
|
||
|
||
def test_the_user_record_carries_the_stored_path_never_base64() -> None:
|
||
"""A5 at the record level: save point 1 (the user append in
|
||
``runTurn``) joins the ``bor.chat.v1`` record with the `image` key
|
||
ONLY when an attachment exists — and the value is the upload
|
||
response's STORED PATH, never a data URL. The /api/chat body
|
||
follows the same omission rule (a text-only body is byte-identical
|
||
to pre-phase)."""
|
||
js = _app_js()
|
||
assert (
|
||
"conversation.push(\n"
|
||
" image ? { who: \"user\", text, image: image.path } : { who: \"user\", text }\n"
|
||
" );" in js
|
||
)
|
||
# the request body: the key is added ONLY when present.
|
||
m = re.search(
|
||
r"const payload = \{ message: text, history \};\s*\n"
|
||
r"\s*if \(image\) payload\.image = image\.path;",
|
||
js,
|
||
)
|
||
assert m, "the /api/chat body must gain `image` (the path) only when attached"
|
||
# the triple: the PATH for the record + body, the data URL for the
|
||
# LIVE bubble only, the filename for the alt.
|
||
m = re.search(
|
||
r"image = \{\s*\n\s*path,.*\n\s*src: attachedImage\.dataUrl,.*\n"
|
||
r"\s*alt: attachedImage\.name,",
|
||
js,
|
||
)
|
||
assert m, "the image triple must carry path / live data URL / filename"
|
||
|
||
|
||
def test_prior_turns_images_are_never_replayed_and_the_redo_is_text_only() -> None:
|
||
"""LOCKED A7: the `history` entries travel as {who, text,
|
||
thinking} ONLY — a prior turn's image is never re-sent (the text of
|
||
a prior turn stands alone; a 10 MB image per past turn would blow
|
||
every budget). And the redo (``retryLastTurn``) re-asks with
|
||
``reask: true`` and NO image argument — and the upload helper is
|
||
called from EXACTLY ONE site (the plain send's handleSend): a
|
||
re-ask of a question that had an image re-sends ``prev.text``
|
||
only."""
|
||
js = _app_js()
|
||
m = re.search(
|
||
r"const history = conversation\.slice\(0, -1\)\.map\(\(m\) => \(\{(.*?)\}\)\);",
|
||
js,
|
||
re.S,
|
||
)
|
||
assert m, "the history map (prior turns in the body) must exist"
|
||
assert "image" not in m.group(1), "A7: history entries must never carry an image"
|
||
assert "return runTurn(text, { reask: true });" in js
|
||
# the upload is reached ONLY from the plain send's handleSend —
|
||
# the def + the one call site, nowhere else (the redo, the
|
||
# stale-regen, and every other turn path are image-free).
|
||
assert js.count("uploadAttachedImage") == 2
|
||
|
||
|
||
def test_the_live_user_bubble_renders_the_attached_image() -> None:
|
||
"""The live user bubble carries the image through the ONE
|
||
renderer (``attachBubbleImage``): the img is created
|
||
createElement-style (no HTML strings — the house rule), prepends
|
||
the bubble (the attachment is part of the question — it sits
|
||
ABOVE the text), lazy-loads, alt = the filename, and the CSS caps
|
||
it (a tall portrait must not blow the 46rem chat column)."""
|
||
js = _app_js()
|
||
assert "image ? { src: image.src || image.path, alt: image.alt } : null" in js
|
||
m = re.search(r"function attachBubbleImage\(bubble, src, alt\) \{(.*?)\n\}", js, re.S)
|
||
assert m, "the bubble-image renderer must exist"
|
||
body = m.group(1)
|
||
for needle in (
|
||
"img.className = \"msg-image\";",
|
||
"img.alt = alt || \"attached image\";",
|
||
"img.loading = \"lazy\";",
|
||
"bubble.prepend(img);",
|
||
"img.onerror",
|
||
):
|
||
assert needle in body, f"attachBubbleImage must keep: {needle}"
|
||
css_block = re.search(r"\.msg-image \{(.*?)\}", _css(), re.S)
|
||
assert css_block, ".msg-image must exist in styles.css"
|
||
assert "max-width: 100%;" in css_block.group(1)
|
||
assert "max-height: 240px;" in css_block.group(1)
|
||
|
||
|
||
def test_the_restore_renders_the_stored_path_through_the_same_helper() -> None:
|
||
"""Task 03: ``renderStoredMessage``'s USER branch reads ``m.image``
|
||
(the stored PATH — null-safe: pre-phase / text-only records have
|
||
no key at all and render byte-identically, no img) and routes it
|
||
through the SAME ``attachBubbleImage`` helper the live send uses
|
||
(ONE renderer for live + restore — the live bubble passes the data
|
||
URL, the restore passes the path). The load failure degrades IN
|
||
PLACE to the small "image unavailable" line — never a broken
|
||
image icon (the stored file was deleted out-of-band; the record
|
||
keeps its path, the render degrades)."""
|
||
js = _app_js()
|
||
m = re.search(
|
||
r"if \(typeof m\.image === \"string\" && m\.image\) \{\s*\n"
|
||
r"\s*attachBubbleImage\(wrap\.querySelector\(\"\.bubble\"\),"
|
||
r" m\.image, m\.text \|\| \"attached image\"\);",
|
||
js,
|
||
)
|
||
assert m, "the restore's user branch must render m.image through attachBubbleImage"
|
||
assert 'note.textContent = "image unavailable";' in js
|
||
assert "img.replaceWith(note);" in js
|
||
# the degradation's small note is styled (the italic one-liner).
|
||
assert re.search(r"\.msg-image-unavailable \{", _css())
|
||
|
||
|
||
def test_the_shared_page_renders_the_user_image_the_same_way() -> None:
|
||
"""Task 03: the shared page's ``renderSharedMessage`` user branch
|
||
carries the SAME image render (the per-page duplication house
|
||
style: shared.js keeps its own ``addBubbleImage`` copy — the
|
||
.msg-image treatment itself is ONE, styles.css is shared by both
|
||
pages, so the rule appears exactly once). The serve route is
|
||
public (the token is the shared chat's credential — the image is
|
||
part of that content), and the load-failure degradation is
|
||
identical to the chat page."""
|
||
js = _shared_js()
|
||
m = re.search(
|
||
r"if \(typeof m\.image === \"string\" && m\.image\) \{\s*\n"
|
||
r"\s*addBubbleImage\(wrap, m\.image, m\.text \|\| \"attached image\"\);",
|
||
js,
|
||
)
|
||
assert m, "the shared page's user branch must render m.image"
|
||
m = re.search(r"function addBubbleImage\(wrap, src, alt\) \{(.*?)\n\}", js, re.S)
|
||
assert m, "the shared page's bubble-image helper must exist"
|
||
body = m.group(1)
|
||
for needle in (
|
||
"img.className = \"msg-image\";",
|
||
"img.alt = alt || \"attached image\";",
|
||
"img.loading = \"lazy\";",
|
||
"bubble.prepend(img);",
|
||
):
|
||
assert needle in body, f"addBubbleImage must keep: {needle}"
|
||
assert 'note.textContent = "image unavailable";' in js
|
||
assert "img.replaceWith(note);" in js
|
||
# ONE .msg-image rule — both pages share it (no second copy).
|
||
assert len(re.findall(r"\.msg-image \{", _css())) == 1
|
||
|
||
|
||
def test_the_preview_strip_reveals_and_clears_with_the_attachment() -> None:
|
||
"""The preview strip (thumbnail ≤48px + the filename + the remove
|
||
✕): revealed with the data-URL thumbnail + the file NAME as the
|
||
readable label (the thumbnail is decorative, alt=""), and cleared
|
||
with the attachment (the remove button, a fresh pick, the send,
|
||
or a New chat — the strip must not linger into a turn)."""
|
||
js = _app_js()
|
||
m = re.search(r"function showAttachPreview\(\) \{(.*?)\n\}", js, re.S)
|
||
assert m, "the preview reveal helper must exist"
|
||
body = m.group(1)
|
||
for needle in (
|
||
'attachPreview.querySelector("img").src = attachedImage.dataUrl;',
|
||
'attachPreview.querySelector(".attach-preview-name").textContent = attachedImage.name;',
|
||
"attachPreview.hidden = false;",
|
||
):
|
||
assert needle in body, f"showAttachPreview must keep: {needle}"
|
||
m = re.search(r"function clearAttachedImage\(\) \{(.*?)\n\}", js, re.S)
|
||
assert m, "the attachment-clear helper must exist (idempotent)"
|
||
body = m.group(1)
|
||
assert "attachedImage = null;" in body
|
||
assert "attachPreview.hidden = true;" in body
|
||
# the send clears the strip AFTER the user bubble is rendered (the
|
||
# bubble already holds the image; a failed turn keeps both).
|
||
run_start = js.index("async function runTurn")
|
||
region = js[run_start:run_start + 4000]
|
||
push = region.index("conversation.push(")
|
||
clear = region.index("clearAttachedImage();")
|
||
assert push < clear, "the strip must clear only after the user bubble lands"
|
||
# the 44px touch floor + the thumbnail box (styles.css is shared
|
||
# with the shared page — the rules need no second copy there).
|
||
btn_block = re.search(r"\.attach-btn \{(.*?)\}", _css(), re.S)
|
||
assert btn_block and "width: 44px;" in btn_block.group(1)
|
||
assert "min-height: 44px;" in btn_block.group(1)
|
||
thumb_block = re.search(r"\.attach-preview-img \{(.*?)\}", _css(), re.S)
|
||
assert thumb_block and "width: 48px;" in thumb_block.group(1)
|
||
assert "height: 48px;" in thumb_block.group(1)
|
||
|
||
|
||
def test_the_remove_button_clears_the_state_and_refocuses_the_trigger() -> None:
|
||
"""The remove ✕ (or a new selection) clears the state + hides the
|
||
strip, and focus returns to the paperclip trigger (the keyboard
|
||
path stays inside the composer — WCAG 2.1 AA)."""
|
||
js = _app_js()
|
||
m = re.search(
|
||
r'attachRemove\?\.addEventListener\("click", \(\) => \{(.*?)\}\);', js, re.S
|
||
)
|
||
assert m, "the remove button's click binding must exist"
|
||
body = m.group(1)
|
||
assert "clearAttachedImage();" in body
|
||
assert "attachBtn?.focus();" in body
|
||
# a fresh pick re-arms the input (the same file re-picked must
|
||
# fire change again).
|
||
m = re.search(r'attachFile\?\.addEventListener\("change", \(\) => \{(.*?)\n\}\);', js, re.S)
|
||
assert m, "the file input's change binding must exist"
|
||
assert 'attachFile.value = "";' in m.group(1)
|
||
|
||
|
||
def test_the_bad_pick_and_the_reader_failure_keep_the_banner_copy() -> None:
|
||
"""Client-side failure copy (the out-of-turn banner — no state
|
||
change: a previous attachment, if any, survives a bad pick): the
|
||
extension pre-check names the six in prose, and a file whose bytes
|
||
never became readable (evicted mid-pick) gets the same copy as an
|
||
upload failure."""
|
||
js = _app_js()
|
||
m = re.search(
|
||
r'showErrorBanner\(\s*\n\s*"Only PNG, JPEG, WebP, GIF, and BMP'
|
||
r' images can be attached."\s*\);',
|
||
js,
|
||
)
|
||
assert m, "the bad-pick banner copy must name the six in prose"
|
||
m = re.search(r'reader\.onerror = \(\) => \{(.*?)\n \};', js, re.S)
|
||
assert m, "the FileReader's onerror branch must exist"
|
||
assert 'showErrorBanner("Couldn\'t attach the image — try again.");' in m.group(1)
|