e8c77412fa
- message_fts 外部内容虚拟表+trigram分词(中文子串可MATCH,无需分词库) - 3个触发器随ORM upsert自动同步索引(抓取器零改动) - 搜索路由:≥3字走FTS MATCH,<3字/MySQL回退LIKE - 实测:搜「大众途安」56条,LIKE全扫3.95s→FTS 0.003s;索引+300MB - 7个新测试(建表幂等/触发器同步/中文匹配/短查询回退/未建索引回退),426绿 Co-Authored-By: Claude <noreply@anthropic.com>
358 lines
13 KiB
Python
358 lines
13 KiB
Python
"""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
|