"""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 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.""" _seed_two_chats_with_memories(tmp_path / "test.db") resp = client.get("/search?q=rabbit") assert resp.status_code == 200 # The link target is chat-level (memories don't carry an event_id # column today, so we don't deep-link to a specific turn). assert 'href="/chats/chat_a"' in resp.text