Single consolidated commit for four completed, validated phases (77, 78, 79, 80). The pipeline run left all work uncommitted because the harness commits only with PHASE_COMMIT=1 while child executors are forbidden from committing; the phases themselves all passed validation and moved to .agents/phases/complete/. Phase 77 — navbar view refresh - router.js dispatches bor:view-refresh on re-show / active re-click / popstate (gated on wasMounted; first show and boot exempt) - History / RAG / Sources / Tuning re-fetch on refresh (admin branch); Chat deliberately excluded (stream survival) - History "Refresh" button (admin-only, in-flight disable + status line) - New story suite tests/e2e/test_navbar_refresh.py (7 tests) Phase 78 — static background - Removed the animated glow layers; static 44px grid over the flat --bg canvas; default and reduced-motion renders byte-identical - Updated background/theme E2E suites; removed bg-glow test pins Phase 79 — API tokens - api_tokens model + migration 0012; hash-only token service - Admin tokens API + Tokens admin view; POST /api/token-auth; live-revoking require_user on chat / suggestions / document content - Frontend token gate with localStorage cache; anonymous E2E suites migrated to token login - New story suite tests/e2e/test_api_tokens.py (9 tests) Phase 80 — history suggestion chips - last_questions() endpoint with SEED fallback; startNewChat() refetch - Seed-semantics docs (config.py, .env.example, README) - Integration state matrix + E2E suite rewritten to the 4 chip states Also included: phase-76 report artifacts and the repo restore-test-db skill (previously untracked), scripts/* ruff fixes from phase 77. Final gate state (phase 80 final pass, covers everything above): - uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99% - uv run ruff check . && uv run pyright → clean, 0 errors - Per-phase story E2E suites green in isolation
319 lines
9.9 KiB
Python
319 lines
9.9 KiB
Python
"""Integration: the onboarding-chips endpoint (phase 80, task 01) —
|
||
the full state matrix of ``GET /api/suggestions``.
|
||
|
||
The chips are the **last 3 questions asked** — the three most recent
|
||
user questions across ALL saved chats: chats are walked newest-
|
||
``updated_at`` first (``created_at`` tiebreak), each chat's
|
||
``bor.chat.v1`` message list is walked newest-first, exact-
|
||
(case-sensitive) de-duplicated, capped at 3. A fresh deployment —
|
||
zero saved questions — gets the SEED list instead
|
||
(``get_settings().suggestions``: the ``BOR_SUGGESTIONS`` override or
|
||
the built-in default). The override's JSON parsing is pinned at unit
|
||
level (``tests/unit/test_config.py``), so this suite stays
|
||
env-agnostic: the empty-DB contract is "exactly
|
||
``get_settings().suggestions``, whatever the environment makes that".
|
||
|
||
Matrix (task item 2):
|
||
|
||
* empty DB → exactly ``get_settings().suggestions``;
|
||
* cap + order: 4 questions in ONE chat → the 3 newest, newest first;
|
||
* chat order: two chats with DISTINCT ``updated_at`` (stamped
|
||
explicitly) → the newer chat's questions outrank the older chat's
|
||
newest-LOOKING question;
|
||
* dedup: the same text asked in two chats → exactly once; a
|
||
differently-cased variant is KEPT (exact dedup);
|
||
* partial: 1–2 saved questions deployment-wide → exactly those chips
|
||
(NO seed top-up — the A6 contract);
|
||
* brain-only: all-``brain`` (or blank user texts) contribute nothing;
|
||
an all-brain deployment → the seed;
|
||
* anonymous → 401 ``authentication required`` (the phase-79 contract,
|
||
pinned here too).
|
||
|
||
The deflection "Maybe try" chips (``app.rag.suggestions.
|
||
derive_suggestions``) are a SEPARATE contract — untouched.
|
||
|
||
Real Postgres (``podman compose up -d db``).
|
||
|
||
Requires: podman compose up -d db
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Iterator
|
||
from datetime import UTC, datetime, timedelta
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.config import get_settings
|
||
from app.models import SavedChat
|
||
|
||
#: Fixed question texts — the matrix asserts EXACT chip lists, so the
|
||
#: texts are distinct per purpose.
|
||
Q_ONE = "How did I install gitlab?"
|
||
Q_TWO = "Which node runs my Borg backups?"
|
||
Q_THREE = "How do I prune deleted docs?"
|
||
Q_FOUR = "What proxy fronts reeseapps.com?"
|
||
Q_FIVE = "How is my K3S cluster set up?"
|
||
Q_SIX = "How do I deploy a service?"
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def clean_chats(db: Session) -> Iterator[None]:
|
||
"""``saved_chats`` is global state: reset around every test."""
|
||
db.execute(text("TRUNCATE saved_chats"))
|
||
db.commit()
|
||
yield
|
||
db.execute(text("TRUNCATE saved_chats"))
|
||
db.commit()
|
||
|
||
|
||
def _user(text: str) -> dict[str, Any]:
|
||
return {"who": "user", "text": text}
|
||
|
||
|
||
def _brain(text: str = "You've got this!") -> dict[str, Any]:
|
||
return {"who": "brain", "text": text}
|
||
|
||
|
||
def _add_chat(
|
||
db: Session,
|
||
*,
|
||
title: str,
|
||
messages: list[dict[str, Any]],
|
||
updated_at: datetime,
|
||
) -> None:
|
||
"""One saved-chat row with EXPLICIT ``created_at``/``updated_at``
|
||
stamps (deterministic order — no reliance on ``now()``
|
||
resolution)."""
|
||
db.add(
|
||
SavedChat(
|
||
title=title,
|
||
messages=messages,
|
||
created_at=updated_at,
|
||
updated_at=updated_at,
|
||
)
|
||
)
|
||
db.commit()
|
||
|
||
|
||
def _chips(admin_client: TestClient) -> list[str]:
|
||
r = admin_client.get("/api/suggestions")
|
||
assert r.status_code == 200, r.text
|
||
return r.json()["suggestions"]
|
||
|
||
|
||
# ---------- empty DB: the seed ----------
|
||
|
||
|
||
def test_empty_db_returns_seed(admin_client: TestClient) -> None:
|
||
"""Zero saved questions → exactly the seed list — env-agnostic:
|
||
``get_settings().suggestions`` (the ``BOR_SUGGESTIONS`` override or
|
||
the built-in default, whatever the environment makes it)."""
|
||
r = admin_client.get("/api/suggestions")
|
||
assert r.status_code == 200
|
||
assert r.json() == {"suggestions": get_settings().suggestions}
|
||
|
||
|
||
# ---------- cap + order within one chat ----------
|
||
|
||
|
||
def test_cap_three_and_newest_first_within_a_chat(
|
||
admin_client: TestClient, db: Session
|
||
) -> None:
|
||
"""4 user questions (brain replies between them) in ONE chat →
|
||
exactly the 3 NEWEST, newest first."""
|
||
_add_chat(
|
||
db,
|
||
title="one long chat",
|
||
messages=[
|
||
_user(Q_ONE), _brain("a1"),
|
||
_user(Q_TWO), _brain("a2"),
|
||
_user(Q_THREE), _brain("a3"),
|
||
_user(Q_FOUR), _brain("a4"),
|
||
],
|
||
updated_at=datetime.now(UTC),
|
||
)
|
||
assert _chips(admin_client) == [Q_FOUR, Q_THREE, Q_TWO]
|
||
|
||
|
||
# ---------- chat order across chats ----------
|
||
|
||
|
||
def test_newer_chat_walked_first(admin_client: TestClient, db: Session) -> None:
|
||
"""Two chats with DISTINCT ``updated_at`` (stamped explicitly):
|
||
the newer chat is walked FIRST — its single question outranks the
|
||
older chat's newest-LOOKING (last-in-conversation) question."""
|
||
base = datetime.now(UTC)
|
||
_add_chat(
|
||
db,
|
||
title="older chat",
|
||
messages=[_user(Q_FIVE), _brain("…"), _user(Q_SIX), _brain("…")],
|
||
updated_at=base,
|
||
)
|
||
_add_chat(
|
||
db,
|
||
title="newer chat",
|
||
messages=[_user(Q_ONE), _brain("…")],
|
||
updated_at=base + timedelta(hours=2),
|
||
)
|
||
# Newer chat first (Q_ONE), then the older chat newest-first
|
||
# (Q_SIX — its LAST question — before Q_FIVE).
|
||
assert _chips(admin_client) == [Q_ONE, Q_SIX, Q_FIVE]
|
||
|
||
|
||
# ---------- dedup ----------
|
||
|
||
|
||
def test_verbatim_reask_counts_once_across_chats(
|
||
admin_client: TestClient, db: Session
|
||
) -> None:
|
||
"""The SAME question text asked in two chats appears EXACTLY ONCE
|
||
in the chips."""
|
||
base = datetime.now(UTC)
|
||
_add_chat(
|
||
db,
|
||
title="older",
|
||
messages=[_user(Q_THREE), _brain("…")],
|
||
updated_at=base,
|
||
)
|
||
_add_chat(
|
||
db,
|
||
title="newer",
|
||
messages=[
|
||
_user(Q_ONE), _brain("…"),
|
||
_user(Q_THREE), _brain("…"), # verbatim re-ask (newer chat)
|
||
],
|
||
updated_at=base + timedelta(hours=2),
|
||
)
|
||
# Newest first: the re-ask (LAST message of the newer chat) leads —
|
||
# and it appears exactly once (the older chat's copy is deduped).
|
||
chips = _chips(admin_client)
|
||
assert chips == [Q_THREE, Q_ONE]
|
||
assert chips.count(Q_THREE) == 1
|
||
|
||
|
||
def test_dedup_is_exact_not_case_insensitive(
|
||
admin_client: TestClient, db: Session
|
||
) -> None:
|
||
"""A differently-cased re-ask is a DIFFERENT question (exact,
|
||
case-sensitive dedup — case-insensitive would drop it): both
|
||
variants show, and the verbatim re-ask in the older chat still
|
||
counts once."""
|
||
base = datetime.now(UTC)
|
||
lower_variant = Q_THREE.lower()
|
||
_add_chat(
|
||
db,
|
||
title="older",
|
||
messages=[_user(Q_THREE), _brain("…")],
|
||
updated_at=base,
|
||
)
|
||
_add_chat(
|
||
db,
|
||
title="newer",
|
||
messages=[
|
||
_user(lower_variant), _brain("…"),
|
||
_user(Q_THREE), _brain("…"),
|
||
_user(Q_ONE), _brain("…"),
|
||
],
|
||
updated_at=base + timedelta(hours=2),
|
||
)
|
||
# Newer chat walked newest-first: Q_ONE, Q_THREE, lower_variant —
|
||
# all three kept (the case variant is NOT a duplicate).
|
||
assert _chips(admin_client) == [Q_ONE, Q_THREE, lower_variant]
|
||
|
||
|
||
# ---------- partial: no seed top-up ----------
|
||
|
||
|
||
def test_exactly_two_questions_give_exactly_two_chips(
|
||
admin_client: TestClient, db: Session
|
||
) -> None:
|
||
"""1–2 saved questions deployment-wide → EXACTLY those chips — NO
|
||
mixing/top-up with the seed (the A6 contract)."""
|
||
base = datetime.now(UTC)
|
||
_add_chat(
|
||
db,
|
||
title="a",
|
||
messages=[_user(Q_TWO), _brain("…")],
|
||
updated_at=base,
|
||
)
|
||
_add_chat(
|
||
db,
|
||
title="b",
|
||
messages=[_user(Q_ONE), _brain("…")],
|
||
updated_at=base + timedelta(hours=1),
|
||
)
|
||
assert _chips(admin_client) == [Q_ONE, Q_TWO]
|
||
|
||
|
||
def test_exactly_one_question_gives_exactly_one_chip(
|
||
admin_client: TestClient, db: Session
|
||
) -> None:
|
||
"""The 1-question boundary of the same contract: exactly one chip,
|
||
never padded toward the cap or mixed with the seed."""
|
||
_add_chat(
|
||
db,
|
||
title="a",
|
||
messages=[_user(Q_TWO), _brain("…")],
|
||
updated_at=datetime.now(UTC),
|
||
)
|
||
assert _chips(admin_client) == [Q_TWO]
|
||
|
||
|
||
# ---------- brain-only / blank user texts ----------
|
||
|
||
|
||
def test_brain_and_blank_user_texts_contribute_nothing(
|
||
admin_client: TestClient, db: Session
|
||
) -> None:
|
||
"""A chat whose messages are all ``who == "brain"`` (plus a blank
|
||
user text) contributes NOTHING: the chips hold exactly the one
|
||
real question from the other chat — no brain text, no blank, no
|
||
seed top-up."""
|
||
base = datetime.now(UTC)
|
||
_add_chat(
|
||
db,
|
||
title="brain only + blank user",
|
||
messages=[
|
||
_brain("just brain talking"),
|
||
_user(" "), # blank user text — skipped
|
||
_brain("more brain"),
|
||
],
|
||
updated_at=base,
|
||
)
|
||
_add_chat(
|
||
db,
|
||
title="real question",
|
||
messages=[_user(Q_FOUR), _brain("…")],
|
||
updated_at=base + timedelta(hours=1),
|
||
)
|
||
assert _chips(admin_client) == [Q_FOUR]
|
||
|
||
|
||
def test_all_brain_deployment_returns_seed(admin_client: TestClient, db: Session) -> None:
|
||
"""A deployment with ONLY brain/blank conversations (zero saved
|
||
questions) → the full seed list."""
|
||
_add_chat(
|
||
db,
|
||
title="all brain",
|
||
messages=[_brain("a"), _user("\t"), _brain("b")],
|
||
updated_at=datetime.now(UTC),
|
||
)
|
||
r = admin_client.get("/api/suggestions")
|
||
assert r.status_code == 200
|
||
assert r.json() == {"suggestions": get_settings().suggestions}
|
||
|
||
|
||
# ---------- auth pin (phase 79) ----------
|
||
|
||
|
||
def test_anonymous_is_401(client: TestClient) -> None:
|
||
"""The phase-79 contract, pinned here too: an unsigned-in caller
|
||
cannot read the chips."""
|
||
r = client.get("/api/suggestions")
|
||
assert r.status_code == 401
|
||
assert r.json() == {"detail": "authentication required"}
|