feat(chat): save and view chat history — admin-only saved_chats, History page, open-a-chat return
This commit is contained in:
@@ -102,8 +102,9 @@ def test_html_pages_are_no_cache_and_versioned(page: Page, app_url: str) -> None
|
||||
|
||||
|
||||
def test_other_pages_share_the_token(page: Page, app_url: str) -> None:
|
||||
"""/sources.html and /login.html: each document revalidates, and both
|
||||
pages' stylesheet requests carry the same process token."""
|
||||
"""/sources.html, /login.html and /history.html (phase 50): each
|
||||
document revalidates, and all three pages' stylesheet requests carry
|
||||
the same process token."""
|
||||
token = _expected_token()
|
||||
assert token
|
||||
|
||||
@@ -118,7 +119,8 @@ def test_other_pages_share_the_token(page: Page, app_url: str) -> None:
|
||||
|
||||
sources_token = navigate("/sources.html")
|
||||
login_token = navigate("/login.html")
|
||||
assert sources_token == login_token == token
|
||||
history_token = navigate("/history.html") # phase 50: the new page
|
||||
assert sources_token == login_token == history_token == token
|
||||
|
||||
|
||||
def test_api_responses_unaffected(page: Page, app_url: str, db_ready: None) -> None:
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
"""Phase 50 E2E (Playwright): save & view chat history.
|
||||
|
||||
TODO.md L5 (owner 2026-08-29): "Need a way to save and view chat
|
||||
history in a new page, then return to that history with a click"
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_chat_history.py -v --no-cov
|
||||
|
||||
The owner-locked loop under test (A10 extension, 2026-08-29):
|
||||
|
||||
* **Save** — on the chat page, admin-only (the pill ships hidden and
|
||||
whoami reveals it): the current conversation POSTs to ``/api/chats``
|
||||
(auto-title = the first question, whitespace-collapsed, 120-char cap)
|
||||
and links to the created row; a re-Save PUTs the SAME row (upsert);
|
||||
"New chat" unlinks, so the next Save creates again;
|
||||
* **History** — ``/history.html`` lists the saved chats in a full-width
|
||||
table (Title | Messages | Updated | Actions); the Title cell IS the
|
||||
Open link (``/?chat=<id>`` — "return to that history with a click"),
|
||||
and Delete is the inline two-step confirm (owner-locked: no native
|
||||
confirm dialog — a real ``window.confirm`` would hang Playwright, so
|
||||
the inline pair appearing is itself pinned);
|
||||
* **Open** — ``/?chat=<id>`` (valid uuid + admin) boots into the saved
|
||||
conversation through the SAME restore path as the phase-14 local
|
||||
session (pixel-identical), links it, and a subsequent Save updates
|
||||
that row; a deleted/unknown id degrades to the local restore with the
|
||||
error banner;
|
||||
* **Anonymous** — no Save button, no History nav link, the History page
|
||||
shows the gated state WITHOUT ever fetching ``/api/chats`` (the router
|
||||
403s them — pinned via the request log), and the API 403s.
|
||||
|
||||
DB isolation: the shared e2e Postgres keeps ``saved_chats`` rows across
|
||||
suites, so every test here uses a DISTINCTIVE question text (its
|
||||
auto-title is therefore unique), never asserts on absolute row counts,
|
||||
and deletes the rows it creates in a ``finally`` (admin cookie). The KB
|
||||
tables are truncated + re-seeded the house way (deterministic mock
|
||||
embeddings); ``saved_chats`` is never touched by the reset.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from playwright.sync_api import Page, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import SessionLocal
|
||||
from app.rag.importer import ImportSummary, import_sources
|
||||
from app.rag.llm import LLMClient
|
||||
from e2e.auth_helpers import login
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||||
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||||
#: Phase 10 viewer URL + phase 13 back=/ (the restored chip must be
|
||||
#: byte-identical to the live-rendered one).
|
||||
CHIP_HREF = "/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F"
|
||||
|
||||
|
||||
async def _import_fixtures(mock_port: int) -> ImportSummary:
|
||||
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
|
||||
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||||
return await import_sources([FIXTURES], LLMClient(settings))
|
||||
|
||||
|
||||
def _run_in_thread(coro: Any) -> Any:
|
||||
"""Run a coroutine on a worker thread.
|
||||
|
||||
Playwright's sync API keeps an asyncio loop running on the test thread,
|
||||
so ``asyncio.run`` cannot be called directly from a test body.
|
||||
"""
|
||||
box: dict[str, Any] = {}
|
||||
|
||||
def runner() -> None:
|
||||
try:
|
||||
box["value"] = asyncio.run(coro)
|
||||
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
|
||||
box["error"] = e
|
||||
|
||||
t = Thread(target=runner)
|
||||
t.start()
|
||||
t.join()
|
||||
if "error" in box:
|
||||
raise box["error"]
|
||||
return box["value"]
|
||||
|
||||
|
||||
def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
|
||||
"""Truncate the KB (and query log + steering notes — deterministic
|
||||
mock answers), then optionally re-import fixtures. ``saved_chats``
|
||||
is deliberately NOT touched: rows persist across suites and every
|
||||
test here cleans up after itself."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text("TRUNCATE chunks, documents, query_log, steering_notes")
|
||||
)
|
||||
db.commit()
|
||||
if not seed:
|
||||
return None
|
||||
return _run_in_thread(_import_fixtures(mock_port))
|
||||
|
||||
|
||||
def _ask(page: Page, question: str) -> None:
|
||||
"""Send one turn and wait until the grounded answer has fully
|
||||
landed (the ``done`` event restored the Send button)."""
|
||||
page.fill("#message-input", question)
|
||||
page.click("#send-btn")
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
|
||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(
|
||||
MOCK_ANSWER_MARKER, timeout=30_000
|
||||
)
|
||||
expect(page.locator("#send-btn")).to_be_enabled()
|
||||
expect(page.locator("#send-label")).to_have_text("Send")
|
||||
|
||||
|
||||
def _admin_cookies(page: Page) -> dict[str, str]:
|
||||
"""The signed session cookies the browser holds after a form login —
|
||||
used to call the admin API with plain httpx (the test's API side
|
||||
sees exactly what the signed-in browser sees)."""
|
||||
return {
|
||||
c["name"]: c["value"]
|
||||
for c in page.context.cookies()
|
||||
if "name" in c and "value" in c
|
||||
}
|
||||
|
||||
|
||||
def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]:
|
||||
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
|
||||
assert r.status_code == 200
|
||||
return r.json()["chats"]
|
||||
|
||||
|
||||
def _find_row(
|
||||
rows: list[dict[str, Any]], title: str
|
||||
) -> dict[str, Any] | None:
|
||||
return next((c for c in rows if c["title"] == title), None)
|
||||
|
||||
|
||||
def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
|
||||
"""Best-effort row cleanup (a 404 — already deleted — is fine)."""
|
||||
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
|
||||
|
||||
|
||||
def _save(page: Page) -> None:
|
||||
"""Press Save and wait for the live-region confirmation (the
|
||||
never-stale contract: the status line is the success feedback)."""
|
||||
page.locator("#save-chat-btn").click()
|
||||
expect(page.locator("#send-status")).to_have_text("Conversation saved.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Save on the chat page → the row exists (UI + API agree)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_and_see_history(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=True)
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
login(page, app_url, next="/")
|
||||
expect(page).to_have_url(app_url + "/", timeout=30_000)
|
||||
|
||||
q = "How is my Kubernetes cluster set up? (hist-save)"
|
||||
_ask(page, q)
|
||||
|
||||
# Admin: the Save pill is revealed (ship-hidden, whoami reveals it).
|
||||
save = page.locator("#save-chat-btn")
|
||||
expect(save).to_be_visible()
|
||||
expect(save).to_have_attribute("aria-label", "Save chat")
|
||||
|
||||
save.click()
|
||||
expect(page.locator("#send-status")).to_have_text("Conversation saved.")
|
||||
|
||||
cookies = _admin_cookies(page)
|
||||
created: str | None = None
|
||||
try:
|
||||
# The API agrees: the row exists, auto-titled from the first
|
||||
# question (whitespace-collapsed, <=120 chars), two messages.
|
||||
row = _find_row(_chats(app_url, cookies), " ".join(q.split())[:120])
|
||||
assert row is not None, "the saved chat row must exist"
|
||||
assert row["message_count"] == 2
|
||||
created = row["id"]
|
||||
|
||||
# The History page shows it: the row for THIS chat carries the
|
||||
# auto-title and the message count.
|
||||
page.goto(app_url + "/history.html")
|
||||
link = page.locator(f"#history-tbody a[href='/?chat={created}']")
|
||||
expect(link).to_be_visible(timeout=15_000)
|
||||
expect(link).to_have_text(" ".join(q.split())[:120])
|
||||
row_tr = page.locator(
|
||||
"#history-tbody tr", has=page.locator(f"a[href='/?chat={created}']")
|
||||
)
|
||||
expect(row_tr.locator(".history-count-cell")).to_have_text("2")
|
||||
finally:
|
||||
if created is not None:
|
||||
_delete_chat(app_url, cookies, created)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. History → click the title → back in the saved conversation; the
|
||||
# conversation continues and a re-Save updates the SAME row
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_open_chat_returns_to_history(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=True)
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
login(page, app_url, next="/")
|
||||
expect(page).to_have_url(app_url + "/", timeout=30_000)
|
||||
|
||||
q = "How is my Kubernetes cluster set up? (hist-open)"
|
||||
_ask(page, q)
|
||||
# The answer text the History session saw (rendered bubble).
|
||||
answer_before = page.locator(".msg.brain .bubble").first.inner_text()
|
||||
|
||||
_save(page)
|
||||
cookies = _admin_cookies(page)
|
||||
row = _find_row(_chats(app_url, cookies), q)
|
||||
assert row is not None
|
||||
chat_id = row["id"]
|
||||
try:
|
||||
# From the History page, the title IS the Open link…
|
||||
page.goto(app_url + "/history.html")
|
||||
link = page.locator(f"#history-tbody a[href='/?chat={chat_id}']")
|
||||
expect(link).to_be_visible(timeout=15_000)
|
||||
|
||||
doc_requests: list[str] = []
|
||||
page.on(
|
||||
"request",
|
||||
lambda r: doc_requests.append(r.url)
|
||||
if r.resource_type == "document"
|
||||
else None,
|
||||
)
|
||||
link.click()
|
||||
|
||||
# …and the click navigates to /?chat=<uuid> ("return to that
|
||||
# history with a click"). app.js then normalizes the one-shot
|
||||
# ?chat= param back to /, so the navigation target itself is
|
||||
# what gets pinned here.
|
||||
assert any(u == app_url + "/?chat=" + chat_id for u in doc_requests), (
|
||||
f"the title link must navigate to /?chat={chat_id}: {doc_requests}"
|
||||
)
|
||||
|
||||
# The chat rendered the saved conversation…
|
||||
expect(page.locator(".msg.user .bubble")).to_have_count(1)
|
||||
expect(page.locator(".msg.user .bubble")).to_contain_text(q)
|
||||
bubble = page.locator(".msg.brain .bubble").first
|
||||
expect(bubble).to_contain_text(q)
|
||||
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER)
|
||||
# …the SAME answer text the History session saw (pixel-identical
|
||||
# restore through renderStoredMessage)…
|
||||
assert bubble.inner_text() == answer_before
|
||||
# …with its source chip restored byte-identically.
|
||||
chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
|
||||
expect(chip).to_have_count(1)
|
||||
expect(chip.first).to_have_attribute("href", CHIP_HREF)
|
||||
|
||||
# The conversation continues: a new turn streams fine…
|
||||
_ask(page, "How is my Kubernetes cluster set up? (hist-open-2)")
|
||||
expect(page.locator(".msg.user .bubble")).to_have_count(2)
|
||||
|
||||
# …and a re-Save UPSERTS: the same single row, count grown to 4.
|
||||
_save(page)
|
||||
mine = [c for c in _chats(app_url, cookies) if c["title"] == q]
|
||||
assert len(mine) == 1, "the re-Save must not spawn a second row"
|
||||
assert mine[0]["id"] == chat_id, "the re-Save updates the SAME row"
|
||||
assert mine[0]["message_count"] == 4
|
||||
finally:
|
||||
_delete_chat(app_url, cookies, chat_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. "New chat" unlinks: the next Save is a fresh create, not an update
|
||||
# of the previously saved conversation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_new_chat_unlinks(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=True)
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
login(page, app_url, next="/")
|
||||
expect(page).to_have_url(app_url + "/", timeout=30_000)
|
||||
|
||||
q1 = "How is my Kubernetes cluster set up? (hist-unlink)"
|
||||
_ask(page, q1)
|
||||
_save(page)
|
||||
cookies = _admin_cookies(page)
|
||||
cleanup: list[str] = []
|
||||
try:
|
||||
row1 = _find_row(_chats(app_url, cookies), q1)
|
||||
assert row1 is not None
|
||||
cleanup.append(row1["id"])
|
||||
|
||||
# New chat clears the conversation AND unlinks it from the row.
|
||||
page.locator("#new-chat-btn").click()
|
||||
expect(page.locator("#send-status")).to_contain_text("New chat started")
|
||||
expect(page.locator(".msg")).to_have_count(0)
|
||||
|
||||
# A fresh conversation, saved: a NEW row (a create, not the
|
||||
# previous row's update) — the list now carries two of ours.
|
||||
q2 = "How is my Kubernetes cluster set up? (hist-unlink-2)"
|
||||
_ask(page, q2)
|
||||
_save(page)
|
||||
rows = _chats(app_url, cookies)
|
||||
mine = [c for c in rows if c["title"] in (q1, q2)]
|
||||
assert len(mine) == 2, "Save after New chat must create a second row"
|
||||
a = _find_row(rows, q1)
|
||||
b = _find_row(rows, q2)
|
||||
assert a is not None and b is not None
|
||||
assert a["id"] != b["id"], "the fresh Save must not reuse the old row"
|
||||
assert a["message_count"] == 2, "the unlinked conversation was untouched"
|
||||
cleanup.append(b["id"])
|
||||
finally:
|
||||
for chat_id in cleanup:
|
||||
_delete_chat(app_url, cookies, chat_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Delete: the inline two-step confirm (no native dialog), the row is
|
||||
# gone (the API 404s), and /?chat=<id> degrades to the local restore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_delete_two_step(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=True)
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
login(page, app_url, next="/")
|
||||
expect(page).to_have_url(app_url + "/", timeout=30_000)
|
||||
|
||||
q = "How is my Kubernetes cluster set up? (hist-delete)"
|
||||
_ask(page, q)
|
||||
_save(page)
|
||||
cookies = _admin_cookies(page)
|
||||
row = _find_row(_chats(app_url, cookies), q)
|
||||
assert row is not None
|
||||
chat_id = row["id"]
|
||||
try:
|
||||
page.goto(app_url + "/history.html")
|
||||
tr = page.locator(
|
||||
"#history-tbody tr", has=page.locator(f"a[href='/?chat={chat_id}']")
|
||||
)
|
||||
expect(tr.locator("a.history-title-link")).to_be_visible(timeout=15_000)
|
||||
|
||||
# Step 1: the Delete button swaps, IN PLACE, for the "Delete?
|
||||
# [Yes] [No]" pair — a real window.confirm would hang Playwright
|
||||
# here, so the pair appearing is the pinned contract.
|
||||
tr.locator("button.history-delete").click()
|
||||
expect(tr.locator(".history-confirm-yes")).to_be_visible()
|
||||
expect(tr.locator(".history-confirm-no")).to_be_visible()
|
||||
|
||||
# "No" cancels: the pair is gone, the Delete button returns, the
|
||||
# row stays.
|
||||
tr.locator(".history-confirm-no").click()
|
||||
expect(tr.locator(".history-confirm-yes")).to_have_count(0)
|
||||
expect(tr.locator("button.history-delete")).to_be_visible()
|
||||
expect(page.locator(f"#history-tbody a[href='/?chat={chat_id}']")).to_have_count(1)
|
||||
|
||||
# Step 2: Delete → "Yes" removes the row + the live-region line.
|
||||
tr.locator("button.history-delete").click()
|
||||
tr.locator(".history-confirm-yes").click()
|
||||
expect(page.locator(f"#history-tbody a[href='/?chat={chat_id}']")).to_have_count(0)
|
||||
expect(page.locator("#history-status")).to_contain_text(f'Deleted "{q}".')
|
||||
|
||||
# The API agrees: the id is unknown now.
|
||||
r = httpx.get(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
|
||||
assert r.status_code == 404
|
||||
|
||||
# Degradation: /?chat=<deleted id> shows the error banner and
|
||||
# falls through to the local restore (this context's
|
||||
# localStorage still holds the conversation from the ask above).
|
||||
page.goto(app_url + f"/?chat={chat_id}")
|
||||
banner = page.locator("#kb-banner")
|
||||
expect(banner).to_be_visible(timeout=15_000)
|
||||
expect(banner).to_contain_text("That saved chat isn't available")
|
||||
expect(page.locator(".msg.user .bubble")).to_contain_text(q)
|
||||
expect(page.locator(".msg.brain .bubble").first).to_contain_text(
|
||||
MOCK_ANSWER_MARKER, timeout=15_000
|
||||
)
|
||||
finally:
|
||||
# No-op when the delete above succeeded (the 404 is handled).
|
||||
_delete_chat(app_url, cookies, chat_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Anonymous: no Save button, no History nav link, the History page is
|
||||
# gated WITHOUT fetching /api/chats, and the API 403s
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_anonymous_cannot(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=True)
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
# A fresh context is anonymous by construction (no login).
|
||||
requests: list[str] = []
|
||||
page.on("request", lambda r: requests.append(r.url))
|
||||
|
||||
page.goto(app_url + "/")
|
||||
# Settled anonymous state (the whoami round-trip has landed)…
|
||||
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
|
||||
# …and the phase-50 surface is absent for anonymous: no Save pill,
|
||||
# no History nav link (both ship hidden and stay hidden).
|
||||
expect(page.locator("#save-chat-btn")).to_be_hidden()
|
||||
expect(page.locator("#nav-history")).to_be_hidden()
|
||||
|
||||
# Direct visit to the History page: it loads and shows the gated
|
||||
# state (the table wrapped away) — and NEVER calls /api/chats (the
|
||||
# router 403s anonymous, so the page must not even try).
|
||||
page.goto(app_url + "/history.html")
|
||||
expect(page.locator("#history-gate")).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#history-table-wrap")).to_be_hidden()
|
||||
assert not any("/api/chats" in u for u in requests), (
|
||||
f"the anonymous History page must not fetch /api/chats: {requests}"
|
||||
)
|
||||
|
||||
# And the API gate itself: 403 without the admin cookie.
|
||||
r = httpx.get(f"{app_url}/api/chats", timeout=10)
|
||||
assert r.status_code == 403
|
||||
@@ -18,9 +18,11 @@ sources, document viewer, global tuning, login): one shared markup block
|
||||
|
||||
Per role, the VISIBLE inventory:
|
||||
|
||||
* admin: brand + nav [Chat, #nav-sources, #nav-git-sources, #nav-tuning]
|
||||
(four links, that order — the "Sources" link joined in phase 35 as
|
||||
"Git sources", owner permission 2026-08-26) + #sign-out-btn (with
|
||||
* admin: brand + nav [Chat, #nav-sources, #nav-git-sources, #nav-tuning,
|
||||
#nav-history] (five links, that order — the "Sources" link joined in
|
||||
phase 35 as "Git sources", owner permission 2026-08-26; the History
|
||||
link joined in phase 50, owner permission 2026-08-29) + #sign-out-btn
|
||||
(with
|
||||
#sign-in-link
|
||||
hidden) — on all five pages, same id+class inventory, same DOM order.
|
||||
The #sync-btn (Sources page only) and the #new-chat-btn (chat page
|
||||
@@ -29,7 +31,7 @@ Per role, the VISIBLE inventory:
|
||||
exactly those two); the #steering-toggle was removed from the navbar
|
||||
the same day; note management lives on /tuning.html;
|
||||
* anonymous: brand + nav [Chat] (#nav-sources / #nav-git-sources /
|
||||
#nav-tuning hidden — locked A10 UI revision) + #sign-in-link (with
|
||||
#nav-tuning / #nav-history hidden — locked A10 UI revision) + #sign-in-link (with
|
||||
#sign-out-btn hidden; the Sources page's #sync-btn stays ship-hidden)
|
||||
on all five pages — and the steering toggle (removed at owner
|
||||
request, 2026-08-28) + panel are ABSENT from the DOM (the panel via
|
||||
@@ -233,6 +235,9 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
|
||||
# RAG and Tuning.
|
||||
expect(page.locator("#nav-git-sources")).to_be_visible()
|
||||
expect(page.locator("#nav-tuning")).to_be_visible()
|
||||
# Phase 50: the FIFTH admin-only nav link (History) is revealed
|
||||
# on every page, after Tuning.
|
||||
expect(page.locator("#nav-history")).to_be_visible()
|
||||
# The steering toggle was removed from the navbar at owner
|
||||
# request (2026-08-28) — absent on every page, admin included.
|
||||
assert page.locator("#steering-toggle").count() == 0, (
|
||||
@@ -255,6 +260,9 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
|
||||
expect(page.locator("#nav-sources")).to_be_hidden()
|
||||
expect(page.locator("#nav-git-sources")).to_be_hidden()
|
||||
expect(page.locator("#nav-tuning")).to_be_hidden()
|
||||
# Phase 50: the History link ships hidden and stays hidden for
|
||||
# anonymous (the same A10 UI revision).
|
||||
expect(page.locator("#nav-history")).to_be_hidden()
|
||||
expect(page.locator("#sync-btn")).to_be_hidden()
|
||||
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
||||
expect(page.locator("#sign-in-link")).to_be_visible()
|
||||
@@ -323,6 +331,9 @@ def _admin_login_page_inventory(page: Page, app_url: str) -> list[str]:
|
||||
expect(page.locator("#nav-sources")).to_be_visible()
|
||||
expect(page.locator("#nav-git-sources")).to_be_visible()
|
||||
expect(page.locator("#nav-tuning")).to_be_visible()
|
||||
# Phase 50: the History link joins the admin bar on the login
|
||||
# page too (the one-bar contract).
|
||||
expect(page.locator("#nav-history")).to_be_visible()
|
||||
assert page.locator("#steering-toggle").count() == 0, (
|
||||
"login: the steering toggle was removed from the navbar"
|
||||
)
|
||||
|
||||
@@ -88,6 +88,7 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
|
||||
("/login.html", "Sign in"), # phase 16: admin sign-in page
|
||||
("/tuning.html", "Global Tuning"), # phase 27: global tuning page
|
||||
("/git-sources.html", "Git sources"), # phase 35: admin git sources page
|
||||
("/history.html", "Saved chats"), # phase 50: admin saved-chats page
|
||||
],
|
||||
)
|
||||
def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> None:
|
||||
@@ -124,7 +125,8 @@ def test_index_page_no_cache_with_versioned_asset_refs(client) -> None:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/sources.html", "/document.html", "/login.html", "/tuning.html", "/git-sources.html"],
|
||||
["/sources.html", "/document.html", "/login.html", "/tuning.html",
|
||||
"/git-sources.html", "/history.html"], # phase 50: + the History page
|
||||
)
|
||||
def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
|
||||
"""Each of the other four pages revalidates and carries at least one
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
"""Integration: saved-chat CRUD (phase 50, task 02) — the ``/api/chats``
|
||||
contract.
|
||||
|
||||
Real Postgres (``podman compose up -d db``). The router sits behind the
|
||||
phase-16 ``require_admin`` gate exactly like ``/api/steering`` (the
|
||||
house pattern of ``test_steering_api.py``): anonymous callers get 403 on
|
||||
every route; the admin CRUD exercises the auto-title convention (first
|
||||
user message, whitespace-collapsed, 120-char cap + the no-user-message
|
||||
fallback), the list order (``updated_at desc, id desc``), the
|
||||
full-payload round-trip (a ``bor.chat.v1``-shaped brain record carrying
|
||||
``sources``/``thinking``/``tools``/``stopped`` survives losslessly),
|
||||
the PUT upsert semantics (replacement + title-keep + title-set +
|
||||
``updated_at`` bump), and the delete 404/204.
|
||||
|
||||
Requires: podman compose up -d db
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import SavedChat
|
||||
|
||||
FIRST_QUESTION = "How did I install gitlab?"
|
||||
EXPLICIT_TITLE = "My backup notes"
|
||||
|
||||
#: A full ``bor.chat.v1`` brain record (phase 14 shape) — every optional
|
||||
#: key present; the round-trip test asserts it survives byte-identical.
|
||||
FULL_BRAIN: dict[str, Any] = {
|
||||
"who": "brain",
|
||||
"text": "Your k3s cluster runs on three nodes — you've got this.",
|
||||
"sources": [
|
||||
{"source": "Homelab", "path": "kubernetes.md", "title": "Kubernetes Cluster"}
|
||||
],
|
||||
"deflected": False,
|
||||
"suggestions": ["What ports does Traefik expose?"],
|
||||
"thinking": "The kubernetes doc covers the cluster layout…",
|
||||
"tools": [
|
||||
{"name": "read_document", "argument": "Homelab/kubernetes.md"},
|
||||
{"name": "list_documents", "argument": None},
|
||||
],
|
||||
"stopped": False,
|
||||
}
|
||||
|
||||
OUT_KEYS = {"id", "title", "created_at", "updated_at", "message_count", "messages"}
|
||||
ROW_KEYS = {"id", "title", "updated_at", "message_count"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_chats(db: Session) -> Iterator[None]:
|
||||
"""``saved_chats`` is global state: reset around every test."""
|
||||
db.execute(text("TRUNCATE saved_chats"))
|
||||
db.commit()
|
||||
yield
|
||||
db.execute(text("TRUNCATE saved_chats"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _user(text: str) -> dict[str, Any]:
|
||||
return {"who": "user", "text": text}
|
||||
|
||||
|
||||
def _simple_conversation() -> list[dict[str, Any]]:
|
||||
return [_user(FIRST_QUESTION), {"who": "brain", "text": "You've got this!"}]
|
||||
|
||||
|
||||
def _expect(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""The stored shape of a record list (see app/api/chats.py):
|
||||
every record carries all ``bor.chat.v1`` keys, explicit nulls where
|
||||
an optional key does not apply (the restore path is null-safe).
|
||||
A record that already carries every key (``FULL_BRAIN``) is
|
||||
unchanged by this."""
|
||||
return [
|
||||
{
|
||||
"who": m["who"],
|
||||
"text": m["text"],
|
||||
"sources": m.get("sources"),
|
||||
"deflected": m.get("deflected"),
|
||||
"suggestions": m.get("suggestions"),
|
||||
"thinking": m.get("thinking"),
|
||||
"tools": m.get("tools"),
|
||||
"stopped": m.get("stopped"),
|
||||
}
|
||||
for m in records
|
||||
]
|
||||
|
||||
|
||||
def _assert_no_chats(admin_client: TestClient) -> None:
|
||||
assert admin_client.get("/api/chats").json() == {"chats": []}
|
||||
|
||||
|
||||
# ---------- anonymous: 403 on every route (phase 16 gate) ----------
|
||||
|
||||
|
||||
def test_anonymous_every_route_returns_403(client: TestClient) -> None:
|
||||
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
|
||||
unknown = uuid.uuid4()
|
||||
cases = [
|
||||
("GET", "/api/chats", None),
|
||||
("POST", "/api/chats", {"messages": _simple_conversation()}),
|
||||
("GET", f"/api/chats/{unknown}", None),
|
||||
("PUT", f"/api/chats/{unknown}", {"messages": _simple_conversation()}),
|
||||
("DELETE", f"/api/chats/{unknown}", None),
|
||||
]
|
||||
for method, path, body in cases:
|
||||
r = anon.request(method, path, json=body)
|
||||
assert r.status_code == 403, f"{method} {path} must be 403 for anonymous"
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
|
||||
|
||||
# ---------- create ----------
|
||||
|
||||
|
||||
def test_create_returns_201_and_auto_titles(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
r = admin_client.post("/api/chats", json={"messages": _simple_conversation()})
|
||||
|
||||
assert r.status_code == 201
|
||||
body = r.json()
|
||||
assert set(body) == OUT_KEYS
|
||||
assert body["title"] == FIRST_QUESTION # auto-title = first user message
|
||||
assert body["message_count"] == 2
|
||||
assert body["messages"] == _expect(_simple_conversation())
|
||||
uuid.UUID(body["id"]) # valid UUID
|
||||
# Fresh row: nothing has updated it, so both stamps agree.
|
||||
assert body["created_at"] and body["updated_at"]
|
||||
assert abs(
|
||||
datetime.fromisoformat(body["created_at"])
|
||||
- datetime.fromisoformat(body["updated_at"])
|
||||
).total_seconds() < 5
|
||||
rows = db.scalars(select(SavedChat)).all()
|
||||
assert [row.title for row in rows] == [FIRST_QUESTION]
|
||||
|
||||
|
||||
def test_create_auto_title_collapses_whitespace_and_truncates_to_120(
|
||||
admin_client: TestClient,
|
||||
) -> None:
|
||||
long_text = "How did I install " + "x" * 200
|
||||
r = admin_client.post(
|
||||
"/api/chats", json={"messages": [_user(long_text), {"who": "brain", "text": "ok"}]}
|
||||
)
|
||||
assert r.status_code == 201
|
||||
assert len(r.json()["title"]) == 120
|
||||
assert r.json()["title"] == long_text[:120]
|
||||
|
||||
# Multi-space / tab / newline runs collapse to single spaces.
|
||||
r = admin_client.post(
|
||||
"/api/chats", json={"messages": [_user("What is\nmy\tTraefik port?")]}
|
||||
)
|
||||
assert r.status_code == 201
|
||||
assert r.json()["title"] == "What is my Traefik port?"
|
||||
|
||||
|
||||
def test_create_honors_explicit_title(admin_client: TestClient) -> None:
|
||||
r = admin_client.post(
|
||||
"/api/chats",
|
||||
json={"title": f" {EXPLICIT_TITLE} ", "messages": _simple_conversation()},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
assert r.json()["title"] == EXPLICIT_TITLE # trimmed, not auto-titled
|
||||
|
||||
|
||||
def test_create_blank_title_falls_back_to_auto_title(admin_client: TestClient) -> None:
|
||||
r = admin_client.post(
|
||||
"/api/chats", json={"title": " \t\n ", "messages": _simple_conversation()}
|
||||
)
|
||||
assert r.status_code == 201
|
||||
assert r.json()["title"] == FIRST_QUESTION
|
||||
|
||||
|
||||
def test_create_without_user_message_falls_back_to_chat_id(admin_client: TestClient) -> None:
|
||||
# Defensive — the UI cannot produce a conversation with no user
|
||||
# message; the auto-title then names the row after its own id.
|
||||
r = admin_client.post(
|
||||
"/api/chats", json={"messages": [{"who": "brain", "text": "hello"}]}
|
||||
)
|
||||
assert r.status_code == 201
|
||||
body = r.json()
|
||||
assert body["title"] == f"Chat {body['id'][:8]}"
|
||||
|
||||
|
||||
def test_create_round_trips_full_brain_record(admin_client: TestClient) -> None:
|
||||
r = admin_client.post(
|
||||
"/api/chats", json={"messages": [_user(FIRST_QUESTION), FULL_BRAIN]}
|
||||
)
|
||||
assert r.status_code == 201
|
||||
# The bor.chat.v1-shaped payload round-trips losslessly: every
|
||||
# optional key (sources/deflected/suggestions/thinking/tools/
|
||||
# stopped) survives identical.
|
||||
assert r.json()["messages"][1] == FULL_BRAIN
|
||||
|
||||
|
||||
def test_create_rejects_empty_messages(admin_client: TestClient) -> None:
|
||||
assert admin_client.post("/api/chats", json={"messages": []}).status_code == 422
|
||||
_assert_no_chats(admin_client)
|
||||
|
||||
|
||||
def test_create_rejects_unknown_who(admin_client: TestClient) -> None:
|
||||
r = admin_client.post(
|
||||
"/api/chats", json={"messages": [{"who": "alien", "text": "hi"}]}
|
||||
)
|
||||
assert r.status_code == 422
|
||||
_assert_no_chats(admin_client)
|
||||
|
||||
|
||||
def test_create_rejects_empty_text(admin_client: TestClient) -> None:
|
||||
assert (
|
||||
admin_client.post("/api/chats", json={"messages": [_user("")]})
|
||||
).status_code == 422
|
||||
_assert_no_chats(admin_client)
|
||||
|
||||
|
||||
def test_create_rejects_extra_message_keys(admin_client: TestClient) -> None:
|
||||
# A corrupted / HTML-shaped payload must not cross the boundary.
|
||||
message = _user(FIRST_QUESTION)
|
||||
message["html"] = "<b>not allowed</b>"
|
||||
assert admin_client.post("/api/chats", json={"messages": [message]}).status_code == 422
|
||||
_assert_no_chats(admin_client)
|
||||
|
||||
|
||||
def test_create_rejects_title_over_500(admin_client: TestClient) -> None:
|
||||
assert (
|
||||
admin_client.post(
|
||||
"/api/chats", json={"title": "t" * 501, "messages": _simple_conversation()}
|
||||
)
|
||||
).status_code == 422
|
||||
|
||||
|
||||
# ---------- list ----------
|
||||
|
||||
|
||||
def test_list_empty(admin_client: TestClient) -> None:
|
||||
r = admin_client.get("/api/chats")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"chats": []}
|
||||
|
||||
|
||||
def test_list_orders_by_updated_at_desc(admin_client: TestClient, db: Session) -> None:
|
||||
base = datetime.now(UTC)
|
||||
db.add_all(
|
||||
[
|
||||
SavedChat(
|
||||
title="oldest",
|
||||
messages=[_user("one")],
|
||||
updated_at=base,
|
||||
),
|
||||
SavedChat(
|
||||
title="newest",
|
||||
messages=[_user("two"), _user("three")],
|
||||
updated_at=base + timedelta(hours=2),
|
||||
),
|
||||
SavedChat(
|
||||
title="middle",
|
||||
messages=[_user("four")],
|
||||
updated_at=base + timedelta(hours=1),
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
r = admin_client.get("/api/chats")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert [c["title"] for c in body["chats"]] == ["newest", "middle", "oldest"]
|
||||
for c in body["chats"]:
|
||||
assert set(c) == ROW_KEYS
|
||||
uuid.UUID(c["id"])
|
||||
assert "messages" not in c # no payloads in the list
|
||||
|
||||
|
||||
def test_list_reports_message_count(admin_client: TestClient) -> None:
|
||||
admin_client.post("/api/chats", json={"messages": _simple_conversation()})
|
||||
body = admin_client.get("/api/chats").json()
|
||||
assert [c["message_count"] for c in body["chats"]] == [2]
|
||||
|
||||
|
||||
# ---------- get ----------
|
||||
|
||||
|
||||
def test_get_returns_full_payload_round_trip(admin_client: TestClient) -> None:
|
||||
created = admin_client.post(
|
||||
"/api/chats",
|
||||
json={"title": EXPLICIT_TITLE, "messages": [_user(FIRST_QUESTION), FULL_BRAIN]},
|
||||
).json()
|
||||
|
||||
r = admin_client.get(f"/api/chats/{created['id']}")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == OUT_KEYS
|
||||
assert body["id"] == created["id"]
|
||||
assert body["title"] == EXPLICIT_TITLE
|
||||
assert body["message_count"] == 2
|
||||
# Byte-identical payload: the brain record with sources/thinking/
|
||||
# tools/stopped (incl. the `argument: null` tool) survives the trip
|
||||
# to Postgres and back.
|
||||
assert body["messages"] == _expect([_user(FIRST_QUESTION), FULL_BRAIN])
|
||||
|
||||
|
||||
def test_get_unknown_chat_returns_404(admin_client: TestClient) -> None:
|
||||
r = admin_client.get(f"/api/chats/{uuid.uuid4()}")
|
||||
assert r.status_code == 404
|
||||
assert r.json() == {"detail": "unknown chat"}
|
||||
|
||||
|
||||
def test_get_invalid_id_returns_422(admin_client: TestClient) -> None:
|
||||
assert admin_client.get("/api/chats/not-a-uuid").status_code == 422
|
||||
|
||||
|
||||
# ---------- update (PUT) — the re-Save upsert ----------
|
||||
|
||||
|
||||
def test_put_replaces_messages_and_bumps_updated_at(admin_client: TestClient) -> None:
|
||||
created = admin_client.post(
|
||||
"/api/chats",
|
||||
json={"title": EXPLICIT_TITLE, "messages": _simple_conversation()},
|
||||
).json()
|
||||
# A second, newer chat — it currently lists first.
|
||||
other = admin_client.post(
|
||||
"/api/chats", json={"messages": [_user("second question")], "title": "Other"}
|
||||
).json()
|
||||
assert admin_client.get("/api/chats").json()["chats"][0]["id"] == other["id"]
|
||||
updated_before = created["updated_at"]
|
||||
|
||||
time.sleep(0.1) # now() has µs resolution — make the bump observable
|
||||
new_messages = [
|
||||
_user("How do I prune deleted docs?"),
|
||||
{"who": "brain", "text": "Use --prune."},
|
||||
]
|
||||
r = admin_client.put(f"/api/chats/{created['id']}", json={"messages": new_messages})
|
||||
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["id"] == created["id"]
|
||||
assert body["title"] == EXPLICIT_TITLE # absent title keeps the current one
|
||||
assert body["message_count"] == 2
|
||||
assert body["messages"] == _expect(new_messages) # full replacement
|
||||
assert datetime.fromisoformat(body["created_at"]) == datetime.fromisoformat(
|
||||
created["created_at"]
|
||||
) # editing does not redate creation
|
||||
assert datetime.fromisoformat(body["updated_at"]) > datetime.fromisoformat(
|
||||
updated_before
|
||||
), "updated_at must bump on a re-Save (onupdate=func.now())"
|
||||
# The list order follows the bump: this row is first again.
|
||||
body_list = admin_client.get("/api/chats").json()["chats"]
|
||||
assert body_list[0]["id"] == created["id"]
|
||||
|
||||
|
||||
def test_put_sets_title_when_supplied(admin_client: TestClient) -> None:
|
||||
created = admin_client.post(
|
||||
"/api/chats", json={"messages": _simple_conversation()}
|
||||
).json()
|
||||
|
||||
r = admin_client.put(
|
||||
f"/api/chats/{created['id']}",
|
||||
json={"title": "Renamed notes", "messages": _simple_conversation()},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json()["title"] == "Renamed notes"
|
||||
assert (
|
||||
admin_client.get(f"/api/chats/{created['id']}").json()["title"] == "Renamed notes"
|
||||
)
|
||||
|
||||
|
||||
def test_put_blank_title_keeps_current(admin_client: TestClient) -> None:
|
||||
created = admin_client.post(
|
||||
"/api/chats",
|
||||
json={"title": EXPLICIT_TITLE, "messages": _simple_conversation()},
|
||||
).json()
|
||||
|
||||
r = admin_client.put(
|
||||
f"/api/chats/{created['id']}", json={"title": " ", "messages": _simple_conversation()}
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json()["title"] == EXPLICIT_TITLE
|
||||
|
||||
|
||||
def test_put_unknown_chat_returns_404(admin_client: TestClient) -> None:
|
||||
r = admin_client.put(
|
||||
f"/api/chats/{uuid.uuid4()}", json={"messages": _simple_conversation()}
|
||||
)
|
||||
assert r.status_code == 404
|
||||
assert r.json() == {"detail": "unknown chat"}
|
||||
|
||||
|
||||
def test_put_rejects_empty_messages(admin_client: TestClient) -> None:
|
||||
created = admin_client.post(
|
||||
"/api/chats", json={"messages": _simple_conversation()}
|
||||
).json()
|
||||
assert (
|
||||
admin_client.put(f"/api/chats/{created['id']}", json={"messages": []})
|
||||
).status_code == 422
|
||||
# The original payload is untouched.
|
||||
assert (
|
||||
admin_client.get(f"/api/chats/{created['id']}").json()["messages"]
|
||||
== _expect(_simple_conversation())
|
||||
)
|
||||
|
||||
|
||||
def test_put_rejects_extra_message_keys(admin_client: TestClient) -> None:
|
||||
created = admin_client.post(
|
||||
"/api/chats", json={"messages": _simple_conversation()}
|
||||
).json()
|
||||
bad = _user("hi")
|
||||
bad["innerHTML"] = "<script>alert(1)</script>"
|
||||
r = admin_client.put(
|
||||
f"/api/chats/{created['id']}", json={"messages": [bad, FULL_BRAIN]}
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert (
|
||||
admin_client.get(f"/api/chats/{created['id']}").json()["messages"]
|
||||
== _expect(_simple_conversation())
|
||||
)
|
||||
|
||||
|
||||
# ---------- delete ----------
|
||||
|
||||
|
||||
def test_delete_returns_204_and_removes(admin_client: TestClient, db: Session) -> None:
|
||||
created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json()
|
||||
|
||||
assert admin_client.delete(f"/api/chats/{created['id']}").status_code == 204
|
||||
assert admin_client.get(f"/api/chats/{created['id']}").status_code == 404
|
||||
assert admin_client.get("/api/chats").json() == {"chats": []}
|
||||
assert db.scalars(select(SavedChat)).all() == []
|
||||
|
||||
|
||||
def test_delete_unknown_chat_returns_404(admin_client: TestClient) -> None:
|
||||
r = admin_client.delete(f"/api/chats/{uuid.uuid4()}")
|
||||
assert r.status_code == 404
|
||||
assert r.json() == {"detail": "unknown chat"}
|
||||
|
||||
|
||||
def test_delete_invalid_id_returns_422(admin_client: TestClient) -> None:
|
||||
assert admin_client.delete("/api/chats/not-a-uuid").status_code == 422
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Integration: migration 0008 (saved_chats) schema contract.
|
||||
|
||||
Drives the **real Alembic engine** against the live dev database
|
||||
(``podman compose up -d db``), mirroring the house pattern of
|
||||
``test_migration_0005.py`` / ``test_migration_0007.py``
|
||||
(information_schema assertions on the state the migration must leave).
|
||||
The tests target revision ``0008`` explicitly so later migrations
|
||||
cannot break them:
|
||||
|
||||
* upgrade 0007 → 0008 → a ``saved_chats`` table exists with
|
||||
``id UUID`` PK, ``title VARCHAR(500) NOT NULL``,
|
||||
``messages JSONB NOT NULL`` (the ``bor.chat.v1`` record list), and
|
||||
``created_at`` / ``updated_at TIMESTAMPTZ NOT NULL`` — both stamped
|
||||
server-side by ``now()`` (an insert that omits them still lands
|
||||
with both set);
|
||||
* the ``updated_at`` ORM ``onupdate`` bumps the timestamp on a row
|
||||
update while ``created_at`` stays put (the phase-50 History page
|
||||
orders by it);
|
||||
* downgrade to 0007 → the table is gone;
|
||||
* upgrade back to 0008 → it is back (round-trip).
|
||||
|
||||
The ``alembic`` fixture guarantees the DB ends at head even if a test
|
||||
fails or the process is interrupted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from alembic import command
|
||||
from app.db import db_available
|
||||
from app.models import SavedChat
|
||||
|
||||
TITLE_BASE = "Mig 0008"
|
||||
MESSAGE_SHAPE = [{"who": "user", "text": "How did I install gitlab?"}]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def alembic(db: Session) -> Iterator[Config]:
|
||||
"""Real Alembic config bound to the dev DB (URL from app settings).
|
||||
|
||||
Starts at head (repairs an interrupted earlier run); teardown upgrades
|
||||
to head no matter what happened, so the dev DB is never left below
|
||||
head.
|
||||
"""
|
||||
if not db_available():
|
||||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
|
||||
cfg.set_main_option("script_location", "alembic")
|
||||
command.upgrade(cfg, "head")
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
def _table_exists(db: Session) -> bool:
|
||||
"""1 iff ``saved_chats`` is a table in this database."""
|
||||
count: Any = db.execute(
|
||||
text(
|
||||
"SELECT count(*) FROM information_schema.tables"
|
||||
" WHERE table_name = 'saved_chats'"
|
||||
)
|
||||
).scalar()
|
||||
assert count is not None, "information_schema count must be an int"
|
||||
return int(count) == 1
|
||||
|
||||
|
||||
def _column(db: Session, column: str) -> tuple[Any, ...] | None:
|
||||
"""(data_type, is_nullable, column_default) for one saved_chats column."""
|
||||
row = db.execute(
|
||||
text(
|
||||
"SELECT data_type, is_nullable, column_default"
|
||||
" FROM information_schema.columns"
|
||||
" WHERE table_name = 'saved_chats' AND column_name = :c"
|
||||
),
|
||||
{"c": column},
|
||||
).fetchone()
|
||||
return tuple(row) if row is not None else None
|
||||
|
||||
|
||||
def _pk_columns(db: Session) -> set[str]:
|
||||
"""Primary-key columns of ``saved_chats`` (empty if it does not exist)."""
|
||||
rows = db.execute(
|
||||
text(
|
||||
"SELECT kcu.column_name"
|
||||
" FROM information_schema.table_constraints tc"
|
||||
" JOIN information_schema.key_column_usage kcu"
|
||||
" ON tc.constraint_name = kcu.constraint_name"
|
||||
" AND tc.table_schema = kcu.table_schema"
|
||||
" WHERE tc.table_name = 'saved_chats'"
|
||||
" AND tc.constraint_type = 'PRIMARY KEY'"
|
||||
)
|
||||
).fetchall()
|
||||
return {r[0] for r in rows}
|
||||
|
||||
|
||||
def _version(db: Session) -> str | None:
|
||||
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
||||
|
||||
|
||||
def _raw_insert(db: Session, title: str) -> uuid.UUID:
|
||||
"""Insert one saved_chats row omitting the timestamps (server-stamped)."""
|
||||
id: uuid.UUID = db.execute(
|
||||
text(
|
||||
"INSERT INTO saved_chats (id, title, messages)"
|
||||
" VALUES (gen_random_uuid(), :t, CAST(:m AS jsonb))"
|
||||
" RETURNING id"
|
||||
),
|
||||
{"t": title, "m": json.dumps(MESSAGE_SHAPE)},
|
||||
).scalar_one()
|
||||
db.commit()
|
||||
return id
|
||||
|
||||
|
||||
def _delete(db: Session, id: uuid.UUID) -> None:
|
||||
db.execute(text("DELETE FROM saved_chats WHERE id = :i"), {"i": id})
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_upgrade_to_0008_creates_saved_chats(db: Session, alembic: Config) -> None:
|
||||
"""Upgrade 0007 → 0008: ``saved_chats`` exists with the locked
|
||||
columns, types, nullability, PK, and server ``now()`` defaults."""
|
||||
command.downgrade(alembic, "0007") # start from the pre-0008 state
|
||||
assert _version(db) == "0007"
|
||||
assert not _table_exists(db), "saved_chats must not exist before 0008"
|
||||
|
||||
command.upgrade(alembic, "0008")
|
||||
assert _version(db) == "0008", "alembic_version must be at 0008"
|
||||
assert _table_exists(db), "saved_chats is missing after 0008"
|
||||
|
||||
assert _pk_columns(db) == {"id"}, "saved_chats must have a single id PK"
|
||||
|
||||
id_col = _column(db, "id")
|
||||
assert id_col is not None, "saved_chats.id is missing"
|
||||
assert id_col[0] == "uuid", "saved_chats.id must be UUID"
|
||||
assert id_col[1] == "NO", "saved_chats.id must be NOT NULL"
|
||||
|
||||
title = _column(db, "title")
|
||||
assert title is not None, "saved_chats.title is missing"
|
||||
assert title[0] == "character varying", "saved_chats.title must be VARCHAR"
|
||||
assert title[1] == "NO", "saved_chats.title must be NOT NULL"
|
||||
|
||||
messages = _column(db, "messages")
|
||||
assert messages is not None, "saved_chats.messages is missing"
|
||||
assert messages[0] == "jsonb", "saved_chats.messages must be JSONB"
|
||||
assert messages[1] == "NO", "saved_chats.messages must be NOT NULL"
|
||||
|
||||
for column in ("created_at", "updated_at"):
|
||||
col = _column(db, column)
|
||||
assert col is not None, f"saved_chats.{column} is missing"
|
||||
assert col[0] == "timestamp with time zone", (
|
||||
f"saved_chats.{column} must be TIMESTAMPTZ"
|
||||
)
|
||||
assert col[1] == "NO", f"saved_chats.{column} must be NOT NULL"
|
||||
assert col[2] is not None and "now()" in col[2], (
|
||||
f"saved_chats.{column} must default to now()"
|
||||
)
|
||||
|
||||
# Phase 51 (share_token) must not leak into this minimal migration.
|
||||
assert _column(db, "share_token") is None, (
|
||||
"0008 stays minimal — share_token lands in 0009 (phase 51)"
|
||||
)
|
||||
|
||||
|
||||
def test_server_timestamps_stamped_on_insert(db: Session, alembic: Config) -> None:
|
||||
"""An insert that omits created_at/updated_at (the API's shape) still
|
||||
lands with both stamped by the server defaults."""
|
||||
command.upgrade(alembic, "head")
|
||||
chat_id = _raw_insert(db, f"{TITLE_BASE}: server stamps")
|
||||
try:
|
||||
created_at, updated_at = db.execute(
|
||||
text("SELECT created_at, updated_at FROM saved_chats WHERE id = :i"),
|
||||
{"i": chat_id},
|
||||
).one()
|
||||
assert isinstance(created_at, datetime), "created_at must be server-stamped"
|
||||
assert isinstance(updated_at, datetime), "updated_at must be server-stamped"
|
||||
assert created_at.tzinfo is not None, "created_at must be timezone-aware"
|
||||
# Fresh row: nothing has updated it, so both stamps agree (now).
|
||||
assert (created_at - updated_at).total_seconds() < 5, (
|
||||
"a fresh row must have created_at ≈ updated_at"
|
||||
)
|
||||
finally:
|
||||
_delete(db, chat_id)
|
||||
|
||||
|
||||
def test_updated_at_bumps_on_row_update(db: Session, alembic: Config) -> None:
|
||||
"""The ORM ``onupdate=func.now()`` (the History page's Updated column)
|
||||
bumps ``updated_at`` on a row update while ``created_at`` stays put."""
|
||||
command.upgrade(alembic, "head")
|
||||
chat = SavedChat(title=f"{TITLE_BASE}: before update", messages=MESSAGE_SHAPE)
|
||||
db.add(chat)
|
||||
db.commit()
|
||||
try:
|
||||
created_before: datetime = chat.created_at
|
||||
updated_before: datetime = chat.updated_at
|
||||
assert created_before is not None and updated_before is not None
|
||||
|
||||
time.sleep(0.1) # now() has µs resolution — make the bump observable
|
||||
chat.title = f"{TITLE_BASE}: after update"
|
||||
chat.messages = [
|
||||
{"who": "user", "text": "How did I install gitlab?"},
|
||||
{"who": "brain", "text": "You've got this!", "sources": []},
|
||||
]
|
||||
db.commit()
|
||||
db.expire(chat)
|
||||
|
||||
created_after: datetime = chat.created_at
|
||||
updated_after: datetime = chat.updated_at
|
||||
assert created_after == created_before, "created_at must not move on update"
|
||||
assert updated_after > updated_before, (
|
||||
"updated_at must bump on a row update (onupdate=func.now())"
|
||||
)
|
||||
finally:
|
||||
db.expire_all()
|
||||
db.execute(text("DELETE FROM saved_chats WHERE id = :i"), {"i": chat.id})
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_downgrade_to_0007_drops_table(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade to 0007: ``saved_chats`` is dropped (A13 — reversible)."""
|
||||
command.downgrade(alembic, "0007")
|
||||
assert _version(db) == "0007"
|
||||
assert not _table_exists(db), "saved_chats must be dropped by the downgrade"
|
||||
assert _column(db, "id") is None, "saved_chats.id must be gone"
|
||||
|
||||
|
||||
def test_upgrade_round_trip_restores_table(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade to 0007, then upgrade back to 0008: the table is back
|
||||
with its locked columns and PK."""
|
||||
command.downgrade(alembic, "0007")
|
||||
command.upgrade(alembic, "0008")
|
||||
assert _version(db) == "0008", "round-trip upgrade must land at 0008"
|
||||
|
||||
assert _table_exists(db), "saved_chats must be back after the round-trip"
|
||||
assert _pk_columns(db) == {"id"}, "saved_chats.id PK must be back"
|
||||
|
||||
messages = _column(db, "messages")
|
||||
assert messages is not None and messages[0] == "jsonb", (
|
||||
"saved_chats.messages must be JSONB after the round-trip"
|
||||
)
|
||||
|
||||
col = _column(db, "updated_at")
|
||||
assert col is not None and col[2] is not None and "now()" in col[2], (
|
||||
"saved_chats.updated_at must keep its now() default after the round-trip"
|
||||
)
|
||||
@@ -185,6 +185,30 @@ def test_empty_static_dir_is_dev(tmp_path) -> None:
|
||||
assert asset_version(str(empty)) == "dev"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTML_PAGES registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_html_pages_include_history() -> None:
|
||||
"""Phase 50: the History page is registered in HTML_PAGES — without
|
||||
this entry it would serve unversioned asset refs, which the
|
||||
immutable-for-a-year asset caching would pin to stale CSS after a
|
||||
deploy (the phase-35 git-sources lesson). The entry is ADDED —
|
||||
every pre-phase-50 page stays registered."""
|
||||
for path in (
|
||||
"/",
|
||||
"/index.html",
|
||||
"/sources.html",
|
||||
"/document.html",
|
||||
"/login.html",
|
||||
"/tuning.html",
|
||||
"/git-sources.html",
|
||||
"/history.html",
|
||||
):
|
||||
assert path in caching.HTML_PAGES, f"{path} must be in HTML_PAGES"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rewrite_asset_refs (task 02)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -28,6 +28,7 @@ HTML_PAGES = (
|
||||
"document.html",
|
||||
"login.html",
|
||||
"git-sources.html",
|
||||
"history.html", # phase 50: the admin saved-chats page
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
"""Unit: the phase-50 task-04 History-page contract.
|
||||
|
||||
The browser behavior itself is E2E-gated by the story suite (task 05);
|
||||
like the other frontend-adjacent unit files, this module pins the
|
||||
JS/CSS/HTML markers the History page depends on, so a silent
|
||||
regression is caught without a browser:
|
||||
|
||||
* the anonymous no-fetch gate (the gate in, the table out, and the
|
||||
single ``GET /api/chats`` fetch lives ONLY in ``loadChats`` —
|
||||
unreachable from the anonymous branch);
|
||||
* the inline two-step Delete (the "Delete? [Yes] [No]" pair, focus to
|
||||
Yes, the row kept on No / a failed request, ``Deleted "<title>".``
|
||||
on success) and the ``window.confirm`` absence in ``history.js``
|
||||
(owner-locked 2026-08-29: no native confirm dialog on this page);
|
||||
* the ``/?chat=<id>`` Open-link href shape (TODO.md L5 — "return to
|
||||
that history with a click");
|
||||
* ``#nav-history`` on ALL SEVEN pages (the phase-34 one-bar contract)
|
||||
+ ``header.js``'s reveal-for-admin block;
|
||||
* the full-width table CSS (AGENTS.md rule 5) + the confirm pair +
|
||||
the empty-state row.
|
||||
|
||||
The Containerfile stage-1 coverage (history.html copied, history.js
|
||||
bundled) is pinned dynamically by
|
||||
``tests/integration/test_containerfile_assets.py`` — a page or module
|
||||
missing from stage 1 fails there.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
ASSETS = FRONTEND / "assets"
|
||||
|
||||
INDEX_HTML = FRONTEND / "index.html"
|
||||
SOURCES_HTML = FRONTEND / "sources.html"
|
||||
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
|
||||
TUNING_HTML = FRONTEND / "tuning.html"
|
||||
DOCUMENT_HTML = FRONTEND / "document.html"
|
||||
LOGIN_HTML = FRONTEND / "login.html"
|
||||
HISTORY_HTML = FRONTEND / "history.html"
|
||||
HISTORY_JS = ASSETS / "history.js"
|
||||
HEADER_JS = ASSETS / "header.js"
|
||||
STYLES_CSS = ASSETS / "styles.css"
|
||||
|
||||
#: The phase-34 one-bar contract + the new History page: SEVEN pages.
|
||||
ALL_PAGES = (
|
||||
INDEX_HTML,
|
||||
SOURCES_HTML,
|
||||
GIT_SOURCES_HTML,
|
||||
TUNING_HTML,
|
||||
DOCUMENT_HTML,
|
||||
LOGIN_HTML,
|
||||
HISTORY_HTML,
|
||||
)
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
assert path.is_file(), f"missing frontend file: {path}"
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
return _text(HISTORY_JS)
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return _text(STYLES_CSS)
|
||||
|
||||
|
||||
def _fn(js: str, name: str) -> str:
|
||||
"""The source of a top-level ``function <name>(...)`` (to its close)."""
|
||||
start = js.find(f"function {name}(")
|
||||
assert start != -1, f"{name}() must exist in history.js"
|
||||
return js[start : js.find("\n}\n", start) + 4]
|
||||
|
||||
|
||||
def _nav_history_tag(html: str) -> str:
|
||||
tag = re.search(r'<a[^>]*id="nav-history"[^>]*>', html)
|
||||
assert tag, "the #nav-history link is missing"
|
||||
return tag.group(0)
|
||||
|
||||
|
||||
# ---------- the #nav-history link: all seven pages ----------
|
||||
|
||||
|
||||
def test_nav_history_present_on_all_seven_pages() -> None:
|
||||
"""The phase-34 one-bar contract extended by phase 50: the admin-only
|
||||
History link SHIPS hidden (revealed by header.js for admin) on every
|
||||
page, after the Tuning link, pointing at /history.html. The page's
|
||||
own link is the active one (is-active + aria-current)."""
|
||||
for html in ALL_PAGES:
|
||||
text = _text(html)
|
||||
tag = _nav_history_tag(text)
|
||||
assert 'href="/history.html"' in tag
|
||||
assert "hidden" in tag, f"{html.name}: #nav-history must ship hidden"
|
||||
# Placed after the Tuning link (the owner-locked position).
|
||||
assert text.find('id="nav-tuning"') < text.find('id="nav-history"'), (
|
||||
f"{html.name}: #nav-history must follow #nav-tuning"
|
||||
)
|
||||
# The history page is the only one whose link is active.
|
||||
for html in ALL_PAGES:
|
||||
tag = _nav_history_tag(_text(html))
|
||||
if html.name == "history.html":
|
||||
assert 'class="nav-link is-active"' in tag
|
||||
assert 'aria-current="page"' in tag
|
||||
else:
|
||||
assert "is-active" not in tag, (
|
||||
f"{html.name}: no nav link is current there"
|
||||
)
|
||||
|
||||
|
||||
def test_nav_history_count_is_exactly_seven_pages() -> None:
|
||||
"""The pin counting occurrences across ``frontend/*.html`` — exactly
|
||||
one ``id="nav-history"`` per page, seven pages, no duplicates and no
|
||||
extra page that forgot (or added twice)."""
|
||||
total = 0
|
||||
for html in sorted(FRONTEND.glob("*.html")):
|
||||
count = html.read_text(encoding="utf-8").count('id="nav-history"')
|
||||
assert count in (0, 1), f"{html.name}: #nav-history appears {count} times"
|
||||
total += count
|
||||
assert total == 7, f"expected #nav-history on 7 pages, found {total}"
|
||||
|
||||
|
||||
def test_header_js_reveals_nav_history_for_admin() -> None:
|
||||
"""header.js reveals #nav-history for admin exactly like
|
||||
#nav-tuning — the same ship-hidden / reveal-for-admin contract,
|
||||
inside initSharedHeader (null-safe: a page without the link is a
|
||||
no-op)."""
|
||||
js = _text(HEADER_JS)
|
||||
fn = js.find("function initSharedHeader")
|
||||
assert fn != -1
|
||||
body = js[fn : js.find("\n}", fn)]
|
||||
assert 'querySelector("#nav-history")' in body
|
||||
assert "navHistory.hidden = !admin" in body
|
||||
|
||||
|
||||
# ---------- history.html: the page scaffold ----------
|
||||
|
||||
|
||||
def test_history_page_scaffold_and_landmarks() -> None:
|
||||
"""The standard page scaffold (AGENTS.md rule 5): skip link, the
|
||||
shared header, the steering panel + announcer (phase 34 — ships on
|
||||
every page), the page-head, the gate (ship-hidden), the
|
||||
role="status" live region, and the table inside the
|
||||
.table-wrap card. Footer with the version span (the index.html
|
||||
shape)."""
|
||||
html = _text(HISTORY_HTML)
|
||||
assert '<a class="skip-link" href="#main">' in html
|
||||
assert 'class="app-header"' in html
|
||||
assert 'nav class="app-nav" id="app-nav" aria-label="Primary"' in html
|
||||
tag = re.search(r'<section[^>]*id="steering-panel"[^>]*>', html)
|
||||
assert tag and "hidden" in tag.group(0), "the steering panel ships hidden"
|
||||
assert re.search(r'<p[^>]*id="steering-announcer"[^>]*role="status"[^>]*>', html)
|
||||
assert "<main id=\"main\" class=\"app-main\" tabindex=\"-1\">" in html
|
||||
assert '<h1>Saved chats</h1>' in html
|
||||
# The anonymous gate — the #sources-gate pattern, ship-hidden.
|
||||
gate = re.search(r'<section[^>]*id="history-gate"[^>]*>', html)
|
||||
assert gate and "hidden" in gate.group(0), "#history-gate must ship hidden"
|
||||
assert 'href="/login.html?next=/history.html"' in html, (
|
||||
"the gate's Sign in returns to the History page (no-JS fallback)"
|
||||
)
|
||||
# The action-feedback live region.
|
||||
assert re.search(r'<span[^>]*id="history-status"[^>]*role="status"[^>]*>', html)
|
||||
# The table wrapper: the .table-wrap card (scrollable) with its
|
||||
# own id, a labeled region, focusable.
|
||||
wrap = re.search(r'<div[^>]*class="table-wrap history-table-wrap"[^>]*>', html)
|
||||
assert wrap, "the table must live in the .table-wrap card"
|
||||
assert 'id="history-table-wrap"' in wrap.group(0)
|
||||
assert 'role="region"' in wrap.group(0) and 'tabindex="0"' in wrap.group(0)
|
||||
# Footer with the version span.
|
||||
assert 'class="footer-version" id="app-version"' in html
|
||||
|
||||
|
||||
def test_history_table_skeleton() -> None:
|
||||
"""The table skeleton: ``.history-table`` with the four columns —
|
||||
Title | Messages | Updated | Actions (the Actions header text is
|
||||
visually-hidden — the row buttons carry their own aria-labels) —
|
||||
and the empty-state row (ship-hidden, the exact copy)."""
|
||||
html = _text(HISTORY_HTML)
|
||||
assert '<table class="history-table">' in html
|
||||
for col in ('<th scope="col">Title</th>', '<th scope="col">Messages</th>',
|
||||
'<th scope="col">Updated</th>'):
|
||||
assert col in html
|
||||
actions_th = re.search(
|
||||
r'<th scope="col">([^<]*)<span class="visually-hidden">Actions</span></th>',
|
||||
html,
|
||||
)
|
||||
assert actions_th, "the Actions column header must be visually-hidden text"
|
||||
assert actions_th.group(1) == "", "no visible text beside the hidden header"
|
||||
# The empty-state row: ship-hidden, colspan 4, the exact copy.
|
||||
row = re.search(r'<tr[^>]*class="history-empty-row"[^>]*>', html)
|
||||
assert row, "the empty-state row must ship in the skeleton"
|
||||
assert "hidden" in row.group(0)
|
||||
assert 'id="history-empty-row"' in row.group(0)
|
||||
assert "<td colspan=\"4\">" in html
|
||||
assert (
|
||||
"No saved chats yet — finish a conversation and press"
|
||||
" <strong>Save</strong> in the chat."
|
||||
) in html
|
||||
|
||||
|
||||
def test_history_page_scripts_and_no_cdn() -> None:
|
||||
"""Script load order (the house pattern): brand.js classic FIRST,
|
||||
the history.js module second, NO direct header.js <script> tag
|
||||
(single-evaluation design — history.js imports it relatively).
|
||||
No-CDN rule (AGENTS.md rule 6): no external script/link tags."""
|
||||
html = _text(HISTORY_HTML)
|
||||
srcs = re.findall(r'<script[^>]*src="([^"]+)"', html)
|
||||
assert srcs == ["assets/brand.js", "/assets/history.js"], (
|
||||
f"history.html must load brand.js (classic, first) + the history.js "
|
||||
f"module, got {srcs}"
|
||||
)
|
||||
js = _js()
|
||||
assert 'from "./header.js"' in js, (
|
||||
"history.js must import the shared header module relatively"
|
||||
)
|
||||
assert '"/assets/header.js"' not in js
|
||||
assert 'src="http' not in html and 'href="http' not in html, (
|
||||
"no CDN: every asset is local (AGENTS.md rule 6)"
|
||||
)
|
||||
|
||||
|
||||
# ---------- the anonymous no-fetch gate ----------
|
||||
|
||||
|
||||
def test_anonymous_boot_makes_no_chats_request() -> None:
|
||||
"""The whoami gate in the boot IIFE: ``initSharedHeader()`` first
|
||||
(shared-header contract), then the anonymous branch hides the
|
||||
table, shows the gate, and RETURNS — no ``/api/chats`` request on
|
||||
the wire (the router 403s anonymous; the story E2E pins the
|
||||
request log). Only the admin path reaches ``loadChats()``. The
|
||||
single ``fetch("/api/chats")`` in the file lives in loadChats."""
|
||||
js = _js()
|
||||
assert js.count('fetch("/api/chats")') == 1, (
|
||||
"exactly ONE list fetch — the anonymous path must never add one"
|
||||
)
|
||||
load = _fn(js, "loadChats")
|
||||
assert 'fetch("/api/chats")' in load, "the list fetch lives in loadChats"
|
||||
|
||||
boot = js[js.find("(async () => {"):]
|
||||
assert boot, "the boot IIFE must exist"
|
||||
assert "await initSharedHeader()" in boot
|
||||
gate_i = boot.find("if (!(await fetchIsAdmin()))")
|
||||
assert gate_i != -1, "the whoami gate must run in boot"
|
||||
# The anonymous branch: gate in, table out, then a bare return —
|
||||
# and NO fetch call anywhere inside it.
|
||||
branch = boot[gate_i : boot.find("return;", gate_i)]
|
||||
assert "fetch(" not in branch, "the anonymous branch must not fetch anything"
|
||||
assert "tableWrap.hidden = true" in branch
|
||||
assert "gateEl.hidden = false" in branch
|
||||
# The admin path: the gate hides, then the list loads.
|
||||
after = boot[boot.find("return;", gate_i):]
|
||||
assert "gateEl.hidden = true" in after
|
||||
assert "loadChats();" in after
|
||||
|
||||
|
||||
def test_admin_load_renders_rows_or_empty_state() -> None:
|
||||
"""loadChats: a 0-row fetch (and non-2xx / a network failure)
|
||||
reveals the empty-state row; a populated fetch renders one row per
|
||||
chat, in the server's order (latest activity first)."""
|
||||
js = _js()
|
||||
load = _fn(js, "loadChats")
|
||||
# Every no-data outcome lands on the empty state.
|
||||
assert load.count("showEmptyState()") == 3, (
|
||||
"network failure, non-2xx and a 0-row list all show the empty state"
|
||||
)
|
||||
assert "chats.length" in load
|
||||
assert "makeRow(chat)" in load
|
||||
empty = _fn(js, "showEmptyState")
|
||||
assert "emptyRow.hidden = false" in empty
|
||||
|
||||
|
||||
# ---------- the /?chat=<id> Open link ----------
|
||||
|
||||
|
||||
def test_open_link_is_the_title_with_chat_href() -> None:
|
||||
"""makeRow: the Title cell is the Open link — ``/?chat=<id>``
|
||||
("return to that history with a click", TODO.md L5) — rendered
|
||||
through textContent (the auto-title is user-derived; never
|
||||
innerHTML). The Updated cell carries the locale date+time with the
|
||||
full ISO in the title attribute; Messages is the message_count."""
|
||||
js = _js()
|
||||
row = _fn(js, "makeRow")
|
||||
assert 'link.href = "/?chat=" + chat.id' in row, (
|
||||
"the Open link returns to /?chat=<id> (task 03's boot load)"
|
||||
)
|
||||
assert 'link.className = "history-title-link"' in row
|
||||
assert "link.textContent = chat.title" in row, "XSS contract: textContent only"
|
||||
assert 'innerHTML' not in row, "makeRow must never build HTML"
|
||||
assert "String(chat.message_count)" in row
|
||||
assert "updatedTd.title = chat.updated_at" in row, "full ISO on hover"
|
||||
assert "fmtDate(chat.updated_at)" in row
|
||||
assert "link.title" in row or "titleTd.title = chat.title" in row
|
||||
|
||||
|
||||
# ---------- the inline two-step Delete ----------
|
||||
|
||||
|
||||
def test_two_step_delete_confirm_pair() -> None:
|
||||
"""makeDeleteControl: the first click swaps the Delete button for
|
||||
the "Delete? [Yes] [No]" pair IN PLACE (keyboard-reachable — focus
|
||||
moves to Yes); No restores the Delete button (focus returns); the
|
||||
Delete button carries a labeled aria-name."""
|
||||
js = _js()
|
||||
# Owner-locked 2026-08-29: no native confirm dialog in the file.
|
||||
assert "window.confirm" not in js, "history.js must use the inline two-step only"
|
||||
fn = _fn(js, "makeDeleteControl")
|
||||
assert 'del.className = "history-delete"' in fn
|
||||
assert 'del.setAttribute("aria-label", `Delete saved chat: ${chat.title}`)' in fn
|
||||
assert 'label.textContent = "Delete?"' in fn
|
||||
assert 'yes.className = "history-confirm-yes"' in fn
|
||||
assert 'no.className = "history-confirm-no"' in fn
|
||||
# The shipped state of the actions cell IS the Delete button
|
||||
# (before any click) — a cell that only gains the button on
|
||||
# restore would render an empty Actions column.
|
||||
append = fn.find("cell.appendChild(del)")
|
||||
ret = fn.rfind("return cell")
|
||||
assert -1 < append < ret, "the Delete button is appended before the return"
|
||||
# The swap + the focus handoff.
|
||||
assert "cell.replaceChildren(label, yes, no)" in fn
|
||||
yes_swap = fn.find("cell.replaceChildren(label, yes, no)")
|
||||
assert fn.find("yes.focus()", yes_swap) > 0, "focus moves to Yes after the swap"
|
||||
# No (and the restore helper) bring the Delete button back, focused.
|
||||
restore_start = fn.find("function restoreDelete")
|
||||
restore_end = fn.find("\n }", restore_start)
|
||||
restore = fn[restore_start:restore_end]
|
||||
assert "cell.replaceChildren(del)" in restore
|
||||
assert "del.focus()" in restore
|
||||
assert 'no.addEventListener("click", restoreDelete)' in fn
|
||||
|
||||
|
||||
def test_confirmed_delete_outcomes() -> None:
|
||||
"""confirmDelete: double-fire guarded; 2xx → the row is removed +
|
||||
the empty-state row reappears when it was the last + the live
|
||||
region `Deleted "<title>".`; a 404 (already gone) drops the stale
|
||||
row and says so; any other failure / a network error KEEPS the row
|
||||
(restore) and lands the error line."""
|
||||
js = _js()
|
||||
fn = _fn(js, "confirmDelete")
|
||||
assert "yesBtn.disabled = true" in fn
|
||||
assert "fetch(`/api/chats/${chat.id}`, { method: \"DELETE\" })" in fn
|
||||
# Success: remove + empty-state check + the exact live-region line.
|
||||
assert "row.remove()" in fn
|
||||
assert "showEmptyIfLast()" in fn
|
||||
assert 'announce(`Deleted "${chat.title}".`)' in fn
|
||||
# 404: the row is stale — drop it, no restore. (The branch slices
|
||||
# stop at the NEXT branch boundary — a template-literal `}` inside
|
||||
# an announce line must not end the slice early.)
|
||||
nf = fn.find("r.status === 404")
|
||||
assert nf != -1, "the 404 branch must be handled"
|
||||
notok = fn.find("if (!r.ok)")
|
||||
nf_branch = fn[nf:notok]
|
||||
assert "row.remove()" in nf_branch
|
||||
assert "already deleted" in nf_branch
|
||||
assert "restoreDelete()" not in nf_branch
|
||||
# !ok (non-404) and network: the row stays, the button is
|
||||
# retryable, and the error line lands.
|
||||
# !ok (non-404) and network: the row stays, the button is
|
||||
# retryable, and the error line lands. (The try/catch wraps the
|
||||
# FETCH, so it precedes the status branches; the !ok slice runs to
|
||||
# the function's close — the success tail after it carries neither
|
||||
# a restore nor that line.)
|
||||
assert notok != -1
|
||||
notok_branch = fn[notok:]
|
||||
assert "restoreDelete()" in notok_branch
|
||||
assert "try again" in notok_branch
|
||||
# The network-error catch: the reachable? line + the restore (the
|
||||
# catch wraps the fetch, so it precedes the status branches).
|
||||
catch_i = fn.find("} catch {")
|
||||
assert catch_i != -1
|
||||
catch_branch = fn[catch_i : fn.find("if (r.status === 404)")]
|
||||
assert "is the app reachable?" in catch_branch
|
||||
assert "restoreDelete()" in catch_branch
|
||||
# The empty-state row reappears exactly when the last data row is
|
||||
# gone (the hidden empty row itself ships in the tbody).
|
||||
empty = _fn(js, "showEmptyIfLast")
|
||||
assert "querySelectorAll(\"tr\").length > 1" in empty
|
||||
|
||||
|
||||
# ---------- the table CSS ----------
|
||||
|
||||
|
||||
def test_history_table_css_full_width_and_palette() -> None:
|
||||
"""styles.css: .history-table is the full-width sources-table family
|
||||
(width 100%, --line borders, the brand-soft thead, row hover); the
|
||||
title link is the accent link (brand-ink, focus-visible); the
|
||||
confirm pair is Yes-on-error-rose + No-ghost; the empty-state row
|
||||
is the muted centered message. Every pair is Phase-08 AA
|
||||
(brand-ink/brand-soft 6.9:1, err 9.1:1, ink-soft >=6.9:1)."""
|
||||
css = _css()
|
||||
block = re.search(r"\.history-table \{([\s\S]*?)\n\}", css)
|
||||
assert block, "styles.css must style .history-table"
|
||||
body = block.group(1)
|
||||
assert "width: 100%" in body, "the table is FULL-WIDTH (AGENTS.md rule 5)"
|
||||
assert "min-width: 640px" in body
|
||||
th = re.search(r"\.history-table th \{([\s\S]*?)\n\}", css)
|
||||
assert th and "var(--brand-soft)" in th.group(1) and "var(--brand-ink)" in th.group(1)
|
||||
hover = re.search(r"\.history-table tbody tr:hover \{([^}]*)\}", css)
|
||||
assert hover, "row hover is part of the table family"
|
||||
link = re.search(r"\.history-title-link \{([\s\S]*?)\n\}", css)
|
||||
assert link and "var(--brand-ink)" in link.group(1), "the Open link is the accent link"
|
||||
assert re.search(r"\.history-title-link:focus-visible \{[^}]*outline[^}]*3px", css), (
|
||||
"the Open link keeps a :focus-visible outline"
|
||||
)
|
||||
yes = re.search(r"\.history-confirm-yes \{([\s\S]*?)\n\}", css)
|
||||
assert yes, "the confirm Yes button must be styled"
|
||||
ybody = yes.group(1)
|
||||
assert "var(--err-bg)" in ybody and "var(--err-ink)" in ybody and "var(--err-line)" in ybody
|
||||
no = re.search(r"\.history-confirm-no \{([\s\S]*?)\n\}", css)
|
||||
assert no and "background: transparent" in no.group(1), "No is the ghost"
|
||||
empty = re.search(r"\.history-empty-row td \{([\s\S]*?)\n\}", css)
|
||||
assert empty, "the empty-state row must be styled"
|
||||
ebody = empty.group(1)
|
||||
assert "text-align: center" in ebody and "var(--ink-soft)" in ebody
|
||||
|
||||
|
||||
def test_history_table_mobile_behavior() -> None:
|
||||
"""≤640px (the phase-07 responsive contract): the table keeps its
|
||||
full width (the .table-wrap's horizontal scroll already covers
|
||||
it) and the actions cell wraps so the two-step confirm pair fits
|
||||
the phone width."""
|
||||
css = _css()
|
||||
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css)
|
||||
assert mobile, "the mobile media query must exist"
|
||||
mbody = mobile.group(1)
|
||||
assert ".history-actions-cell { white-space: normal; }" in mbody
|
||||
assert ".history-actions { flex-wrap: wrap; }" in mbody
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Unit: the phase-50 task-03 save-chat contract on the chat page.
|
||||
|
||||
The browser behavior itself is E2E-gated by the story suite (task 05);
|
||||
like the other frontend-adjacent unit files, this module pins the
|
||||
JS/CSS/HTML markers the save/load contract depends on, so a silent
|
||||
regression is caught without a browser:
|
||||
|
||||
* the ``currentChatId`` lifecycle (set on create/open, cleared by New
|
||||
chat and by the 404-PUT fallback);
|
||||
* the upsert branch (PUT when linked, POST when not, the 404→recreate
|
||||
fallback, the live-region feedback strings);
|
||||
* the boot-load precedence (a valid ``?chat=`` uuid + admin replaces the
|
||||
local restore and mirrors it to localStorage; anonymous / invalid /
|
||||
404 / network → the local restore);
|
||||
* the ship-hidden / reveal-for-admin gate on ``#save-chat-btn``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
APP_JS = FRONTEND / "assets" / "app.js"
|
||||
INDEX_HTML = FRONTEND / "index.html"
|
||||
SOURCES_HTML = FRONTEND / "sources.html"
|
||||
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
|
||||
DOCUMENT_HTML = FRONTEND / "document.html"
|
||||
LOGIN_HTML = FRONTEND / "login.html"
|
||||
TUNING_HTML = FRONTEND / "tuning.html"
|
||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
return APP_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return STYLES_CSS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _index() -> str:
|
||||
return INDEX_HTML.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _fn(js: str, name: str) -> str:
|
||||
"""The source of a top-level ``function <name>(...)`` (to its close)."""
|
||||
start = js.find(f"function {name}(")
|
||||
assert start != -1, f"{name}() must exist in app.js"
|
||||
return js[start : js.find("\n}\n", start) + 4]
|
||||
|
||||
|
||||
# ---------- the Save button on the chat page ----------
|
||||
|
||||
|
||||
def test_save_button_ships_hidden_beside_new_chat() -> None:
|
||||
"""#save-chat-btn: a real type=button with the accessible name
|
||||
"Save chat", SHIPPED HIDDEN (app.js reveals it for admin only),
|
||||
beside #new-chat-btn in .chat-shell inside <main>, above
|
||||
#messages — the two chat-shell actions read as a pair. No other
|
||||
page carries it (chat-page only, like New chat)."""
|
||||
html = _index()
|
||||
btn = re.search(r'<button[^>]*id="save-chat-btn"[^>]*>', html)
|
||||
assert btn, "index.html must contain #save-chat-btn"
|
||||
tag = btn.group(0)
|
||||
assert 'type="button"' in tag
|
||||
assert 'aria-label="Save chat"' in tag
|
||||
assert "hidden" in tag, "the button ships hidden (reveal is app.js's job)"
|
||||
# Beside New chat: after it, still inside .chat-shell, above #messages.
|
||||
main_idx = html.find('main id="main"')
|
||||
shell_idx = html.find('class="container chat-shell"')
|
||||
new_idx = html.find('id="new-chat-btn"')
|
||||
messages_idx = html.find('id="messages"')
|
||||
assert -1 < main_idx < shell_idx < new_idx < btn.start() < messages_idx, (
|
||||
"the button must sit beside #new-chat-btn in .chat-shell, above #messages"
|
||||
)
|
||||
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML):
|
||||
assert 'id="save-chat-btn"' not in other.read_text(encoding="utf-8"), (
|
||||
f"{other.name}: the Save button is chat-page only"
|
||||
)
|
||||
|
||||
|
||||
def test_save_button_css_is_the_exact_new_chat_family() -> None:
|
||||
"""styles.css: .save-chat-btn carries the EXACT visual family of
|
||||
.new-chat-btn — solid brand pill (--bg on --brand = 5.2:1, AA),
|
||||
borderless, 999px radius, ≥44px target, hover lightens the brand
|
||||
fill; the ≤640px block mirrors the New chat overrides (label stays
|
||||
visible in .chat-shell, icon hidden there; icon-only elsewhere)."""
|
||||
css = _css()
|
||||
block = re.search(r"\.save-chat-btn \{([\s\S]*?)\n\}", css)
|
||||
assert block, "styles.css must style .save-chat-btn"
|
||||
body = block.group(1)
|
||||
assert "min-height: 44px" in body
|
||||
assert "border-radius: 999px" in body
|
||||
assert "border: 0" in body
|
||||
assert "background: var(--brand)" in body, "same solid brand fill as New chat"
|
||||
assert "color: var(--bg)" in body, "--bg text on --brand = 5.2:1 (AA)"
|
||||
hover = re.search(r"\.save-chat-btn:hover \{([\s\S]*?)\n\}", css)
|
||||
assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
|
||||
svg = re.search(r"\.save-chat-btn svg \{([\s\S]*?)\n\}", css)
|
||||
assert svg and "display: none" in svg.group(1), "icon hidden on desktop (like New chat)"
|
||||
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
|
||||
assert mobile, "mobile media query missing"
|
||||
mbody = mobile.group(1)
|
||||
assert ".save-chat-btn { padding: 0.4rem 0.3rem; }" in mbody, "squeezes with New chat"
|
||||
assert ".save-chat-label { display: none; }" in mbody
|
||||
assert ".save-chat-btn svg { display: block; }" in mbody
|
||||
assert ".chat-shell .save-chat-label { display: inline; }" in mbody, (
|
||||
"in .chat-shell the label stays visible, as for New chat"
|
||||
)
|
||||
assert ".chat-shell .save-chat-btn svg { display: none; }" in mbody
|
||||
|
||||
|
||||
# ---------- currentChatId lifecycle ----------
|
||||
|
||||
|
||||
def test_current_chat_id_module_scope_and_lifecycle() -> None:
|
||||
"""currentChatId: module scope, string | null — set to the created
|
||||
row's id on a fresh Save (201), set to the opened id on a
|
||||
successful boot load, cleared by "New chat" AND by the 404-PUT
|
||||
fallback (a stale link must never leave the conversation unsaved)."""
|
||||
js = _js()
|
||||
assert "let currentChatId = null" in js, "module-scope link, null = unlinked"
|
||||
# Set on create: the 201 branch links to the created row's id.
|
||||
save_body = _fn(js, "saveCurrentChat")
|
||||
assert "res.status === 201" in save_body
|
||||
assert "currentChatId = String(created.id)" in save_body, (
|
||||
"a fresh Save links to the created row's id"
|
||||
)
|
||||
# Set on open: the boot load links to the fetched id.
|
||||
load_body = _fn(js, "restoreSavedChatFromUrl")
|
||||
assert "currentChatId = chatId" in load_body
|
||||
# Cleared by New chat.
|
||||
new_body = _fn(js, "startNewChat")
|
||||
assert "currentChatId = null" in new_body, "New chat unlinks"
|
||||
# Cleared by the 404-PUT fallback (see the upsert test for the branch).
|
||||
assert "res.status === 404" in save_body
|
||||
assert save_body.count("currentChatId = null") >= 1
|
||||
|
||||
|
||||
# ---------- the upsert branch ----------
|
||||
|
||||
|
||||
def test_save_upsert_put_when_linked_post_when_not() -> None:
|
||||
"""saveCurrentChat: linked → PUT /api/chats/<id> with the messages
|
||||
payload (re-Save updates the SAME row — no title in the body, so the
|
||||
row keeps its current one); unlinked → POST /api/chats (the server
|
||||
auto-titles). The 404 from the PUT unlinks and retries as a create.
|
||||
Empty conversation → no request, live-region "Nothing to save
|
||||
yet."; success → live-region "Conversation saved." (status text
|
||||
only, no banner); 403/5xx/network → the error banner."""
|
||||
js = _js()
|
||||
body = _fn(js, "saveCurrentChat")
|
||||
# No-op first: nothing to save → live-region line, no fetch.
|
||||
noop = body.find('sendStatus.textContent = "Nothing to save yet."')
|
||||
first_fetch = body.find("await fetch(")
|
||||
assert 0 < noop < first_fetch, "the empty-conversation no-op precedes any fetch"
|
||||
assert "if (!conversation.length)" in body
|
||||
# The branch: PUT when linked, POST when not.
|
||||
assert "if (currentChatId)" in body
|
||||
assert '`/api/chats/${currentChatId}`' in body
|
||||
assert 'method: "PUT"' in body
|
||||
assert 'fetch("/api/chats"' in body
|
||||
assert 'method: "POST"' in body
|
||||
put_idx = body.find('method: "PUT"')
|
||||
post_idx = body.find('method: "POST"')
|
||||
assert -1 < put_idx < post_idx, "the PUT (linked) branch precedes the POST fallback"
|
||||
assert '{ messages: conversation }' in body, "the messages payload — no title (keep current)"
|
||||
# The 404→recreate fallback: unlink, then POST again.
|
||||
notfound_idx = body.find("res.status === 404")
|
||||
assert notfound_idx != -1, "the PUT 404 must be handled"
|
||||
fallback = body[notfound_idx:post_idx]
|
||||
assert "currentChatId = null" in fallback, "the stale link is dropped"
|
||||
# Success is status text only — the live region, never stale — and
|
||||
# nothing between the 201 link and the success line may raise a
|
||||
# banner (the !res.ok branch returns before either).
|
||||
assert body.count('sendStatus.textContent = "Conversation saved."') == 1
|
||||
saved_line = 'sendStatus.textContent = "Conversation saved."'
|
||||
between = body[body.find("res.status === 201") : body.find(saved_line)]
|
||||
assert "showErrorBanner" not in between, "no banner on the success path"
|
||||
# Failures raise an actionable banner (non-ok HTTP + network).
|
||||
assert 'showErrorBanner("Couldn\'t save the conversation — is the app reachable?")' in body
|
||||
assert "check you're still signed in and try again" in body, "403/5xx: actionable line"
|
||||
# The double-click guard releases on EVERY outcome.
|
||||
finally_idx = body.rfind("finally")
|
||||
assert finally_idx != -1 and "saveBtn.disabled = false" in body[finally_idx:], (
|
||||
"the button is re-enabled in the finally — never stale"
|
||||
)
|
||||
|
||||
|
||||
# ---------- boot-load precedence ----------
|
||||
|
||||
|
||||
def test_boot_load_precedence_saved_chat_over_local_restore() -> None:
|
||||
"""Inside the boot IIFE: after fetchIsAdmin() + the reveal gate,
|
||||
restoreSavedChatFromUrl() runs; only when it returns false does the
|
||||
phase-14 local restore run. Header init stays first (shared-module
|
||||
contract)."""
|
||||
js = _js()
|
||||
boot_start = js.find("(async () => {")
|
||||
assert boot_start != -1, "the boot IIFE must exist"
|
||||
boot = js[boot_start:]
|
||||
init_i = boot.find("await initSharedHeader();")
|
||||
admin_i = boot.find("isAdmin = await fetchIsAdmin();")
|
||||
reveal_i = boot.find("saveBtn.hidden = !isAdmin")
|
||||
saved_i = boot.find("await restoreSavedChatFromUrl();")
|
||||
local_i = boot.find("restoreConversation();")
|
||||
assert -1 < init_i < admin_i < reveal_i < saved_i < local_i, (
|
||||
"boot order: header init → whoami → Save reveal → ?chat= load → local fallback"
|
||||
)
|
||||
assert "if (!openedSaved) restoreConversation();" in boot, (
|
||||
"the local restore runs ONLY when the saved-chat load did not open"
|
||||
)
|
||||
|
||||
|
||||
def test_boot_load_gates_valid_uuid_and_admin_only() -> None:
|
||||
"""restoreSavedChatFromUrl: a VALID uuid + admin is the ONLY
|
||||
fetch path — invalid/absent ?chat= and anonymous short-circuit to
|
||||
false (no request: the gate would 403). On 200 the messages
|
||||
REPLACE the local conversation, render through the SAME
|
||||
renderStoredMessage loop (pixel-identical restore), link
|
||||
currentChatId, and mirror to localStorage. 404/network/malformed/
|
||||
empty → banner + false (the local restore then runs)."""
|
||||
js = _js()
|
||||
body = _fn(js, "restoreSavedChatFromUrl")
|
||||
# The gates, in order: param present → valid uuid → admin.
|
||||
assert '.get("chat")' in body, "the ?chat= param"
|
||||
assert "UUID_RE.test(chatId)" in body, "a valid uuid only"
|
||||
assert "!isAdmin" in body, "admin only (no fetch for anonymous)"
|
||||
gate = body.find("!isAdmin")
|
||||
fetch_i = body.find('fetch(`/api/chats/${chatId}`)')
|
||||
assert -1 < gate < fetch_i, "the gates short-circuit BEFORE the fetch"
|
||||
assert "const UUID_RE" in js, "the uuid pattern is module-level"
|
||||
# On success: replace → render through the SAME loop → link → mirror.
|
||||
assert "conversation = messages" in body, "the saved messages REPLACE the local conversation"
|
||||
assert "renderStoredMessage(m)" in body, "the SAME renderStoredMessage path as local restore"
|
||||
assert "markLastRetryable()" in body, "parity with local restore: Retry on the last bubble"
|
||||
save_mir = body.find("saveConversation()")
|
||||
link_i = body.find("currentChatId = chatId")
|
||||
assert -1 < link_i < save_mir, "link first, then mirror to localStorage"
|
||||
# Failure: the exact banner line, then false (→ local restore). The
|
||||
# gate line returns false directly; the 404/network, malformed-body
|
||||
# and empty-payload paths all route through the banner helper.
|
||||
banner_line = 'showErrorBanner("That saved chat isn\'t available — it may have been deleted.")'
|
||||
assert banner_line in body
|
||||
assert "return false" in body, "invalid/absent param or anonymous → no fetch, local restore"
|
||||
assert body.count("return unavailable()") == 4, (
|
||||
"network, non-ok (404/403/5xx), malformed body and empty payload all fall back"
|
||||
)
|
||||
# The ?chat= param is a one-shot boot instruction: the success path
|
||||
# normalizes the URL back to / so a later refresh (or "New chat" +
|
||||
# refresh) restores the LOCAL session instead of re-opening the row.
|
||||
assert 'history.replaceState(null, "", "/")' in body, (
|
||||
"a consumed ?chat= must not linger in the URL"
|
||||
)
|
||||
# The defensive filter keeps a corrupted stored row from poisoning the
|
||||
# restore (same shape check as loadStoredConversation).
|
||||
assert 'm.who === "user" || m.who === "brain"' in body
|
||||
assert 'typeof m.text === "string"' in body
|
||||
|
||||
|
||||
# ---------- the reveal gate ----------
|
||||
|
||||
|
||||
def test_save_button_revealed_only_for_admin() -> None:
|
||||
"""The ship-hidden/reveal-for-admin contract: app.js queries
|
||||
#save-chat-btn, binds the click to saveCurrentChat, and the boot
|
||||
IIFE sets saveBtn.hidden = !isAdmin (phase 16 absent-not-hidden —
|
||||
hidden is display:none, no trace for anonymous)."""
|
||||
js = _js()
|
||||
assert 'document.querySelector("#save-chat-btn")' in js
|
||||
assert 'saveBtn?.addEventListener("click", saveCurrentChat)' in js
|
||||
assert "saveBtn.hidden = !isAdmin" in js, "revealed for admin only, at boot"
|
||||
# The reveal happens in the boot IIFE (after whoami), not at module
|
||||
# evaluation (isAdmin is false there).
|
||||
boot_start = js.find("(async () => {")
|
||||
reveal = js.find("saveBtn.hidden = !isAdmin")
|
||||
assert boot_start < reveal, "the reveal must run at boot, after whoami resolves"
|
||||
|
||||
|
||||
def test_boot_load_adds_no_direct_storage_access() -> None:
|
||||
"""The localStorage accesses stay EXACTLY the phase-14 three
|
||||
(loadStoredConversation / saveConversation / clearStoredConversation)
|
||||
— the saved-chat mirror goes through saveConversation(), so the
|
||||
house failure-safety pin (exactly 3, all try-wrapped) holds."""
|
||||
js = _js()
|
||||
accesses = list(re.finditer(r"localStorage\.(?:getItem|setItem|removeItem)", js))
|
||||
assert len(accesses) == 3, f"expected exactly 3 localStorage accesses, got {len(accesses)}"
|
||||
|
||||
|
||||
def test_no_cdn_added() -> None:
|
||||
"""AGENTS.md rule 6: the Save button adds no external script/link."""
|
||||
index = _index()
|
||||
assert 'src="http' not in index and 'href="http' not in index
|
||||
@@ -22,8 +22,8 @@ ASSETS = FRONTEND / "assets"
|
||||
|
||||
HEADER_JS = ASSETS / "header.js"
|
||||
|
||||
#: All six pages carry the shared header block (phase 34's five pages +
|
||||
#: phase 35's git-sources page).
|
||||
#: All seven pages carry the shared header block (phase 34's five pages
|
||||
#: + phase 35's git-sources page + phase 50's History page).
|
||||
PAGES = (
|
||||
FRONTEND / "index.html",
|
||||
FRONTEND / "sources.html",
|
||||
@@ -31,6 +31,7 @@ PAGES = (
|
||||
FRONTEND / "git-sources.html",
|
||||
FRONTEND / "login.html",
|
||||
FRONTEND / "tuning.html",
|
||||
FRONTEND / "history.html",
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user