"""T100 (Phase 4): cross-chat search UX (top-bar + results page). Verifies the FastAPI ``/search`` route that wraps T93's ``search_all_memories`` service: * ``/search?q=...`` returns 200 + an HTML page that lists matches drawn from MULTIPLE chats (not just the current one) and links each result back to ``/chats/{chat_id}``. * ``/search`` with no query renders the page in its empty state with a "enter a query" placeholder and no result rows (avoids hitting the FTS index with an invalid empty MATCH). * Result links navigate to the originating chat so users can pick up the thread where the memory came from. """ from __future__ import annotations from pathlib import Path from unittest.mock import patch import pytest from fastapi.testclient import TestClient from chat.app import app from chat.db.connection import open_db from chat.eventlog.log import append_event from chat.eventlog.projector import project import chat.state.memory # noqa: F401 (registers memory_written handler) @pytest.fixture def client(tmp_path, monkeypatch): config_path = tmp_path / "config.toml" config_path.write_text('featherless_api_key = "test"\n') monkeypatch.setenv("CHAT_CONFIG_PATH", str(config_path)) monkeypatch.setenv("CHAT_DB_PATH", str(tmp_path / "test.db")) with TestClient(app) as c: yield c def _seed_two_chats_with_memories(db_path: Path) -> None: """Seed: a ``you_entity``, two bots, two chats, and one ``rabbit`` memory per chat. Two-chat seeding lets the cross-chat assertion actually distinguish "both chats appear" from "only the current one does".""" with open_db(db_path) as conn: append_event( conn, kind="you_authored", payload={"name": "Me", "pronouns": "", "persona": ""}, ) for bot_id, chat_id in (("bot_a", "chat_a"), ("bot_b", "chat_b")): append_event( conn, kind="bot_authored", payload={ "id": bot_id, "name": bot_id.upper(), "persona": "thoughtful", "voice_samples": [], "traits": [], "backstory": "", "initial_relationship_to_you": "friend", "kickoff_prose": "kickoff", }, ) append_event( conn, kind="chat_created", payload={ "id": chat_id, "host_bot_id": bot_id, "initial_time": "2026-04-26T20:00:00+00:00", "narrative_anchor": "Day 1", "weather": "", }, ) append_event( conn, kind="memory_written", payload={ "owner_id": bot_id, "chat_id": chat_id, "pov_summary": f"the rabbit darted across {chat_id}", "witness_you": 1, "witness_host": 1, "witness_guest": 0, "source": "direct", "reliability": 1.0, "significance": 1, "pinned": 0, "auto_pinned": 0, }, ) project(conn) def test_search_returns_results_from_multiple_chats(client, tmp_path): """A single ``/search?q=rabbit`` must surface matches from BOTH chats — the whole point of the cross-chat search box is that it isn't owner-scoped.""" _seed_two_chats_with_memories(tmp_path / "test.db") resp = client.get("/search?q=rabbit") assert resp.status_code == 200 body = resp.text # Both chats' memory snippets must appear in the rendered page. assert "chat_a" in body assert "chat_b" in body assert "rabbit" in body.lower() def test_empty_query_renders_placeholder_not_results(client, tmp_path): """``/search`` with no query renders the page in its empty state. The placeholder copy is a contract with the user — they should see "enter a query" rather than an empty result list that looks like a no-match. Also: the FTS short-circuit means there are no result rows to leak into the body.""" _seed_two_chats_with_memories(tmp_path / "test.db") resp = client.get("/search") assert resp.status_code == 200 body = resp.text.lower() assert "enter a query" in body # Seeded "rabbit" memories must NOT appear: empty query => no results. assert "the rabbit darted" not in resp.text def test_result_links_navigate_to_chat(client, tmp_path): """Each result links back to its originating chat so the user can reopen the thread where the memory was first witnessed. Post-T111.2: the link now includes a turn anchor when the memory row carries an ``event_id`` (T109's nullable column is populated for rows projected after migration 0014 ran). We assert on the chat-id portion of the href because the exact event id is autoincrement and depends on seed order; the dedicated ``test_search_result_link_includes_turn_anchor`` test below pins the anchor format itself.""" _seed_two_chats_with_memories(tmp_path / "test.db") resp = client.get("/search?q=rabbit") assert resp.status_code == 200 assert 'href="/chats/chat_a' in resp.text def test_search_results_include_fts_snippet_with_highlight(client, tmp_path): """T111.1: FTS snippet() wraps each match in ``...`` so the result row visually highlights the term that matched. The seeded ``pov_summary`` is ``the rabbit darted across chat_a``; SQLite's ``snippet()`` returns the column text with each match token wrapped — searching for ``rabbit`` yields a snippet containing ``rabbit``. Assertion is just that the marker appears (the snippet may be truncated with an ellipsis when the indexed text runs longer than the configured token window).""" _seed_two_chats_with_memories(tmp_path / "test.db") resp = client.get("/search?q=rabbit") assert resp.status_code == 200 assert "rabbit" in resp.text def test_search_result_link_includes_turn_anchor(client, tmp_path): """T111.2: result links deep-link to the originating turn via the chat-page anchor stamped by Phase 3.5 T86 (``id="turn-{event_id}"``). The seeded ``memory_written`` events are projected with ``memories.event_id`` populated (T109); the route exposes that id and the template builds the link as ``/chats/{chat_id}#turn-{event_id}``. We don't assert a specific event id (it's an autoincrement that depends on seed order), only that *some* turn anchor is present for the chat link the user is about to click.""" _seed_two_chats_with_memories(tmp_path / "test.db") resp = client.get("/search?q=rabbit") assert resp.status_code == 200 assert "/chats/chat_a#turn-" in resp.text def test_search_results_use_batched_lookups(client, tmp_path): """T106: hydration must not fan out to per-row ``get_bot``/ ``get_chat``/``get_scene`` calls. The previous implementation called each helper once per result row (worst case 50 rows x 3 helpers = 150 individual queries). The batched implementation collects distinct ids and issues at most one query per entity kind via ``WHERE id IN (...)``, so the per-row helpers should not be invoked at all when there are matches. We seed two chats (so both ``get_bot`` and ``get_chat`` would have been hit pre-T106) and assert each helper sees zero per-row calls. """ _seed_two_chats_with_memories(tmp_path / "test.db") with ( patch("chat.web.search.get_bot") as mock_get_bot, patch("chat.web.search.get_chat") as mock_get_chat, patch("chat.web.search.get_scene") as mock_get_scene, ): resp = client.get("/search?q=rabbit") assert resp.status_code == 200 # Batched IN-list queries replace the per-row helpers entirely. assert mock_get_bot.call_count == 0 assert mock_get_chat.call_count == 0 assert mock_get_scene.call_count == 0