phase: 175_history_pagination
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 12s

All criteria verified. This pass found no defects — the phase was fully implemented by prior task runs, and every gate is green.

**Phase 175 final verification pass — report**

- Verified tasks 01–03 implementation in-tree: `list_chats` `?limit=` (25, 1–100) / `?offset=` + `func.count` `total`; `SavedChatList.total` required; `#history-pager` below `#history-table-wrap` (hidden default, aria-labels, aria-hidden SVGs); `history.js` `PAGE_SIZE=25`/offset state, parameterized fetch, `updatePager`, refresh-keeps-current-page; CSS pill union + ≤640px block. No code changes were needed.
- Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 99%** (>90% gate).
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors, 0 warnings.
- Phase E2E in isolation: `uv run pytest tests/e2e/test_history_pagination.py -v --no-cov` → **9 passed** (page 1-of-2 / 2-of-2 walk, replace-not-append, refresh-on-page-2, delete-to-empty + single-page hide, anonymous no-fetch, 375px mobile, a11y/no-CDN).
- Regression set, each in isolation: test_chat_history 5✓, test_history_page_width 5✓, test_share_chat 4✓, test_stale_saved_chats 3✓, test_history_copy 1✓; integration `test_chats_api.py` green (incl. updated `test_list_empty` two-key pin and all five new pagination pins).
- Completion criteria: all nine boxes satisfied (API slice/422/honest-total pins ✓; E2E walk + status lines ✓; refresh/delete/hide ✓; anonymous gate ✓; mobile ✓; gates ✓; E2E + regression ✓; docs ✓; commit/phase-move is the harness's).
- Documentation: README.md reviewed in full — the saved-chats section already carries the `?limit=`/`?offset=`/`total` contract from this phase; all 31 `.env.example` vars documented; AGENTS.md verified accurate. **Consistent — no changes needed.**
- Deviations: none. No defects found in previously completed work.
- Next pending phase: none — 175 is the only entry in `todo/`.
This commit is contained in:
2026-10-11 04:12:53 -04:00
parent 938d6cf09d
commit 375169bbfc
31 changed files with 2339 additions and 61 deletions
+113 -4
View File
@@ -9,7 +9,11 @@ surface (list / detail / delete / unshare) stays admin-only (guests get
403 on exactly those four routes). The admin CRUD pins stay green
unchanged: the auto-title convention (first
user message, whitespace-collapsed, 120-char cap + the no-user-message
fallback), the list order (``updated_at desc, id desc``), the
fallback), the list order (``updated_at desc, id desc``; phase 175
task 01: server-side pagination — ``limit`` (default 25, clamped
1–100) / ``offset`` + the full ``total`` on every response; out-of-
range params 422; an offset beyond the end → an empty list with the
honest ``total``), the
full-payload round-trip (a ``bor.chat.v1``-shaped brain record carrying
``sources``/``thinking``/``tools``/``stopped``/``failed``/``error``
survives losslessly),
@@ -244,7 +248,7 @@ def _expect(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
def _assert_no_chats(admin_client: TestClient) -> None:
assert admin_client.get("/api/chats").json() == {"chats": []}
assert admin_client.get("/api/chats").json() == {"chats": [], "total": 0}
# ---------- guest (no session): the public write surface (phase 55,
@@ -518,7 +522,7 @@ def test_create_rejects_title_over_500(admin_client: TestClient) -> None:
def test_list_empty(admin_client: TestClient) -> None:
r = admin_client.get("/api/chats")
assert r.status_code == 200
assert r.json() == {"chats": []}
assert r.json() == {"chats": [], "total": 0}
def test_list_orders_by_updated_at_desc(admin_client: TestClient, db: Session) -> None:
@@ -560,6 +564,111 @@ def test_list_reports_message_count(admin_client: TestClient) -> None:
assert [c["message_count"] for c in body["chats"]] == [2]
# ---------- list — pagination (phase 175, task 01) ----------
def _seed_staggered_chats(db: Session, n: int) -> list[str]:
"""``n`` saved chats with distinctive titles and DISTINCT,
staggered ``updated_at`` (the
``test_list_orders_by_updated_at_desc`` idiom, scaled up): the
ordered list is fully determined by ``updated_at desc`` (the
uuid4 id tie-break never fires). Returns the titles in ORDERED
order (newest first); ``clean_chats`` truncates around the test."""
base = datetime.now(UTC)
db.add_all(
[
SavedChat(
title=f"page175 row {i:02d}",
messages=[_user(f"question {i}")],
updated_at=base + timedelta(hours=i), # i=0 oldest
)
for i in range(n)
]
)
db.commit()
return [f"page175 row {i:02d}" for i in range(n - 1, -1, -1)]
def _ordered_chat_ids(db: Session) -> list[str]:
"""The full ordered id sequence (``updated_at desc, id desc``)
via raw SQL — the ground truth a page concatenation is checked
against (bypassing the API itself)."""
return [
str(r[0])
for r in db.execute(
text(
"SELECT id FROM saved_chats ORDER BY updated_at DESC, "
"id DESC"
)
)
]
def test_list_default_page_is_25_and_reports_total(
admin_client: TestClient, db: Session
) -> None:
ordered = _seed_staggered_chats(db, 30)
r = admin_client.get("/api/chats")
assert r.status_code == 200
body = r.json()
assert set(body) == {"chats", "total"}
assert body["total"] == 30 # the FULL count, not the page length
assert len(body["chats"]) == 25
# The page is the FIRST 25 of the full ordered list
# (``updated_at desc`` — the staggered fixture pins the order).
assert [c["title"] for c in body["chats"]] == ordered[:25]
for c in body["chats"]:
assert set(c) == ROW_KEYS
uuid.UUID(c["id"])
def test_list_limit_offset_slice(admin_client: TestClient, db: Session) -> None:
ordered = _seed_staggered_chats(db, 30)
r = admin_client.get("/api/chats", params={"limit": 10, "offset": 20})
assert r.status_code == 200
body = r.json()
assert body["total"] == 30
# Exactly the LAST 10 of the ordered list (the fixture's ten
# oldest), pinned by title.
assert [c["title"] for c in body["chats"]] == ordered[20:]
def test_list_order_preserved_across_pages(
admin_client: TestClient, db: Session
) -> None:
_seed_staggered_chats(db, 30)
pages: list[dict[str, Any]] = []
for offset in (0, 10, 20):
body = admin_client.get(
"/api/chats", params={"limit": 10, "offset": offset}
).json()
assert body["total"] == 30
pages.extend(body["chats"])
# The three pages concatenated == the full ordered id sequence
# (the raw-SQL ground truth) — no duplicates, no gaps.
assert [c["id"] for c in pages] == _ordered_chat_ids(db)
assert len({c["id"] for c in pages}) == 30
def test_list_rejects_out_of_range_params(admin_client: TestClient) -> None:
# The house fixed-422 contract (FastAPI field-message validation
# — no hand-rolled checks): one request per case, status
# asserted.
for params in ({"limit": 0}, {"limit": 101}, {"offset": -1}):
assert admin_client.get("/api/chats", params=params).status_code == 422
def test_list_offset_beyond_total_returns_empty_with_total(
admin_client: TestClient, db: Session
) -> None:
_seed_staggered_chats(db, 30)
r = admin_client.get("/api/chats", params={"offset": 100})
assert r.status_code == 200
# An honest count, not an error: the list is empty but ``total``
# still reports the fixture's 30 rows.
assert r.json() == {"chats": [], "total": 30}
# ---------- get ----------
@@ -708,7 +817,7 @@ def test_delete_returns_204_and_removes(admin_client: TestClient, db: Session) -
assert admin_client.delete(f"/api/chats/{created['id']}").status_code == 204
assert admin_client.get(f"/api/chats/{created['id']}").status_code == 404
assert admin_client.get("/api/chats").json() == {"chats": []}
assert admin_client.get("/api/chats").json() == {"chats": [], "total": 0}
assert db.scalars(select(SavedChat)).all() == []