"""Tests for GET /api/search — full-text search over messages + customers. Drives the FastAPI app over an in-process ``httpx.AsyncClient`` (no real network). Seeds a small corpus: * Messages: a couple containing "报价", a couple containing other text, one WhatsApp row to exercise the channel filter. * Customers: nickname / real_name / phone / wechat_phone variants so each LIKE branch is exercised. Coverage: * ``q=报价`` finds matching messages. * ``q=张三`` finds customers by nickname; ``q=138`` finds by phone. * ``type`` selector returns only the requested section; ``all`` returns both. * ``channel`` filter narrows both sections. * Empty / whitespace ``q`` -> 400. * Time window ``start``/``end`` narrows the message section. * Pagination (``page``/``page_size``) is applied independently per section. """ from __future__ import annotations from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import Customer, Message async def _seed(session: AsyncSession) -> None: session.add_all( [ # --- messages --------------------------------------------------- Message( msg_id="m_quote_a", channel="wechat", owner_account_id="wx_owner_1", talker_id="t_a", text="这是报价单,请查收", timestamp=1000, type="text", type_raw=1, ), Message( msg_id="m_quote_b", channel="wechat", owner_account_id="wx_owner_1", talker_id="t_b", text="报价能不能再优惠一点", timestamp=2000, type="text", type_raw=1, ), Message( msg_id="m_other", channel="wechat", owner_account_id="wx_owner_1", talker_id="t_a", text="明天见", timestamp=3000, type="text", type_raw=1, ), Message( msg_id="m_quote_wa", channel="whatsapp", owner_account_id="wa_owner_1", talker_id="t_wa", text="quote 报价 via WhatsApp", timestamp=4000, type="text", type_raw=0, ), # --- customers -------------------------------------------------- Customer( friend_id="wx_zhangsan", channel="wechat", owner_account_id="wx_owner_1", friend_nickname="张三", real_name="张三丰", phone="13800000001", wechat_phone="13800000001", last_chat_time=2000, ), Customer( friend_id="wx_lisi", channel="wechat", owner_account_id="wx_owner_1", friend_nickname="李四", real_name=None, phone="13900000002", last_chat_time=1000, ), Customer( friend_id="wa_zhang", channel="whatsapp", owner_account_id="wa_owner_1", friend_nickname="Zhang San", phone="13700000003", last_chat_time=None, ), ] ) await session.commit() # --------------------------------------------------------------------------- # q validation # --------------------------------------------------------------------------- async def test_empty_q_returns_400(make_client): """Empty ``q`` -> HTTP 400 (the search keyword is required).""" client = await make_client(_seed) r = await client.get("/api/search", params={"q": ""}) assert r.status_code == 400 async def test_whitespace_q_returns_400(make_client): """Whitespace-only ``q`` -> HTTP 400 (we reject after stripping).""" client = await make_client(_seed) r = await client.get("/api/search", params={"q": " "}) assert r.status_code == 400 async def test_missing_q_returns_400(make_client): """Missing ``q`` -> HTTP 400 (the param is required).""" client = await make_client(_seed) r = await client.get("/api/search") assert r.status_code == 400 # --------------------------------------------------------------------------- # message search # --------------------------------------------------------------------------- async def test_search_messages_by_text(make_client): """``q=报价&type=messages`` -> only matching messages, ordered desc by time.""" client = await make_client(_seed) r = await client.get( "/api/search", params={"q": "报价", "type": "messages"} ) assert r.status_code == 200 body = r.json() # The wrapper exposes a ``messages`` page; ``customers`` is omitted when # type=messages is requested. assert "messages" in body assert "customers" not in body msgs = body["messages"] assert msgs["total"] == 3 # m_quote_a, m_quote_b, m_quote_wa ids = [it["msg_id"] for it in msgs["items"]] assert set(ids) == {"m_quote_a", "m_quote_b", "m_quote_wa"} # Ordered by timestamp DESC: m_quote_wa(4000) → m_quote_b(2000) → m_quote_a(1000) assert ids == ["m_quote_wa", "m_quote_b", "m_quote_a"] async def test_search_messages_channel_filter(make_client): """``q=报价&type=messages&channel=wechat`` excludes the WhatsApp hit.""" client = await make_client(_seed) r = await client.get( "/api/search", params={"q": "报价", "type": "messages", "channel": "wechat"}, ) assert r.status_code == 200 msgs = r.json()["messages"] assert msgs["total"] == 2 assert {it["msg_id"] for it in msgs["items"]} == { "m_quote_a", "m_quote_b", } assert all(it["channel"] == "wechat" for it in msgs["items"]) async def test_search_messages_time_window(make_client): """``start``/``end`` narrow the message section.""" client = await make_client(_seed) r = await client.get( "/api/search", params={"q": "报价", "type": "messages", "start": 0, "end": 2500}, ) assert r.status_code == 200 msgs = r.json()["messages"] # m_quote_a(1000) + m_quote_b(2000); m_quote_wa(4000) excluded. assert {it["msg_id"] for it in msgs["items"]} == { "m_quote_a", "m_quote_b", } # --------------------------------------------------------------------------- # customer search # --------------------------------------------------------------------------- async def test_search_customers_by_nickname(make_client): """``q=张三&type=customers`` -> finds customers with that nickname/real_name.""" client = await make_client(_seed) r = await client.get( "/api/search", params={"q": "张三", "type": "customers"} ) assert r.status_code == 200 body = r.json() assert "customers" in body assert "messages" not in body custs = body["customers"] # wx_zhangsan (friend_nickname=张三) + wa_zhang (Zhang San matches via # substring "Zhang" — but we search "张三" so only the wx row hits). ids = [it["friend_id"] for it in custs["items"]] assert "wx_zhangsan" in ids # The whatsapp "Zhang San" row should NOT match a Chinese keyword. assert "wa_zhang" not in ids async def test_search_customers_by_phone(make_client): """``q=138`` -> customers whose phone/wechat_phone contains 138.""" client = await make_client(_seed) r = await client.get( "/api/search", params={"q": "138", "type": "customers"} ) assert r.status_code == 200 custs = r.json()["customers"] ids = [it["friend_id"] for it in custs["items"]] assert ids == ["wx_zhangsan"] # only 13800000001 contains "138" async def test_search_customers_channel_filter(make_client): """``channel`` narrows the customer section.""" client = await make_client(_seed) r = await client.get( "/api/search", params={"q": "Zhang", "type": "customers", "channel": "whatsapp"}, ) assert r.status_code == 200 custs = r.json()["customers"] assert custs["total"] == 1 assert custs["items"][0]["friend_id"] == "wa_zhang" # --------------------------------------------------------------------------- # type=all # --------------------------------------------------------------------------- async def test_search_all_returns_both_sections(make_client): """``type=all`` (default) -> response carries both ``messages`` and ``customers``.""" client = await make_client(_seed) r = await client.get("/api/search", params={"q": "报价"}) assert r.status_code == 200 body = r.json() assert "messages" in body assert "customers" in body assert body["messages"]["total"] == 3 # No customer has "报价" in their nickname/name/phone. assert body["customers"]["total"] == 0 async def test_search_all_default_type(make_client): """Omitting ``type`` defaults to ``all``.""" client = await make_client(_seed) r = await client.get("/api/search", params={"q": "张三"}) assert r.status_code == 200 body = r.json() assert "messages" in body assert "customers" in body # No message text contains "张三". assert body["messages"]["total"] == 0 assert body["customers"]["total"] >= 1 async def test_search_all_with_channel_applies_to_both(make_client): """``channel`` filters both sections when ``type=all``.""" client = await make_client(_seed) r = await client.get( "/api/search", params={"q": "报价", "channel": "whatsapp"} ) assert r.status_code == 200 body = r.json() # Only the WhatsApp message matches. assert body["messages"]["total"] == 1 assert body["messages"]["items"][0]["msg_id"] == "m_quote_wa" # No whatsapp customer has "报价". assert body["customers"]["total"] == 0 # --------------------------------------------------------------------------- # pagination # --------------------------------------------------------------------------- async def test_message_pagination(make_client): """``page_size=1`` -> one message per page, total still 3.""" client = await make_client(_seed) r = await client.get( "/api/search", params={"q": "报价", "type": "messages", "page": 1, "page_size": 1}, ) assert r.status_code == 200 msgs = r.json()["messages"] assert msgs["total"] == 3 assert len(msgs["items"]) == 1 # Descending by timestamp -> first item is the WhatsApp row (4000). assert msgs["items"][0]["msg_id"] == "m_quote_wa" async def test_customer_pagination(make_client): """Customers paginate independently with their own page_size.""" client = await make_client(_seed) r = await client.get( "/api/search", params={"q": "1", "type": "customers", "page": 1, "page_size": 1}, ) assert r.status_code == 200 custs = r.json()["customers"] # All seeded phone numbers contain "1" -> all 3. assert custs["total"] == 3 assert len(custs["items"]) == 1 # --------------------------------------------------------------------------- # FTS 加速路径(2026-08-21): 建了 message_fts 索引时搜索走 trigram MATCH, # 结果与 LIKE 一致;未建索引回退 LIKE(现有测试全是这条路径)。 # --------------------------------------------------------------------------- async def test_search_uses_fts_when_available(make_client): """建 FTS 索引后 /api/search 结果与 LIKE 等价(中文≥3字)。""" from sqlalchemy.ext.asyncio import async_sessionmaker from app.db.fts import ensure_fts async def seed(session): await ensure_fts(session) session.add_all([ Message(msg_id="f1", channel="wechat", owner_account_id="o", talker_id="t", text="24年大众途安 简约白", timestamp=1000), Message(msg_id="f2", channel="wechat", owner_account_id="o", talker_id="t", text="丰田汉兰达 2958", timestamp=2000), ]) await session.commit() client = await make_client(seed) r = await client.get("/api/search", params={"q": "大众途安", "type": "messages"}) assert r.status_code == 200 msgs = r.json()["messages"] assert msgs["total"] == 1 assert msgs["items"][0]["msg_id"] == "f1" async def test_search_fts_short_query_falls_back(make_client): """2 字查询(低于 trigram 最小 3 字)回退 LIKE,结果一致。""" from app.db.fts import ensure_fts async def seed(session): await ensure_fts(session) session.add(Message(msg_id="s1", channel="wechat", owner_account_id="o", talker_id="t", text="汉兰达", timestamp=1)) await session.commit() client = await make_client(seed) r = await client.get("/api/search", params={"q": "汉兰", "type": "messages"}) assert r.status_code == 200 assert r.json()["messages"]["total"] == 1