feat(chat): invalidate saved chats on sources sync — versioned stamps, stale marker, Regenerate against the new index
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user