phase: 175_history_pagination
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:
@@ -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() == []
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user