phase: 123_chat_image_questions
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/`.
This commit is contained in:
@@ -24,6 +24,7 @@ Requires: podman compose up -d db
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
@@ -799,6 +800,105 @@ def test_public_read_returns_snapshot_without_private_keys(
|
||||
assert body["messages"] == _expect([_user(FIRST_QUESTION), FULL_BRAIN])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 123 (tasks 03/04): the question's attached image — the saved
|
||||
# and shared shape carries the stored PATH (LOCKED A5: never base64),
|
||||
# on the USER record only (the attachment belongs to the question — a
|
||||
# brain record never carries the key). Text-only records stay WITHOUT
|
||||
# the key (absent, never null — the phase-50 byte-identical contract,
|
||||
# pinned by ``_expect`` above; the ``ChatMessage`` wrap serializer does
|
||||
# the dropping, so every surface — the stored JSONB, the admin GET,
|
||||
# the public shared read — inherits it).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: A well-formed stored path (the ``POST /api/chat-images`` response's
|
||||
#: shape — the chat-images router names the file
|
||||
#: ``<uuid4().hex>.<ext>``; the API boundary itself only bounds the
|
||||
#: string to 500 chars, the pattern gate is the chat request's).
|
||||
IMAGE_PATH = "/api/chat-images/" + "a" * 32 + ".png"
|
||||
|
||||
|
||||
def _user_with_image(text: str) -> dict[str, Any]:
|
||||
return {"who": "user", "text": text, "image": IMAGE_PATH}
|
||||
|
||||
|
||||
def test_create_round_trips_the_user_image_path(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""The user record carries the PATH at every boundary: the 201
|
||||
body, the RAW stored JSONB (never base64 — the A5 contract), and
|
||||
the admin GET — and the brain record stays WITHOUT the key.
|
||||
A text-only record in the SAME chat stays key-free too."""
|
||||
r = admin_client.post(
|
||||
"/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
_user(FIRST_QUESTION), # text-only — stays WITHOUT the key
|
||||
_user_with_image("What is in this image?"),
|
||||
{"who": "brain", "text": "A cat, by the look of it."},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert "image" not in body["messages"][0]
|
||||
assert body["messages"][1]["image"] == IMAGE_PATH
|
||||
assert "image" not in body["messages"][2]
|
||||
|
||||
# The RAW stored JSONB (the DB is the durable boundary — the
|
||||
# served bodies could in principle re-derive it): the path, and
|
||||
# NOTHING base64 anywhere in the payload.
|
||||
raw = db.execute(
|
||||
text("SELECT messages FROM saved_chats WHERE id = :id"),
|
||||
{"id": body["id"]},
|
||||
).scalar_one()
|
||||
assert "image" not in raw[0]
|
||||
assert raw[1]["image"] == IMAGE_PATH
|
||||
assert "image" not in raw[2]
|
||||
assert "base64" not in json.dumps(raw)
|
||||
|
||||
got = admin_client.get(f"/api/chats/{body['id']}")
|
||||
assert got.status_code == 200
|
||||
assert "image" not in got.json()["messages"][0]
|
||||
assert got.json()["messages"][1]["image"] == IMAGE_PATH
|
||||
assert "image" not in got.json()["messages"][2]
|
||||
|
||||
|
||||
def test_shared_serve_includes_the_user_image_path(
|
||||
admin_client: TestClient,
|
||||
) -> None:
|
||||
"""The shared view is faithful (task 03's pin): the PUBLIC
|
||||
snapshot's user record carries the image path — the public
|
||||
``messages`` shape already gains the one optional key, so no new
|
||||
shared-shape field exists — and the brain / text-only records
|
||||
stay WITHOUT it. The image bytes themselves ride the public
|
||||
serve route (phase 55 A1 — the image is part of the chat's
|
||||
content, the token is the credential): pinned in
|
||||
``test_chat_api.py`` alongside the upload."""
|
||||
r = admin_client.post(
|
||||
"/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
_user(FIRST_QUESTION), # text-only — stays WITHOUT the key
|
||||
_user_with_image("What is in this image?"),
|
||||
{"who": "brain", "text": "A cat, by the look of it."},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
share_url = _share(admin_client, r.json()["id"])["share_url"]
|
||||
|
||||
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
|
||||
got = anon.get(f"/api{share_url}")
|
||||
assert got.status_code == 200
|
||||
body = got.json()
|
||||
assert set(body) == SHARED_OUT_KEYS # no new shared-shape field
|
||||
messages = body["messages"]
|
||||
assert "image" not in messages[0]
|
||||
assert messages[1]["image"] == IMAGE_PATH
|
||||
assert "image" not in messages[2]
|
||||
|
||||
|
||||
def test_public_read_wrong_and_revoked_tokens_404_with_one_detail(
|
||||
admin_client: TestClient,
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user