"""Integration: the onboarding-chips endpoint (phase 103, task 01) — the full state matrix of ``GET /api/suggestions``. The chips are the **session openers** — each saved chat contributes AT MOST ONE chip: its first non-blank user message (the question that opened the session). Chats are walked newest-``updated_at`` first (``created_at`` tiebreak), each chat's ``bor.chat.v1`` message list is walked FORWARD (oldest→newest, the record's conversational order), openers are exact-(case-sensitive) de-duplicated, capped at 3 — the cap binds ACROSS chats. Follow-up questions ("What about …?") can NEVER surface: they are unanswerable without the session behind them (owner 2026-09-12). Everything else is the phase-80 contract: a fresh deployment — zero saved openers — 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``; * one chat with 4 user questions → EXACTLY its opener (the first question); none of the 3 follow-ups appears; * cap ACROSS chats: 4 multi-turn chats (distinct ``updated_at``) → exactly the 3 NEWEST chats' openers, newest first; the oldest chat's opener is dropped by the cap; none of the chats' FOLLOW-UPS appears anywhere; * chat order: two multi-turn chats with DISTINCT ``updated_at`` (stamped explicitly) → [newer chat's opener, older chat's opener]; the older chat's LAST (newest-looking) question is NOT in the chips; * dedup: the SAME opener text as the first question of two chats → exactly once (a verbatim re-ask as a FOLLOW-UP in the newer chat is deduped too); a differently-cased OPENER variant → both kept (exact dedup); * partial: 2 multi-turn chats → 2 openers; 1 chat → 1 chip — the follow-ups in those chats do NOT pad the row (NO seed top-up — the phase-80 A6 contract); * A3: a LEADING blank user entry does NOT disqualify the chat — the first NON-BLANK user message is the opener; * brain-only: all-``brain`` (or blank-user-only) chats 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. The Q_* are opener-flavored; #: FOLLOW_UP is the follow-up-flavored text (the owner's qwen example). 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?" FOLLOW_UP = "What about qwen 3.6 35b?" @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 openers → 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} # ---------- openers only: follow-ups never surface ---------- def test_a_chats_follow_ups_never_surface(admin_client: TestClient, db: Session) -> None: """The phase-103 core pin: ONE chat with 4 user questions (brain replies between them, the 4th follow-up-flavored) → the chips hold EXACTLY the chat's OPENER (its first question); none of the 3 follow-ups appears (a follow-up like "What about …?" is meaningless as a conversation starter without the session).""" _add_chat( db, title="one long chat", messages=[ _user(Q_ONE), _brain("a1"), _user(Q_TWO), _brain("a2"), _user(Q_THREE), _brain("a3"), _user(FOLLOW_UP), _brain("a4"), ], updated_at=datetime.now(UTC), ) chips = _chips(admin_client) assert chips == [Q_ONE] for follow_up in (Q_TWO, Q_THREE, FOLLOW_UP): assert follow_up not in chips # ---------- the cap binds ACROSS chats ---------- def test_cap_three_across_chats(admin_client: TestClient, db: Session) -> None: """FOUR multi-turn chats (opener + at least one follow-up each) with DISTINCT explicit ``updated_at`` stamps → the chips are EXACTLY the 3 NEWEST chats' openers, newest first; the oldest chat's opener is dropped (the cap of 3 now binds ACROSS chats, not within one chat); none of the four chats' FOLLOW-UPS appears anywhere.""" base = datetime.now(UTC) _add_chat( db, title="oldest", messages=[_user(Q_FIVE), _brain("…"), _user(FOLLOW_UP), _brain("…")], updated_at=base, ) _add_chat( db, title="second", messages=[_user(Q_THREE), _brain("…"), _user(Q_SIX), _brain("…")], updated_at=base + timedelta(hours=1), ) _add_chat( db, title="third", messages=[_user(Q_TWO), _brain("…"), _user(Q_FOUR), _brain("…")], updated_at=base + timedelta(hours=2), ) _add_chat( db, title="newest", messages=[_user(Q_ONE), _brain("…"), _user(FOLLOW_UP), _brain("…")], updated_at=base + timedelta(hours=3), ) chips = _chips(admin_client) # Newest chat first: the 3 NEWEST openers; the oldest chat's # opener (Q_FIVE) is dropped by the cap. assert chips == [Q_ONE, Q_TWO, Q_THREE] assert Q_FIVE not in chips for follow_up in (Q_SIX, Q_FOUR, FOLLOW_UP): assert follow_up not in chips # ---------- chat order across chats ---------- def test_newer_chat_walked_first(admin_client: TestClient, db: Session) -> None: """Two multi-turn chats with DISTINCT ``updated_at`` (stamped explicitly): the newer chat is walked FIRST — its opener leads the older chat's opener; the older chat's LAST (newest-looking) question — a follow-up — is NOT in the chips.""" base = datetime.now(UTC) _add_chat( db, title="older chat", messages=[ _user(Q_FIVE), _brain("…"), _user(Q_SIX), _brain("…"), # the older chat's LAST question ], updated_at=base, ) _add_chat( db, title="newer chat", messages=[ _user(Q_ONE), _brain("…"), _user(Q_TWO), _brain("…"), # a follow-up — never a chip ], updated_at=base + timedelta(hours=2), ) assert _chips(admin_client) == [Q_ONE, Q_FIVE] # The older chat's LAST question and the newer chat's follow-up # must not surface. assert Q_SIX not in _chips(admin_client) assert Q_TWO not in _chips(admin_client) # ---------- dedup (openers only) ---------- def test_verbatim_reask_counts_once_across_chats( admin_client: TestClient, db: Session ) -> None: """The SAME opener text as the first question of two chats appears EXACTLY ONCE in the chips — and the newer chat's verbatim re-ask AS A FOLLOW-UP stays deduped too (it is the same text as the already-seen opener).""" base = datetime.now(UTC) _add_chat( db, title="older", messages=[_user(Q_ONE), _brain("…"), _user(Q_TWO), _brain("…")], updated_at=base, ) _add_chat( db, title="newer", messages=[ _user(Q_ONE), _brain("…"), # SAME opener as the older chat _user(Q_TWO), _brain("…"), # verbatim re-ask AS A FOLLOW-UP ], updated_at=base + timedelta(hours=2), ) chips = _chips(admin_client) assert chips == [Q_ONE] assert chips.count(Q_ONE) == 1 assert Q_TWO not in chips def test_dedup_is_exact_not_case_insensitive( admin_client: TestClient, db: Session ) -> None: """A differently-cased OPENER variant is a DIFFERENT question (exact, case-sensitive dedup — case-insensitive would drop it): both variants show; the follow-ups in both chats do not.""" base = datetime.now(UTC) lower_variant = Q_THREE.lower() _add_chat( db, title="older", messages=[ _user(Q_THREE), _brain("…"), # opener _user(Q_FOUR), _brain("…"), # follow-up ], updated_at=base, ) _add_chat( db, title="newer", messages=[ _user(lower_variant), _brain("…"), # differently-cased opener _user(Q_ONE), _brain("…"), # follow-up ], updated_at=base + timedelta(hours=2), ) chips = _chips(admin_client) # Newer chat first: its opener, then the older chat's opener — # both kept (the case variant is NOT a duplicate). assert chips == [lower_variant, Q_THREE] assert Q_ONE not in chips assert Q_FOUR not in chips # ---------- partial: no seed top-up ---------- def test_exactly_two_questions_give_exactly_two_chips( admin_client: TestClient, db: Session ) -> None: """2 multi-turn chats → EXACTLY their 2 openers — NO mixing/top-up with the seed (the phase-80 A6 contract); the follow-ups in those chats do not pad the row.""" base = datetime.now(UTC) _add_chat( db, title="a", messages=[ _user(Q_TWO), _brain("…"), _user(Q_THREE), _brain("…"), # follow-up — never a chip ], updated_at=base, ) _add_chat( db, title="b", messages=[ _user(Q_ONE), _brain("…"), _user(FOLLOW_UP), _brain("…"), # follow-up — never a chip ], updated_at=base + timedelta(hours=1), ) chips = _chips(admin_client) assert chips == [Q_ONE, Q_TWO] assert Q_THREE not in chips assert FOLLOW_UP not in chips def test_exactly_one_question_gives_exactly_one_chip( admin_client: TestClient, db: Session ) -> None: """The 1-chat boundary of the same contract: exactly one chip (the chat's opener), never padded toward the cap by the chat's own follow-ups or mixed with the seed.""" _add_chat( db, title="a", messages=[ _user(Q_TWO), _brain("…"), _user(Q_ONE), _brain("…"), # follow-up — never a chip ], updated_at=datetime.now(UTC), ) chips = _chips(admin_client) assert chips == [Q_TWO] assert Q_ONE not in chips # ---------- 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 opener of 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 — no non-blank user message _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_leading_blank_user_entry_does_not_disqualify( admin_client: TestClient, db: Session ) -> None: """A3: a LEADING blank user entry (the UI cannot produce one — ``handleSend`` trims and guards empty text) does NOT disqualify the chat: the first NON-BLANK user message is the opener.""" _add_chat( db, title="leading blank", messages=[ _user(" "), # leading blank user entry — skipped _user(Q_TWO), # the first NON-BLANK user message = the opener _brain("…"), _user(FOLLOW_UP), # a follow-up — never a chip ], updated_at=datetime.now(UTC), ) chips = _chips(admin_client) assert chips == [Q_TWO] assert FOLLOW_UP not in chips def test_all_brain_deployment_returns_seed(admin_client: TestClient, db: Session) -> None: """A deployment with ONLY brain/blank conversations (zero saved openers) → 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"}