feat(chat): invalidate saved chats on sources sync — versioned stamps, stale marker, Regenerate against the new index

This commit is contained in:
2026-08-30 23:39:15 -04:00
parent ea8e041189
commit 32b7bfd4b3
26 changed files with 2145 additions and 63 deletions
+544
View File
@@ -0,0 +1,544 @@
"""Phase 53 E2E (Playwright): invalidate saved chats on sources sync.
TODO.md L4 (owner 2026-08-30): "Make sure the saved chats are invalidated
if the docs are synced, that way it generates a new answer with new data".
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_stale_saved_chats.py -v --no-cov
The full user-visible invalidation loop under test:
* **Save (fresh)** — as admin: ask (the mock answers deterministically),
Save via the chat-page pill; ``GET /api/chats`` (admin cookie) reports
the row with ``stale: false``; opening the FRESH row at ``/?chat=<id>``
shows NO banner;
* **KB change** — the test process (which shares the app's environment)
bumps the ``sources_meta`` seed row through ``bump_sources_version``
over a short ``SessionLocal()`` — the exact helper BOTH real sync
paths (the Sync button, ``scripts/import_docs``) call change-gated.
The bump GATES are pinned by this phase's integration tests (task 02)
and the Sync button's end-to-end clone/import path by
``test_sync_button.py``; the E2E proves the user-visible loop, not
git plumbing (the phase's recorded ASSUMPTION);
* **Stale surfaced** — ``/history.html``: the row now carries the rose
Stale pill (``aria-label``d for screen readers); ``GET /api/chats``
and ``GET /api/chats/<id>`` carry ``stale: true`` (computed
server-side — the client never computes staleness);
* **Regenerate** — opening the row (``/?chat=<id>``, the same URL the
History table links) reveals ``#stale-banner`` with the Regenerate
button; clicking it re-streams the last answer IN PLACE against the
new index (the phase-49 redo-in-place: the old bubble leaves the DOM,
the question is not duplicated), and the handler then auto re-saves
the linked row — the server re-stamps ``sources_version`` → the
banner clears, ``GET /api/chats/<id>`` reports ``stale: false`` with
the last brain message being the fresh answer, and the History pill
is gone;
* **Guard** — a stale row whose conversation has NO brain record
(user-only) reveals the banner text WITHOUT the Regenerate button
(``retryLastTurn`` has nothing to redo);
* **Anonymous** — sharing the (now fresh) chat and opening
``/shared/<token>`` without a session renders the phase-51 snapshot
with NO staleness surface — neither in the DOM nor on the public
``SharedChatOut`` wire (the snapshot is frozen by design).
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`` and ``sources_meta`` are never touched by
the reset — the version is monotonic by design, and rows stamped
against an older generation are simply stale (that is the point).
Determinism: the mock quotes the asked question into its grounded
answer (ending in the ``Deterministic mock answer for E2E`` marker),
so the regenerated answer is textually identical to the stale one —
OLD-vs-fresh bubble identity is proved the phase-49 way, with a
test-only ``data-retry-marker`` attribute set on the old wrap before
the click.
"""
from __future__ import annotations
import asyncio
import uuid
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
from playwright.sync_api import Browser, BrowserContext, Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.models import SavedChat
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from app.rag.sources_meta import bump_sources_version, current_sources_version
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"
#: The banner's line (frontend/index.html, task 05).
BANNER_TEXT = "The sources have been updated since this chat was saved."
#: The live-region outcome of a successful Regenerate (app.js, task 05).
REGEN_STATUS = "Regenerated — the answer now reflects the current sources."
#: The fresh row's Stale cell (history.js, task 04).
FRESH_STALE_CELL = "—"
#: The stale row's Stale cell aria-label (history.js, task 04).
STALE_CELL_ARIA = "Stale — sources have changed since this chat was saved"
#: The typing indicator is itself a .msg.brain — exclude its bubble.
ANSWER = ".msg.brain .bubble:not(.typing)"
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``
and ``sources_meta`` are deliberately NOT touched: rows persist
across suites (every test cleans up after itself) and the version
is monotonic (the invalidation marker)."""
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 _bump_sources_version() -> int:
"""The test-only stand-in for a KB-changing sync (task 02): the SAME
``bump_sources_version`` helper both real sync paths call
change-gated, over a short session (bump flushes, the caller
commits). The bump GATES — changed/unchanged/`--limit`/failed — are
pinned by this phase's integration tests, not by the E2E."""
with SessionLocal() as db:
v = bump_sources_version(db)
db.commit()
return v
def _current_version() -> int:
with SessionLocal() as db:
return current_sources_version(db)
def _row_stamp(chat_id: str) -> int:
"""The saved row's ``sources_version`` stamp (helper read — the
same pattern as the phase's integration tests)."""
with SessionLocal() as db:
row = db.get(SavedChat, uuid.UUID(chat_id))
assert row is not None
return row.sources_version
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(ANSWER).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 _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.")
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 _chat(app_url: str, cookies: dict[str, str], chat_id: str) -> dict[str, Any]:
r = httpx.get(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
assert r.status_code == 200
return r.json()
def _auto_title(question: str) -> str:
"""The phase-50 auto-title convention: the first question,
whitespace-collapsed, capped at 120 chars."""
return " ".join(question.split())[:120]
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 _history_row(page: Page, chat_id: str) -> Any:
"""The History table row for this chat (located through the Open
link — ``/?chat=<id>`` — the same URL the table links)."""
return page.locator(
"#history-tbody tr", has=page.locator(f"a[href='/?chat={chat_id}']")
)
def _assert_no_error_banner(page: Page) -> None:
"""Regenerate settles through the normal done path — never the red
role=alert error banner (the KB-offline banner is a separate,
health-driven state the db_ready fixture keeps away)."""
banner = page.locator("#kb-banner")
expect(banner).to_be_hidden()
expect(banner).not_to_have_class("is-error")
def _tag_last_brain_wrap(page: Page, marker: str) -> None:
"""Tag the rendered wrap of the LAST brain bubble (settled state —
no typing indicator present) so the test can prove the OLD element
leaves the DOM: the mock answers are byte-stable, so the redo of
the same question is textually indistinguishable from the original."""
page.evaluate(
"""(marker) => {
const wraps = document.querySelectorAll("#messages > .msg.brain");
wraps[wraps.length - 1].setAttribute("data-retry-marker", marker);
}""",
marker,
)
def _regenerate_in_place(page: Page, marker: str) -> None:
"""Click the banner's Regenerate and wait for the FULL phase-49
redo-in-place + auto re-save to settle: the turn goes in-flight
(the button IS Stop), the old bubble leaves the DOM before the
first fresh token, the fresh answer lands, and the handler's
post-turn PUT (the server re-stamps ``sources_version``) clears the
banner and lands the outcome on the live region."""
_tag_last_brain_wrap(page, marker)
regen = page.locator("#stale-regenerate")
expect(regen).to_be_enabled()
regen.click()
# In flight: the redo owns the Send/Stop control…
expect(page.locator("#send-label")).to_have_text("Stop", timeout=5_000)
# …and the double-click guard holds (one regenerate at a time).
expect(regen).to_be_disabled()
# Redo in place: the OLD wrap is already gone (the phase-49
# contract — removal precedes the rerun).
expect(page.locator(f"[data-retry-marker='{marker}']")).to_have_count(0)
# The fresh answer streams into its place and the turn settles.
expect(page.locator(ANSWER).last).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
_assert_no_error_banner(page)
# Post-turn: the linked row was re-saved (the server re-stamped
# it), so the banner cleared and the outcome is announced.
expect(page.locator("#stale-banner")).to_be_hidden(timeout=15_000)
expect(page.locator("#send-status")).to_have_text(REGEN_STATUS)
# ---------------------------------------------------------------------------
# 1. The full loop: save (fresh) → KB change → stale surfaced → Regenerate
# → fresh again (UI + API agree at every step)
# ---------------------------------------------------------------------------
def test_full_invalidation_loop(
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? (stale-loop)"
_ask(page, q)
# --- Save (fresh): the API agrees, and a fresh open shows NO banner.
_save(page)
cookies = _admin_cookies(page)
row = _find_row(_chats(app_url, cookies), _auto_title(q))
assert row is not None, "the saved chat row must exist"
assert row["message_count"] == 2
assert row["stale"] is False, "a just-saved chat is fresh, not stale"
chat_id: str = row["id"]
# The DB agrees: Save stamped the row with the CURRENT generation
# (helper read — the integration suite pins the same contract).
assert _row_stamp(chat_id) == _current_version(), (
"Save must stamp sources_version with the current generation"
)
try:
# A fresh row opened at /?chat=<id> shows NO stale banner.
page.goto(app_url + f"/?chat={chat_id}")
expect(page.locator(".msg.user .bubble")).to_have_count(1)
expect(page.locator(".msg.user .bubble")).to_contain_text(q)
expect(page.locator(ANSWER)).to_have_count(1)
expect(page.locator("#stale-banner")).to_be_hidden()
expect(page.locator("#stale-regenerate")).to_be_hidden()
# --- KB change: the version bumps (the test-only stand-in for a
# KB-changing sync — see the module docstring / task ASSUMPTION).
bumped = _bump_sources_version()
assert bumped > 0
# The API agrees the row is stale NOW (server-computed — both
# the detail and the list shapes carry the flag):
detail = _chat(app_url, cookies, chat_id)
assert detail["stale"] is True
mine = _find_row(_chats(app_url, cookies), _auto_title(q))
assert mine is not None and mine["stale"] is True
# --- History: the row carries the rose Stale pill (and its
# aria-label, so the marker is conveyed without the visual).
page.goto(app_url + "/history.html")
tr = _history_row(page, chat_id)
expect(tr.locator("a.history-title-link")).to_be_visible(timeout=15_000)
pill = tr.locator(".stale-pill")
expect(pill).to_have_count(1)
expect(pill).to_have_text("Stale")
expect(tr.locator(".history-stale-cell")).to_have_attribute(
"aria-label", STALE_CELL_ARIA
)
# --- Open the row (the SAME URL the History table links): the
# banner reveals, with the Regenerate button.
tr.locator("a.history-title-link").click()
banner = page.locator("#stale-banner")
expect(banner).to_be_visible(timeout=15_000)
expect(banner).to_have_attribute("role", "status")
expect(banner).to_contain_text(BANNER_TEXT)
expect(page.locator("#stale-regenerate")).to_have_text("Regenerate")
# The conversation restored in full (one question, one answer).
expect(page.locator(".msg.user .bubble")).to_have_count(1)
expect(page.locator(ANSWER)).to_have_count(1)
# --- Regenerate: the fresh answer streams in place against the
# new index, and the handler re-saves the linked row.
_regenerate_in_place(page, "stale-old")
# Redo mechanics: the question was NOT duplicated, the fresh
# answer stands alone in the last bubble (it quotes the
# question — the mock is deterministic).
expect(page.locator(".msg.user .bubble")).to_have_count(1)
expect(page.locator(ANSWER)).to_have_count(1)
expect(page.locator(ANSWER).last).to_contain_text(q)
expect(page.locator("#stale-regenerate")).to_be_enabled()
# --- The API agrees the row is fresh again, with the fresh
# answer as its last brain message (the re-save re-stamped the
# row — the DB agrees).
detail = _chat(app_url, cookies, chat_id)
assert detail["stale"] is False, "the re-saved row must be fresh"
msgs = detail["messages"]
assert [m["who"] for m in msgs] == ["user", "brain"], (
"the re-save must not duplicate the question"
)
assert msgs[0]["text"] == q
assert MOCK_ANSWER_MARKER in msgs[-1]["text"]
assert q in msgs[-1]["text"], "the fresh answer quotes the re-asked question"
assert _row_stamp(chat_id) == _current_version(), (
"the Regenerate re-save must re-stamp sources_version"
)
# --- History: the Stale pill is gone (the em-dash returns).
page.goto(app_url + "/history.html")
tr = _history_row(page, chat_id)
expect(tr.locator("a.history-title-link")).to_be_visible(timeout=15_000)
expect(tr.locator(".stale-pill")).to_have_count(0)
expect(tr.locator(".history-stale-cell")).to_have_text(FRESH_STALE_CELL)
finally:
_delete_chat(app_url, cookies, chat_id)
# ---------------------------------------------------------------------------
# 2. Guard: a stale chat with NO brain record reveals the banner text
# WITHOUT the Regenerate button (nothing to regenerate)
# ---------------------------------------------------------------------------
def test_stale_chat_without_brain_answer_is_text_only(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
js_errors: list[str] = []
page.on("pageerror", lambda e: js_errors.append(str(e)))
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
cookies = _admin_cookies(page)
# A user-only saved conversation — the same wire shape the Save pill
# posts (auto-title from the first user message).
q = "How is my Kubernetes cluster set up? (stale-nobrain)"
r = httpx.post(
f"{app_url}/api/chats",
timeout=10,
cookies=cookies,
json={"messages": [{"who": "user", "text": q}]},
)
assert r.status_code == 201, r.text
chat_id = r.json()["id"]
assert r.json()["stale"] is False # freshly stamped on create
try:
_bump_sources_version()
assert _chat(app_url, cookies, chat_id)["stale"] is True
# Opening the stale user-only row: the banner reveals…
page.goto(app_url + f"/?chat={chat_id}")
banner = page.locator("#stale-banner")
expect(banner).to_be_visible(timeout=15_000)
expect(banner).to_contain_text(BANNER_TEXT)
# …but the Regenerate button is REMOVED — with no brain record
# there is nothing to regenerate (retryLastTurn is never called).
expect(page.locator("#stale-regenerate")).to_have_count(0)
# The conversation restored: the user question, no brain bubble.
expect(page.locator(".msg.user .bubble")).to_have_count(1)
expect(page.locator(".msg.user .bubble")).to_contain_text(q)
expect(page.locator(ANSWER)).to_have_count(0)
assert not js_errors, f"the text-only banner must not throw: {js_errors}"
finally:
_delete_chat(app_url, cookies, chat_id)
# ---------------------------------------------------------------------------
# 3. Anonymous: the shared (now fresh) chat renders at /shared/<token>
# with NO staleness surface — neither in the DOM nor on the public
# SharedChatOut wire (phase 51 unchanged)
# ---------------------------------------------------------------------------
def test_anonymous_shared_snapshot_has_no_staleness_surface(
page: Page,
browser: Browser,
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? (stale-share)"
_ask(page, q)
_save(page)
cookies = _admin_cookies(page)
row = _find_row(_chats(app_url, cookies), _auto_title(q))
assert row is not None
chat_id: str = row["id"]
anon_ctx: BrowserContext | None = None
try:
# Make the chat stale, then bring it back CURRENT through the
# full Regenerate loop — the chat shared below is fresh.
_bump_sources_version()
page.goto(app_url + f"/?chat={chat_id}")
expect(page.locator("#stale-banner")).to_be_visible(timeout=15_000)
_regenerate_in_place(page, "stale-share-old")
assert _chat(app_url, cookies, chat_id)["stale"] is False
# Share from the History row's Share column (Create link → the
# cell re-renders to Copy + Unshare).
page.goto(app_url + "/history.html")
tr = _history_row(page, chat_id)
create = tr.locator("button.history-share-create")
expect(create).to_be_visible(timeout=15_000)
create.click()
expect(tr.locator("button.history-share-copy")).to_be_visible(timeout=15_000)
share_url = _chat(app_url, cookies, chat_id).get("share_url")
assert share_url is not None, "the Create link must have shared the row"
token = share_url.removeprefix("/shared/")
# --- The FRESH anonymous context (no session): the phase-51
# snapshot renders in full…
anon_ctx = browser.new_context()
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + share_url)
expect(anon.locator("#shared-title")).to_have_text(_auto_title(q))
expect(anon.locator(".msg.user .bubble")).to_have_count(1)
expect(anon.locator(".msg.user .bubble")).to_contain_text(q)
expect(anon.locator(".msg.brain .bubble").first).to_contain_text(
MOCK_ANSWER_MARKER, timeout=30_000
)
# …with NO staleness surface anywhere: none of the phase-53
# elements exist on the shared page, and the banner's line never
# appears (the snapshot is frozen by design — phase 51). (The
# check is on the UI strings, not the bare word: the question
# text is quoted into the mock's answer and is data, not a
# staleness surface.)
stale_selectors = (
"#stale-banner, #stale-regenerate, "
".stale-banner, .stale-pill, .stale-regenerate"
)
expect(anon.locator(stale_selectors)).to_have_count(0)
body_text = anon.locator("body").inner_text().lower()
assert "sources have been updated since this chat was saved" not in body_text, (
"the shared snapshot must carry no staleness surface"
)
anon_ctx.close()
anon_ctx = None
# …and the public wire agrees: the anonymous read carries
# title + messages ONLY (no stale flag, no staleness surface).
r = httpx.get(f"{app_url}/api/shared/{token}", timeout=10)
assert r.status_code == 200
body = r.json()
assert set(body) == {"title", "messages"}, (
f"the public snapshot must stay minimal: {set(body)}"
)
assert "stale" not in body
assert body["title"] == _auto_title(q)
assert MOCK_ANSWER_MARKER in body["messages"][-1]["text"]
finally:
if anon_ctx is not None:
anon_ctx.close()
_delete_chat(app_url, cookies, chat_id)
+191 -4
View File
@@ -10,7 +10,11 @@ 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.
``updated_at`` bump), the delete 404/204, and (phase 53, task 03) the
sources-version stamp + ``stale`` flag: create and re-Save stamp the
row's ``sources_version``, list/detail expose ``stale`` (true iff the
stamp is behind the current generation — computed server-side), and
share/unshare stay version-immune.
Requires: podman compose up -d db
"""
@@ -31,7 +35,8 @@ from sqlalchemy.orm import Session
from app.config import Settings
from app.main import app as fastapi_app
from app.models import SavedChat
from app.models import SavedChat, SourcesMeta
from app.rag.sources_meta import bump_sources_version
FIRST_QUESTION = "How did I install gitlab?"
EXPLICIT_TITLE = "My backup notes"
@@ -62,8 +67,16 @@ FULL_BRAIN: dict[str, Any] = {
"stopped": False,
}
OUT_KEYS = {"id", "title", "created_at", "updated_at", "message_count", "messages"}
ROW_KEYS = {"id", "title", "updated_at", "message_count"}
OUT_KEYS = {
"id",
"title",
"created_at",
"updated_at",
"message_count",
"messages",
"stale", # phase 53: server-computed staleness flag
}
ROW_KEYS = {"id", "title", "updated_at", "message_count", "stale"}
@pytest.fixture(autouse=True)
@@ -76,6 +89,45 @@ def clean_chats(db: Session) -> Iterator[None]:
db.commit()
@pytest.fixture(autouse=True)
def seeded_sources_meta(db: Session) -> Iterator[None]:
"""The ``sources_meta`` counter (phase 53) is global state: reset
to the migration-0010 seed (id 1, version 0 — "the pre-counter
KB") around every test, so each test starts from a known
generation and the dev DB is left exactly as the migration left
it."""
db.execute(text("DELETE FROM sources_meta"))
db.add(SourcesMeta(id=1, version=0))
db.commit()
yield
db.execute(text("DELETE FROM sources_meta"))
db.add(SourcesMeta(id=1, version=0))
db.commit()
def _bump(db: Session) -> int:
"""One simulated KB-changing sync: a single committed bump
(task 02's change-gate lives in the sync paths themselves; the API
tests only need the counter's contract — caller commits).
"""
version = bump_sources_version(db)
db.commit() # the helper only flushes
return version
def _stored_sources_version(db: Session, chat_id: str) -> int:
"""The row's ``sources_version`` via raw SQL — deliberately
bypassing the session's identity map, because the API's commits
land in the app's own sessions (a cached ORM object could be
stale)."""
return int(
db.execute(
text("SELECT sources_version FROM saved_chats WHERE id = :id"),
{"id": chat_id},
).scalar_one()
)
def _user(text: str) -> dict[str, Any]:
return {"who": "user", "text": text}
@@ -716,6 +768,141 @@ def test_get_carry_share_url_and_unshare_drops_it(admin_client: TestClient) -> N
assert set(got2) == OUT_KEYS
# ---------- sources-version stamp + stale flag (phase 53, task 03) ----------
def test_create_stamps_current_sources_version(
admin_client: TestClient, db: Session
) -> None:
"""``POST`` stamps the PENDING row with the current generation —
it ships in the same INSERT (the ``share_token`` precedent), and
the 201 body reports ``stale: false`` (a fresh save is by
definition current)."""
created = admin_client.post(
"/api/chats", json={"messages": _simple_conversation()}
).json()
assert created["stale"] is False, "a fresh save is never stale"
assert _stored_sources_version(db, created["id"]) == 0 # seed generation
# After a KB-changing sync (one bump), the NEXT save stamps the
# new generation and is still fresh.
assert _bump(db) == 1
created2 = admin_client.post(
"/api/chats", json={"messages": [_user("another question")]}
).json()
assert _stored_sources_version(db, created2["id"]) == 1
assert created2["stale"] is False
def test_pre_counter_row_goes_stale_on_the_first_bump(
admin_client: TestClient, db: Session
) -> None:
"""Recorded assumption: pre-existing rows (saved before the counter
existed) stamp 0 — "the pre-counter KB" — and go stale on the
first bump (0 < 1). A row inserted directly with the column's
server default mirrors such a legacy row."""
db.add(SavedChat(title="legacy", messages=[_user("old question")]))
db.commit() # no sources_version supplied → server default 0
listing = admin_client.get("/api/chats").json()["chats"][0]
assert listing["stale"] is False, "0 == 0: current at the pre-counter KB"
assert _bump(db) == 1
listing = admin_client.get("/api/chats").json()["chats"][0]
assert listing["stale"] is True, "0 < 1: the first bump stale-s it"
def test_list_and_detail_report_stale_after_bump(admin_client: TestClient, db: Session) -> None:
"""The staleness flag is computed server-side in BOTH admin read
shapes: after a bump, the list row and the detail payload of a
row saved at the older generation report ``stale: true`` (and a
second bump — still behind — stays stale)."""
created = admin_client.post(
"/api/chats", json={"messages": _simple_conversation()}
).json()
assert created["stale"] is False
assert _bump(db) == 1
row = admin_client.get("/api/chats").json()["chats"][0]
assert row["stale"] is True
assert set(row) == ROW_KEYS # stale sits in the standard row shape
detail = admin_client.get(f"/api/chats/{created['id']}").json()
assert detail["stale"] is True
assert set(detail) == OUT_KEYS
assert _bump(db) == 2 # still behind (stamp 0 < 2) → still stale
assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is True
def test_resave_restamps_and_clears_stale(admin_client: TestClient, db: Session) -> None:
"""A Re-Save (``PUT``) re-stamps the row to the CURRENT generation
unconditionally — the owner is affirming this content against the
current KB — so the 200 body reports ``stale: false`` again and
the stored stamp advances (the manual escape hatch for a
false-positive stale row)."""
created = admin_client.post(
"/api/chats", json={"messages": _simple_conversation()}
).json()
assert _bump(db) == 1
assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is True
r = admin_client.put(
f"/api/chats/{created['id']}", json={"messages": _simple_conversation()}
)
assert r.status_code == 200
assert r.json()["stale"] is False, "the re-Save affirms against generation 1"
assert _stored_sources_version(db, created["id"]) == 1
# A later bump stale-s it again — the stamp is a point in time, not
# a sticky flag.
assert _bump(db) == 2
assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is True
def test_share_and_unshare_leave_sources_version_untouched(
admin_client: TestClient, db: Session
) -> None:
"""Share/unshare write ONLY ``share_token`` (raw SQL, the
phase-51 contract) — the version stamp, like ``updated_at``, is
immune: neither action can (un-)stale a chat, and staleness stays
a pure function of the saved generation vs the current one."""
assert _bump(db) == 1 # a non-zero stamp makes the assert observable
created = admin_client.post(
"/api/chats", json={"messages": _simple_conversation()}
).json()
assert _stored_sources_version(db, created["id"]) == 1
_share(admin_client, created["id"])
assert _stored_sources_version(db, created["id"]) == 1
assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is False
assert admin_client.post(f"/api/chats/{created['id']}/unshare").status_code == 200
assert _stored_sources_version(db, created["id"]) == 1
assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is False
def test_public_read_snapshot_has_no_stale_surface(
admin_client: TestClient, db: Session,
) -> None:
"""``/api/shared/<token>`` is a FROZEN snapshot by design (phase
51): even after a bump, the anonymous body keeps exactly its
title+messages key set — no ``stale`` flag, no staleness surface
(an owner who regenerates can re-share afterwards)."""
created = admin_client.post(
"/api/chats", json={"messages": _simple_conversation(), "share": True}
).json()
assert _bump(db) == 1 # the saved chat is now stale (admin surface)
assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is True
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
r = anon.get(f"/api{created['share_url']}")
assert r.status_code == 200
body = r.json()
assert set(body) == SHARED_OUT_KEYS # no stale key — frozen snapshot
assert "stale" not in body
# ---------- the /shared/<token> page route (phase 51, task 01) ----------
+33 -3
View File
@@ -4,7 +4,9 @@ kind).
Drives ``scripts.import_docs`` end to end with a fake ``clone_or_pull``
(no real git, no network) and a recording fake ``import_sources`` (no
real DB), covering:
real DB), covering: the phase-53 version bump is stubbed in the
``main()`` tests the same way (the ``sources_version=`` summary token
is asserted against the canned value).
- Effective sources set (phase 35: the shared resolver — stubbed here,
keeping this file's no-real-DB style) → each git URL is cloned/pulled
@@ -83,6 +85,20 @@ def _fake_clone_factory() -> tuple[list[tuple[str, Path]], object]:
return calls, fake_clone_or_pull
def _stub_bump(monkeypatch: pytest.MonkeyPatch) -> list[None]:
"""Stub the phase-53 version bump (this file keeps its no-real-DB
style for the counter — the fake import already avoids the KB
tables). Returns the call record; the canned new version is 1."""
bumps: list[None] = []
def fake_bump(session: object) -> int:
bumps.append(None)
return 1
monkeypatch.setattr(import_docs, "bump_sources_version", fake_bump)
return bumps
# --- repo_name -------------------------------------------------------------
@@ -210,6 +226,7 @@ def test_main_git_sources_clone_then_import(
monkeypatch.setattr(import_docs, "clone_or_pull", fake)
fake_import = FakeImportSources()
monkeypatch.setattr(import_docs, "import_sources", fake_import)
bumps = _stub_bump(monkeypatch)
rc = import_docs.main([])
@@ -227,11 +244,19 @@ def test_main_git_sources_clone_then_import(
for dest in (tmp_path / "bor" / "homelab", tmp_path / "bor" / "deploy"):
assert (dest / "notes.md").is_file()
# The final summary print reflects the import (added > 0).
assert "added=1" in capsys.readouterr().out
out = capsys.readouterr().out
assert "added=1" in out
# Phase 53: a KB-changing run bumps the sources version exactly
# once and reports it (stubbed — this file keeps its no-real-DB
# style for the counter, like the fake import above).
assert len(bumps) == 1
assert "sources_version=1" in out
def test_main_cli_source_still_imports_manual_dir(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
manual = tmp_path / "manual"
manual.mkdir()
@@ -243,6 +268,7 @@ def test_main_cli_source_still_imports_manual_dir(
monkeypatch.setattr(import_docs, "clone_or_pull", fake)
fake_import = FakeImportSources()
monkeypatch.setattr(import_docs, "import_sources", fake_import)
bumps = _stub_bump(monkeypatch)
rc = import_docs.main(["--source", str(manual)])
@@ -250,6 +276,10 @@ def test_main_cli_source_still_imports_manual_dir(
assert calls == []
assert fake_import.calls[0]["sources"] == [manual]
assert fake_import.calls[0]["prune"] is False
# Phase 53: a manual --source run that changes the KB bumps exactly
# once (the CLI is the other canonical sync path).
assert len(bumps) == 1
assert "sources_version=1" in capsys.readouterr().out
def test_resolve_sources_mixed_git_and_local(
+84 -9
View File
@@ -12,6 +12,17 @@ covering the change-gated overview trigger:
``overview=failed``, the previous row untouched;
- a ``--limit`` debug run with changes → ``overview=skipped``;
- an empty source run with no row → no row created, ``overview=skipped``.
Phase 53 (task 02): the sources-version bump sits alongside the
overview gate — a KB-changing run bumps ``sources_meta`` exactly once
(``sources_version=<n>`` on the summary line), including a **prune-only**
run: the invalidation gate ``added + updated + pruned > 0`` is
deliberately broader than the overview's (a pruned doc can invalidate a
saved answer that cited it, while the outline stays). ``--limit`` runs
and unchanged re-runs never bump (``sources_version=skipped``), and a
failed ``lite`` never rolls the bump back. The counter is pinned to the
migration-0010 seed (0) around every test by
:func:`_reset_sources_version`.
"""
from __future__ import annotations
@@ -24,8 +35,10 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.config import Settings
from app.db import SessionLocal, db_available
from app.models import KbOverview
from app.rag.llm import LLMError
from app.rag.sources_meta import current_sources_version
from scripts import import_docs
from tests.fakes import FakeEmbedder
@@ -44,6 +57,12 @@ def _row(db: Session) -> KbOverview | None:
return db.get(KbOverview, 1)
def _version(db: Session) -> int:
"""The ``sources_meta`` generation (phase 53; freshly reloaded)."""
db.expire_all()
return current_sources_version(db)
def _run_main(
monkeypatch: pytest.MonkeyPatch,
llm: FakeEmbedder,
@@ -86,6 +105,26 @@ def _clean_kb(db: Session) -> Iterator[None]:
db.commit()
@pytest.fixture(autouse=True)
def _reset_sources_version() -> Iterator[None]:
"""Phase 53: the sources version counter is global mutable state —
pin it to the migration-0010 seed (0) around every test so the bump
assertions start from a known generation (own session: the CLI
bumps through its own short-lived ``SessionLocal``). Skips like the
``db`` fixture when Postgres is down."""
if not db_available():
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
session = SessionLocal()
try:
session.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
session.commit()
yield
finally:
session.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
session.commit()
session.close()
def test_changed_import_writes_overview_row(
db: Session,
src: Path,
@@ -110,7 +149,8 @@ def test_changed_import_writes_overview_row(
assert rc == 0
assert "added=2" in out
assert out.rstrip().endswith("overview=updated")
assert out.rstrip().endswith("overview=updated sources_version=1")
assert _version(db) == 1 # phase 53: a changed import bumps exactly once
# Exactly one lite call — the overview itself (markdown files never
# get a summary, so nothing else may touch ``chat``).
assert len(llm.chat_calls) == 1
@@ -137,7 +177,7 @@ def test_unchanged_reimport_does_not_call_lite(
llm = FakeEmbedder()
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
assert rc == 0
assert out.rstrip().endswith("overview=updated")
assert out.rstrip().endswith("overview=updated sources_version=1")
assert len(llm.chat_calls) == 1
assert _row(db) is not None
@@ -145,10 +185,11 @@ def test_unchanged_reimport_does_not_call_lite(
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
assert rc == 0
assert "unchanged=2" in out
assert out.rstrip().endswith("overview=skipped")
assert out.rstrip().endswith("overview=skipped sources_version=skipped")
assert len(llm.chat_calls) == 1 # no new lite call
row = _row(db)
assert row is not None and row.content == "Summary of MyDocs"
assert _version(db) == 1 # phase 53: an unchanged re-run never bumps
def test_lite_failure_is_fail_soft(
@@ -160,7 +201,7 @@ def test_lite_failure_is_fail_soft(
good = FakeEmbedder()
rc, out = _run_main(monkeypatch, good, ["--source", str(src)], capsys)
assert rc == 0
assert out.rstrip().endswith("overview=updated")
assert out.rstrip().endswith("overview=updated sources_version=1")
previous = _row(db)
assert previous is not None
previous_content = previous.content
@@ -172,11 +213,14 @@ def test_lite_failure_is_fail_soft(
rc, out = _run_main(monkeypatch, bad, ["--source", str(src)], capsys)
assert rc == 0 # a failed outline must not fail the import
assert "updated=1" in out
assert out.rstrip().endswith("overview=failed")
assert out.rstrip().endswith("overview=failed sources_version=2")
assert len(bad.chat_calls) == 1 # the (failed) attempt was made
row = _row(db)
assert row is not None
assert row.content == previous_content # previous row untouched
# Phase 53: the bump commits independently of the best-effort
# overview — a failed ``lite`` never rolls the version back.
assert _version(db) == 2
def test_limit_run_skips_overview(
@@ -188,19 +232,20 @@ def test_limit_run_skips_overview(
llm = FakeEmbedder()
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
assert rc == 0
assert out.rstrip().endswith("overview=updated")
assert out.rstrip().endswith("overview=updated sources_version=1")
assert len(llm.chat_calls) == 1
# An incomplete walk must not rewrite the outline (mirrors the
# --prune-with---limit guard).
# --prune-with---limit guard) — and must not advance the version.
(src / "alpha.md").write_text("# Alpha\n\nChanged content.\n", encoding="utf-8")
rc, out = _run_main(monkeypatch, llm, ["--source", str(src), "--limit", "1"], capsys)
assert rc == 0
assert "updated=1" in out
assert out.rstrip().endswith("overview=skipped")
assert out.rstrip().endswith("overview=skipped sources_version=skipped")
assert len(llm.chat_calls) == 1 # --limit never burns a lite call
row = _row(db)
assert row is not None and row.content == "Summary of MyDocs"
assert _version(db) == 1 # phase 53: --limit debug runs never bump
def test_empty_source_without_row_creates_nothing(
@@ -217,6 +262,36 @@ def test_empty_source_without_row_creates_nothing(
assert rc == 0
assert "files=0" in out
assert out.rstrip().endswith("overview=skipped")
assert out.rstrip().endswith("overview=skipped sources_version=skipped")
assert llm.chat_calls == [] # no KB → no outline, no wasted model call
assert _row(db) is None # nothing created
assert _version(db) == 0 # nothing changed → nothing bumped
def test_prune_only_run_bumps_sources_version(
db: Session,
src: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Phase 53 (task 02): the invalidation gate is deliberately broader
than the overview's — a prune-only run (added + updated == 0,
pruned > 0) advances the version (a pruned document can invalidate
a saved answer that cited it) while the outline stays.
"""
llm = FakeEmbedder()
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
assert rc == 0
assert "added=2" in out
assert out.rstrip().endswith("overview=updated sources_version=1")
assert _version(db) == 1
# Delete one file; a --prune run drops exactly it: no add/update,
# but pruned=1 → the version still bumps while the overview skips.
(src / "alpha.md").unlink()
rc, out = _run_main(monkeypatch, llm, ["--source", str(src), "--prune"], capsys)
assert rc == 0
assert "pruned=1" in out
assert out.rstrip().endswith("overview=skipped sources_version=2")
assert _version(db) == 2 # the prune-only change bumped exactly once
assert _row(db) is not None # the outline row is untouched
+260
View File
@@ -0,0 +1,260 @@
"""Integration: migration 0010 (sources_meta + saved_chats.sources_version).
Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the house pattern of
``test_migration_0009.py`` (information_schema / pg catalog assertions
on the state the migration must leave). The tests target revision
``0010`` explicitly so later migrations cannot break them:
* upgrade 0009 → 0010 → the single-row ``sources_meta`` table exists
(``id`` Integer PK default 1, ``version`` Integer NOT NULL default 0,
``updated_at`` TIMESTAMPTZ NOT NULL default now()) with its **seed
row** (id 1, version 0), and ``saved_chats.sources_version`` is
Integer NOT NULL default 0 — a pre-0010 row comes back stamped 0
(the pre-counter KB, phase-53 locked decision 2);
* inserted rows round-trip the stamp (default and explicit);
* downgrade to 0009 → column + table gone (A13 — reversible), the rest
of ``saved_chats`` survives;
* upgrade back to 0010 → table, seed row, and column are all 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 uuid
from collections.abc import Iterator
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
@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 _version(db: Session) -> str | None:
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
def _table_exists(db: Session, table: str) -> bool:
count: Any = db.execute(
text(
"SELECT count(*) FROM information_schema.tables"
" WHERE table_schema = 'public' AND table_name = :t"
),
{"t": table},
).scalar()
assert count is not None, "information_schema count must be an int"
return int(count) == 1
def _column(db: Session, table: str, column: str) -> tuple[Any, ...] | None:
"""(data_type, is_nullable, column_default) for one table column."""
row = db.execute(
text(
"SELECT data_type, is_nullable, column_default"
" FROM information_schema.columns"
" WHERE table_name = :t AND column_name = :c"
),
{"t": table, "c": column},
).fetchone()
return tuple(row) if row is not None else None
def _seed_row(db: Session) -> tuple[int, int] | None:
"""The (id, version) of the ``sources_meta`` row with id 1."""
row = db.execute(
text("SELECT id, version FROM sources_meta WHERE id = 1")
).fetchone()
return tuple(row) if row is not None else None
def _insert(db: Session, version: int | None) -> uuid.UUID:
"""Insert one saved_chats row, optionally with an explicit stamp."""
if version is None:
sql = (
"INSERT INTO saved_chats (id, title, messages)"
" VALUES (gen_random_uuid(), :t, CAST(:m AS jsonb))"
" RETURNING id"
)
params: dict[str, Any] = {}
else:
sql = (
"INSERT INTO saved_chats (id, title, messages, sources_version)"
" VALUES (gen_random_uuid(), :t, CAST(:m AS jsonb), :v)"
" RETURNING id"
)
params = {"v": version}
params.update(
{"t": "Mig 0010", "m": '[{"who": "user", "text": "How did I install gitlab?"}]'}
)
chat_id: uuid.UUID = db.execute(text(sql), params).scalar_one()
db.commit()
return chat_id
def _legacy_insert(db: Session) -> uuid.UUID:
"""Insert one row WITHOUT the ``sources_version`` column — the only
possible shape at revision 0009 (the column does not exist yet)."""
chat_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": "Mig 0010",
"m": '[{"who": "user", "text": "How did I install gitlab?"}]',
},
).scalar_one()
db.commit()
return chat_id
def _delete(db: Session, chat_id: uuid.UUID) -> None:
db.execute(text("DELETE FROM saved_chats WHERE id = :i"), {"i": chat_id})
db.commit()
def test_upgrade_to_0010_adds_sources_meta_and_stamp(
db: Session, alembic: Config
) -> None:
"""Upgrade 0009 → 0010: the seeded counter table and the NOT NULL
stamp column exist; a pre-0010 row comes back stamped 0 (the
pre-counter KB)."""
command.downgrade(alembic, "0009") # start from the pre-0010 state
assert _version(db) == "0009"
assert not _table_exists(db, "sources_meta"), "sources_meta must be absent at 0009"
assert _column(db, "saved_chats", "sources_version") is None, (
"sources_version must be absent at 0009"
)
# A pre-0010 row (no sources_version in the INSERT — the column does
# not exist at 0009): its data must survive the additive migration.
legacy = _legacy_insert(db)
try:
command.upgrade(alembic, "0010")
assert _version(db) == "0010", "alembic_version must be at 0010"
id_col = _column(db, "sources_meta", "id")
assert id_col is not None, "sources_meta.id is missing"
assert id_col[0] == "integer", "sources_meta.id must be INTEGER"
assert id_col[1] == "NO", "sources_meta.id must be NOT NULL (PK)"
assert id_col[2] == "1", "sources_meta.id must default to 1"
ver_col = _column(db, "sources_meta", "version")
assert ver_col is not None, "sources_meta.version is missing"
assert ver_col[0] == "integer", "sources_meta.version must be INTEGER"
assert ver_col[1] == "NO", "sources_meta.version must be NOT NULL"
assert ver_col[2] == "0", "sources_meta.version must default to 0"
updated = _column(db, "sources_meta", "updated_at")
assert updated is not None, "sources_meta.updated_at is missing"
assert updated[0] == "timestamp with time zone", (
"sources_meta.updated_at must be TIMESTAMPTZ"
)
assert updated[1] == "NO", "sources_meta.updated_at must be NOT NULL"
assert str(updated[2]).startswith("now("), (
"sources_meta.updated_at must have server default now()"
)
assert _seed_row(db) == (1, 0), "the seed row (id 1, version 0) is missing"
stamp = _column(db, "saved_chats", "sources_version")
assert stamp is not None, "saved_chats.sources_version is missing"
assert stamp[0] == "integer", "sources_version must be INTEGER"
assert stamp[1] == "NO", "sources_version must be NOT NULL"
assert stamp[2] == "0", "sources_version must default to 0"
row = db.execute(
text("SELECT title, sources_version FROM saved_chats WHERE id = :i"),
{"i": legacy},
).fetchone()
assert row is not None, "the pre-0010 row must survive the upgrade"
assert row[1] == 0, "a pre-0010 row must upgrade stamped 0 (pre-counter KB)"
finally:
_delete(db, legacy)
def test_inserted_rows_round_trip_the_stamp(db: Session, alembic: Config) -> None:
"""At 0010, an omitted stamp defaults to 0 and an explicit stamp
round-trips verbatim."""
command.upgrade(alembic, "head")
default_id = _insert(db, None)
explicit_id = _insert(db, 7)
try:
rows = db.execute(
text("SELECT sources_version FROM saved_chats WHERE id IN (:a, :b)"),
{"a": default_id, "b": explicit_id},
).all()
stamps = {row[0] for row in rows}
assert stamps == {0, 7}, "default stamp 0 and explicit stamp 7 must round-trip"
default_stamp = db.execute(
text("SELECT sources_version FROM saved_chats WHERE id = :i"),
{"i": default_id},
).scalar_one()
assert default_stamp == 0, "an omitted stamp must default to 0"
finally:
_delete(db, default_id)
_delete(db, explicit_id)
def test_downgrade_to_0009_drops_both(db: Session, alembic: Config) -> None:
"""Downgrade to 0009: the stamp column and the counter table are
gone (A13 — reversible) while the rest of ``saved_chats`` survives."""
command.downgrade(alembic, "0009")
assert _version(db) == "0009"
assert _column(db, "saved_chats", "sources_version") is None, (
"sources_version must be dropped"
)
assert not _table_exists(db, "sources_meta"), "sources_meta must be dropped"
id_col = _column(db, "saved_chats", "id")
assert id_col is not None and id_col[0] == "uuid", (
"saved_chats.id must survive the downgrade"
)
token_col = _column(db, "saved_chats", "share_token")
assert token_col is not None and token_col[0] == "uuid", (
"saved_chats.share_token must survive the downgrade"
)
def test_upgrade_round_trip_restores_both(db: Session, alembic: Config) -> None:
"""Downgrade to 0009, then upgrade back to 0010: the counter table
(with a fresh seed row) and the stamp column are back."""
command.downgrade(alembic, "0009")
command.upgrade(alembic, "0010")
assert _version(db) == "0010", "round-trip upgrade must land at 0010"
assert _table_exists(db, "sources_meta"), "sources_meta must be back"
assert _seed_row(db) == (1, 0), "the seed row must be re-seeded on upgrade"
stamp = _column(db, "saved_chats", "sources_version")
assert stamp is not None, "sources_version must be back after the round-trip"
assert stamp[0] == "integer" and stamp[1] == "NO", (
"sources_version must be INTEGER NOT NULL after the round-trip"
)
assert stamp[2] == "0", "sources_version must default to 0 after the round-trip"
+42
View File
@@ -39,6 +39,15 @@ real ``LLMClient`` the probe is stubbed (:func:`_stub_probe`) so no
test ever hits the network; the ``_real_llm`` tests get a passing
probe from ``FakeEmbedder.embed_one``/``chat``.
Phase 53 (task 02): the sources-version bump — a sync whose import
changed the KB (added + updated + pruned > 0) advances the single-row
``sources_meta`` counter exactly once (the new generation lands in the
``/api/sync/status`` detail as ``sources_version``); an unchanged
re-sync never bumps (the detail still reports the current generation),
and every failure path (git error, model down) never bumps. The
counter is pinned to the migration-0010 seed (0) around every test by
:func:`_reset_sources_version`.
The git / import / overview layers are monkeypatched in ``app.api.sync``
(same fake style as ``test_import_docs_git.py``) — no real git, no LLM:
the runner's state machine and HTTP surface are under test.
@@ -66,11 +75,13 @@ from sqlalchemy.orm import Session
from app.api import sync as sync_api
from app.config import Settings
from app.db import SessionLocal, db_available
from app.main import app as fastapi_app
from app.models import GitSource
from app.rag import git_sources as git_sources_resolver
from app.rag.importer import ImportSummary
from app.rag.llm import EmbeddingError, LLMClient, ModelUnavailableError
from app.rag.sources_meta import current_sources_version
from scripts.git_sync import GitSyncError
from tests.conftest import ADMIN_PASSWORD
from tests.fakes import FakeEmbedder
@@ -100,6 +111,26 @@ def clean_git_sources(db: Session) -> Iterator[None]:
db.commit()
@pytest.fixture(autouse=True)
def _reset_sources_version() -> Iterator[None]:
"""Phase 53: the sources version counter is global mutable state —
pin it to the migration-0010 seed (0) around every sync test so the
bump assertions start from a known generation (own session: the
runner bumps through its own short-lived ``SessionLocal``).
Skips like the ``db`` fixture when Postgres is down."""
if not db_available():
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
session = SessionLocal()
try:
session.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
session.commit()
yield
finally:
session.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
session.commit()
session.close()
@pytest.fixture()
def sync_client() -> Iterator[TestClient]:
"""Context-managed TestClient — one app event loop across requests
@@ -310,7 +341,10 @@ def test_admin_sync_success_reports_full_detail(
"files": 5, "added": 1, "updated": 2, "unchanged": 2, "pruned": 3,
"errors": 0, "chunks": 11, "summaries": 1, "summary_errors": 0,
"overview": True,
"sources_version": 1, # phase 53: changed KB → exactly one bump (0 → 1)
}
# The bump committed: the counter advanced exactly once, not twice.
assert current_sources_version(db) == 1
# Git: the configured repo was cloned into BOR_SOURCES_DIR/<repo-name>/.
assert clone_calls == [(repo_url, tmp_path / "bor" / "repo")]
# Import: exactly the checkouts, with prune=True (the button is the
@@ -354,6 +388,10 @@ def test_unchanged_kb_skips_overview_refresh(
assert body["detail"]["overview"] is False
assert fake_overview.llms == [] # no wasted model call
assert len(fake_import.llms) == 1 # the import itself ran
# Phase 53: an unchanged re-sync never bumps — the detail reports
# the current (unadvanced) generation.
assert body["detail"]["sources_version"] == 0
assert current_sources_version(db) == 0
# --- admin: concurrency ----------------------------------------------------
@@ -435,6 +473,8 @@ def test_git_failure_marks_failed_and_skips_import(
assert body["finished_at"] is not None
assert fake_import.sources == [] # no partial import
assert fake_overview.llms == []
# Phase 53: a FAILED sync never bumps — the version is untouched.
assert current_sources_version(db) == 0
# A failed run leaves the system restartable: a new POST is accepted.
assert sync_client.post("/api/sync").status_code == 202
@@ -773,6 +813,8 @@ def test_model_down_fails_fast_before_any_clone(
assert body["detail"] == {}
assert clone_calls == [] # fail fast: before any clone
assert fake_import.sources == [] # and before any import
# Phase 53: a FAILED sync (model down) never bumps.
assert current_sources_version(db) == 0
def test_probe_names_dead_embed_model_and_masks_credentials(
+98 -17
View File
@@ -17,7 +17,13 @@ regression is caught without a browser:
* ``#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 empty-state row;
* the Stale column (phase 53, task 04): the READ-ONLY marker cell in
``makeRow`` (the rose ``.stale-pill`` from the row's ``stale`` flag
+ the em-dash fallback, the ``<td>`` aria-label in BOTH states —
WCAG 2.1 AA, conveyed without the visual), the ``Stale`` ``<th>``
between Updated and Share in ``history.html``, and the ``.stale-pill``
rose-family CSS in ``styles.css``.
The Containerfile stage-1 coverage (history.html copied, history.js
bundled) is pinned dynamically by
@@ -177,35 +183,39 @@ def test_history_page_scaffold_and_landmarks() -> None:
def test_history_table_skeleton() -> None:
"""The table skeleton: ``.history-table`` with the five columns —
Title | Messages | Updated | Share (phase 51) | 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)."""
"""The table skeleton: ``.history-table`` with the six columns —
Title | Messages | Updated | Stale (phase 53) | Share (phase 51) |
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>', '<th scope="col">Share</th>'):
'<th scope="col">Updated</th>', '<th scope="col">Stale</th>',
'<th scope="col">Share</th>'):
assert col in html
# The Share column sits BETWEEN Updated and Actions.
# The Stale column (phase 53) sits BETWEEN Updated and Share —
# i.e. between Updated and Actions — so the phase-51 contract
# (Share between Updated and Actions) still holds.
assert (
html.find('<th scope="col">Updated</th>')
< html.find('<th scope="col">Stale</th>')
< html.find('<th scope="col">Share</th>')
< html.find('visually-hidden">Actions')
), "the Share column must sit between Updated and Actions"
), "the Stale column must sit between Updated and Share"
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 5 (the Share column
# joined the table in phase 51), the exact copy.
# The empty-state row: ship-hidden, colspan 6 (phase 51 added
# Share, phase 53 added Stale), 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=\"5\">" in html
assert "<td colspan=\"6\">" in html
assert (
"No saved chats yet — finish a conversation and press"
" <strong>Save</strong> in the chat."
@@ -446,20 +456,91 @@ def test_history_table_mobile_behavior() -> None:
def test_make_row_inserts_share_cell_between_updated_and_actions() -> None:
"""makeRow: the Share <td> (with the share control) lands BETWEEN
the Updated cell and the Actions cell — the column order in
history.html is Title | Messages | Updated | Share | Actions."""
history.html is Title | Messages | Updated | Stale (phase 53) |
Share | Actions."""
js = _js()
row = _fn(js, "makeRow")
updated_i = row.find('updatedTd.className = "history-updated-cell"')
stale_i = row.find('staleTd.className = "history-stale-cell"')
share_i = row.find('shareTd.className = "history-share-cell"')
actions_i = row.find('actionsTd.className = "history-actions-cell"')
assert -1 < updated_i < share_i < actions_i, (
"the share cell must sit between Updated and Actions"
assert -1 < updated_i < stale_i < share_i < actions_i, (
"the stale cell (phase 53) must sit between Updated and Share —"
" i.e. the share cell must still sit between Updated and Actions"
)
assert "makeShareControl(chat)" in row
seq = re.findall(r"tr\.appendChild\((\w+)\)", row)
assert seq == ["titleTd", "countTd", "updatedTd", "shareTd", "actionsTd"], (
f"row cell order must be title/count/updated/share/actions, got {seq}"
assert seq == [
"titleTd", "countTd", "updatedTd", "staleTd", "shareTd", "actionsTd",
], f"row cell order must be title/count/updated/stale/share/actions, got {seq}"
# ---------- the Stale column (phase 53, task 04) ----------
def test_stale_cell_branches_on_row_flag_with_aria_label() -> None:
"""makeRow (phase 53 task 04): the Stale cell renders from the
row's ``stale`` flag — the SERVER computes staleness (task 03),
the client never does version math. Stale rows: the rose
``.stale-pill`` (the ``Stale`` text + the EXACT hover copy pointing
at the Regenerate action on the chat page, task 05). Fresh rows:
a plain em-dash (no pill). The <td> carries its own aria-label in
BOTH states (WCAG 2.1 AA — the marker must be conveyed without the
visual). READ-ONLY badge: the cell binds no events and creates no
controls (the Regenerate button lives on the chat-page banner);
textContent only (XSS contract)."""
js = _js()
row = _fn(js, "makeRow")
assert 'staleTd.className = "history-stale-cell"' in row
assert "tr.appendChild(staleTd)" in row
assert "if (chat.stale)" in row, "the cell branches on the row's stale flag"
branch = row[row.find("if (chat.stale)") : row.find("tr.appendChild(staleTd)")]
# The stale branch: the rose pill with the exact hover copy.
assert 'pill.className = "stale-pill"' in branch
assert 'pill.textContent = "Stale"' in branch
assert (
'pill.title = "Sources have changed since this chat was saved'
" — open the chat to Regenerate\";"
) in branch, "the pill's hover copy points at the Regenerate action (task 05)"
# The fresh branch: the plain em-dash, never the pill.
else_i = branch.find("} else {")
assert else_i != -1, "the fresh branch must exist"
fresh = branch[else_i:]
assert 'staleTd.textContent = "—"' in fresh, "fresh rows render the em-dash"
assert "stale-pill" not in fresh, "fresh rows render the em-dash, not the pill"
# The <td> aria-label ships in BOTH states (conveyed without the
# visual — WCAG 2.1 AA).
assert branch.count('staleTd.setAttribute("aria-label"') == 2, (
"the cell's aria-label must exist in the stale AND the fresh branch"
)
# READ-ONLY: no events, no controls, no innerHTML anywhere in the
# cell's construction.
assert "addEventListener" not in branch
assert "createElement(\"button\")" not in branch
assert "innerHTML" not in branch
def test_stale_column_css_rose_family() -> None:
"""styles.css (phase 53 task 04): ``.stale-pill`` is the rose
family — the Stop-treatment tokens (err-ink on err-bg ≈9.3:1, the
err-line border), theme-token based so it stays AA with the
palette; a compact rounded pill (border-radius 999px, nowrap). The
``.history-stale-cell`` keeps the marker on one line and rides
ink-soft (5.1:1 on --surface) for the fresh rows' em-dash."""
css = _css()
pill = re.search(r"\.stale-pill \{([\s\S]*?)\n\}", css)
assert pill, "styles.css must style .stale-pill"
body = pill.group(1)
assert "background: var(--err-bg)" in body, "the Stop-treatment tokens"
assert "color: var(--err-ink)" in body
assert "border: 1px solid var(--err-line)" in body
assert "border-radius: 999px" in body, "the pill shape"
assert "white-space: nowrap" in body
cell = re.search(r"\.history-stale-cell \{([^}]*)\}", css)
assert cell, "the stale cell must be styled"
cbody = cell.group(1)
assert "white-space: nowrap" in cbody
assert "var(--ink-soft)" in cbody, "the em-dash rides ink-soft (AA on --surface)"
def test_share_control_three_states_and_two_step_unshare() -> None:
+224
View File
@@ -447,3 +447,227 @@ def test_share_button_revealed_only_for_admin() -> None:
assert boot_start < save_reveal < reveal, (
"the Share reveal joins the same admin-reveal block as Save"
)
# ---------- stale saved chat: banner + Regenerate (phase 53, task 05) ----------
def test_stale_banner_html_after_kb_banner() -> None:
"""index.html: the #stale-banner section sits DIRECTLY AFTER
#kb-banner (the chat-shell top-of-column position — kb-banner keeps
the top slot when both are visible), role="status", shipped hidden,
with the exact text and the #stale-regenerate button (type=button,
visible label "Regenerate", the redo glyph — the SAME SVG paths as
RETRY_ICON in app.js, the phase-49 Retry asset). No other page
carries it (chat-page only)."""
html = _index()
kb_idx = html.find('id="kb-banner"')
banner = re.search(r'<section[^>]*id="stale-banner"[^>]*>', html)
assert banner, "index.html must contain the #stale-banner section"
tag = banner.group(0)
assert 'role="status"' in tag
assert "hidden" in tag, "the banner ships hidden (the reveal is app.js's job)"
assert -1 < kb_idx < banner.start(), "the banner sits directly after #kb-banner"
# Nothing between the kb-banner close and the stale banner except
# whitespace + the phase-53 comment: the top-of-column pair is kept.
between = html[html.find("</div>", kb_idx) : banner.start()]
assert "id=" not in between, "no other element lands between the two banners"
block = html[banner.start() : html.find("</section>", banner.start())]
assert "The sources have been updated since this chat was saved." in block
btn = re.search(r'<button[^>]*id="stale-regenerate"[^>]*>', block)
assert btn, "the banner carries the #stale-regenerate button"
assert 'type="button"' in btn.group(0)
btn_block = block[btn.start() : block.find("</button>", btn.start())]
assert ">Regenerate</span>" in btn_block, "the visible label is Regenerate"
# The redo glyph: the SAME paths as RETRY_ICON (the phase-49 asset).
assert 'd="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"' in btn_block
assert 'd="M21 3v5h-5"' in btn_block
# The banner's own leading mark is the redo glyph too (distinct from
# the kb-banner warning triangle) — aria-hidden decoration.
lead = block[: btn.start()]
assert 'aria-hidden="true"' in lead and 'd="M21 3v5h-5"' in lead
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
TUNING_HTML, Path(FRONTEND / "history.html")):
assert 'id="stale-banner"' not in other.read_text(encoding="utf-8"), (
f"{other.name}: the stale banner is chat-page only"
)
def test_boot_load_reveals_stale_banner_on_payload_stale() -> None:
"""restoreSavedChatFromUrl: on a 200 payload with stale: true, the
#stale-banner is revealed (hidden removed) — the flag is
server-computed (task 03), the client never does staleness math.
The reveal rides the boot SUCCESS path (after the link + the
localStorage mirror), so a non-stale payload leaves the banner
hidden."""
js = _js()
body = _fn(js, "restoreSavedChatFromUrl")
assert "data.stale === true" in body, "the reveal branches on the payload's stale flag"
link_i = body.find("currentChatId = chatId")
reveal_i = body.find("staleBanner.hidden = false")
assert -1 < link_i < reveal_i, "the reveal runs on the success path, after the link"
assert "staleBanner.hidden = false" in body, "reveal = remove `hidden`"
def test_boot_load_stale_reveal_no_brain_record_guard() -> None:
"""The no-brain-record guard: a stale conversation with NO brain
record (user-only) is revealed TEXT-ONLY — the #stale-regenerate
button is removed BEFORE the reveal (retryLastTurn is never called
in that state)."""
js = _js()
body = _fn(js, "restoreSavedChatFromUrl")
guard_i = body.find('!conversation.some((m) => m.who === "brain")')
remove_i = body.find("staleRegenBtn.remove()")
reveal_i = body.find("staleBanner.hidden = false")
assert -1 < guard_i < remove_i < reveal_i, (
"the no-brain check removes the button before the banner is revealed"
)
def test_retry_last_turn_returns_the_turn_promise() -> None:
"""retryLastTurn RETURNS the runTurn promise (phase 53 task 05):
the Regenerate path awaits the turn's completion to know when to
persist. The phase-49 Retry click handler ignores the return value
— behavior-neutral for it (the redo-order pins in
test_frontend_feedback.py keep holding unchanged)."""
js = _js()
body = _fn(js, "retryLastTurn")
assert "return runTurn(text, { reask: true })" in body, (
"the redo promise is returned for the Regenerate await"
)
assert "void runTurn" not in body, "the fire-and-forget void is gone"
# The existing Retry click handler still ignores the return value.
append = _fn(js, "appendRetryButton")
assert "retryLastTurn(wrap)" in append, "the Retry click is unchanged (no await)"
def test_stale_regenerate_drives_retry_last_turn_and_awaits() -> None:
"""regenerateStaleChat: drives retryLastTurn on the LAST brain
bubble's rendered wrap (phase-49 targeting — retryLastTurn's own
`wrap !== lastBrainWrap` guard makes a stale click a no-op that
resolves nothing), AWAITs the returned turn promise, and persists
only when the turn completed WITHOUT the error banner (a
mid-stream error leaves the linked row untouched — stale stays
true). The double-click guard releases in the finally — never
stale (PLAN §7.4)."""
js = _js()
body = _fn(js, "regenerateStaleChat")
assert "staleRegenBtn.disabled = true" in body, "one regenerate at a time"
call_i = body.find("retryLastTurn(lastBrainWrap)")
await_i = body.find("await turn")
assert -1 < call_i < await_i, "call the redo on the last brain wrap, then await it"
err_i = body.find('banner.classList.contains("is-error")')
put_i = body.find('`/api/chats/${currentChatId}`')
assert -1 < await_i < err_i < put_i, (
"the error-banner check sits between the await and the persist"
)
finally_idx = body.rfind("finally")
assert finally_idx != -1 and "staleRegenBtn.disabled = false" in body[finally_idx:], (
"the button is re-enabled in the finally — never stale"
)
def test_stale_regenerate_persists_the_linked_row() -> None:
"""The post-regenerate persist (the existing upsert path): linked →
PUT /api/chats/<id> (the server re-stamps sources_version → the row
is fresh); a 404 (the row was deleted from History meanwhile)
follows saveCurrentChat's stale-link rule — unlink + recreate
(POST), and the recreate links the new id. Success hides the banner
AND announces the outcome in the #send-status live region; 403/5xx
→ the actionable error banner (the row stays as the turn left it);
network → the reachable? banner."""
js = _js()
body = _fn(js, "regenerateStaleChat")
assert "if (currentChatId)" in body
assert 'method: "PUT"' in body
assert 'fetch("/api/chats"' in body and '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"
# The recreate links the new row.
assert "res.status === 201" in body
assert "currentChatId = String(created.id)" in body
# Success: hide the banner, then announce in the live region.
hide_i = body.find("staleBanner.hidden = true")
ann_i = body.find('sendStatus.textContent = "Regenerated')
assert -1 < hide_i < ann_i, "hide the banner, then announce the outcome"
assert "the answer now reflects the current sources." in body, ("the live-region line")
# Failures raise an actionable banner (non-ok HTTP + network).
assert (
'showErrorBanner("Couldn\'t save the regenerated answer — is the app reachable?")' in body
)
assert "check you're still signed in and try again" in body, "403/5xx: actionable line"
def test_stale_regenerate_binding_and_element_queries() -> None:
"""The wiring: app.js queries #stale-banner + #stale-regenerate at
module scope and binds the click to regenerateStaleChat. The banner
only ever shows on the /?chat=<id> boot path (admin), so the
binding is inert otherwise."""
js = _js()
assert 'document.querySelector("#stale-banner")' in js
assert 'document.querySelector("#stale-regenerate")' in js
assert 'staleRegenBtn?.addEventListener("click", regenerateStaleChat)' in js
def test_stale_banner_cleared_on_new_chat_and_resave() -> None:
"""Never-stale (PLAN §7.4): "New chat" replaces the conversation the
banner described (and unlinks it) — the banner hides; a successful
manual re-Save re-stamps the row to the current generation (task
03) — the banner is done the moment the save succeeds."""
js = _js()
new_body = _fn(js, "startNewChat")
assert "staleBanner.hidden = true" in new_body, "New chat hides the banner"
save_body = _fn(js, "saveCurrentChat")
saved_line = 'sendStatus.textContent = "Conversation saved."'
after = save_body[save_body.find(saved_line):]
assert "staleBanner.hidden = true" in after, (
"a successful re-save re-stamps the row — the banner is done"
)
def test_stale_banner_css_is_the_kb_banner_family() -> None:
"""styles.css: the banner rides the .kb-banner family (the section
carries BOTH classes — the flex row + accent tokens come from
.kb-banner; .stale-banner adds the wrap so the pill can drop below
the text when the row must wrap), and the .stale-regenerate pill is
the EXACT brand-pill family of Save/Share (solid --brand, --bg text
5.2:1 AA, borderless, 999px, ≥44px, hover lightens the fill, 16px
redo glyph). The ≤640px block makes the pill a full-width row."""
html = _index()
tag = re.search(r'<section[^>]*id="stale-banner"[^>]*>', html)
assert tag and "kb-banner" in tag.group(0) and "stale-banner" in tag.group(0), (
"the section carries both classes — the family comes from .kb-banner"
)
css = _css()
block = re.search(r"\.stale-regenerate \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .stale-regenerate"
body = block.group(1)
for prop in (
"display: inline-flex",
"min-height: 44px",
"margin-left: auto",
"border-radius: 999px",
"border: 0",
"background: var(--brand)",
"color: var(--bg)",
"font-weight: 700",
"cursor: pointer",
):
assert prop in body, f".stale-regenerate must keep the Save/Share family ({prop})"
hover = re.search(r"\.stale-regenerate:hover \{([\s\S]*?)\n\}", css)
assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
assert re.search(r"\.stale-regenerate svg \{ width: 16px; height: 16px", css), (
"the redo glyph rides the 16px pill size"
)
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
assert mobile, "mobile media query missing"
assert ".stale-regenerate { margin-left: 0; width: 100%; }" in mobile.group(1), (
"at phone width the pill takes a full-width row"
)
+101
View File
@@ -0,0 +1,101 @@
"""Unit: sources-version counter helpers (phase 53, task 01).
``app.rag.sources_meta`` runs against the local compose Postgres
(``podman compose up -d db``) — the house DB-test pattern: the helpers
are thin session wrappers whose contract (seeded single row, flush-not
commit, defensive absence) only holds against a real database. Skips
with clear instructions when the stack is not up.
A fixture resets ``sources_meta`` to the migration-0010 seed state
(id 1, version 0) before and after every test, so the suite leaves the
dev DB exactly as the migration left it.
"""
from __future__ import annotations
import pytest
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import SourcesMeta
from app.rag.sources_meta import bump_sources_version, current_sources_version
@pytest.fixture()
def seeded_sources_meta(db: Session):
"""Reset the counter to the migration-0010 seed (id 1, version 0)."""
db.execute(text("DELETE FROM sources_meta"))
db.add(SourcesMeta(id=1, version=0))
db.commit()
yield
db.execute(text("DELETE FROM sources_meta"))
db.add(SourcesMeta(id=1, version=0))
db.commit()
def test_current_absent_row_returns_zero_without_raising(
db: Session, seeded_sources_meta: None
) -> None:
"""Defensive: a deleted seed row reads as 0 — never an exception."""
db.execute(text("DELETE FROM sources_meta"))
db.commit()
assert current_sources_version(db) == 0
def test_first_bump_zero_to_one(db: Session, seeded_sources_meta: None) -> None:
"""Seeded at 0 (the pre-counter KB), the first bump returns 1 and
``current`` reflects it."""
assert current_sources_version(db) == 0
assert bump_sources_version(db) == 1
db.commit() # the caller commits — the helper only flushes
assert current_sources_version(db) == 1
def test_second_bump_increments(db: Session, seeded_sources_meta: None) -> None:
"""Bumps are monotonic: 0 → 1 → 2, one step per KB-changing sync."""
assert bump_sources_version(db) == 1
db.commit()
assert bump_sources_version(db) == 2
db.commit()
assert current_sources_version(db) == 2
def test_bump_absent_row_upserts_to_one(db: Session, seeded_sources_meta: None) -> None:
"""Upsert semantics: a deleted seed row is recreated by the first
bump (version 0 → 1), never left dangling."""
db.execute(text("DELETE FROM sources_meta"))
db.commit()
assert bump_sources_version(db) == 1
db.commit()
row = db.get(SourcesMeta, 1)
assert row is not None, "the single row must be recreated"
assert row.id == 1
assert row.version == 1
assert row.updated_at is not None, "updated_at must be server-stamped"
def test_bump_flushes_without_committing(db: Session, seeded_sources_meta: None) -> None:
"""The helper flushes, it does not commit: a rolled-back session
must roll the bump back with it (each sync path owns its
transaction)."""
assert bump_sources_version(db) == 1
db.rollback()
assert current_sources_version(db) == 0, "the uncommitted bump must roll back"
def test_bumps_in_separate_sessions_progress(db: Session, seeded_sources_meta: None) -> None:
"""One writer at a time is the deployment reality, but two bumps in
two sessions must not race to the same value: the second session
sees the committed increment and lands on the next generation."""
assert bump_sources_version(db) == 1
db.commit()
other = SessionLocal()
try:
assert bump_sources_version(other) == 2
other.commit()
finally:
other.close()
db.expire_all() # drop the stale identity-map state
assert current_sources_version(db) == 2