956f243fce
Co-Authored-By: Claude Code <noreply@anthropic.com>
191 lines
7.0 KiB
Python
191 lines
7.0 KiB
Python
"""C1 词库 CRUD + 预览。"""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
|
|
from app.analytics.sensitive import scanner
|
|
from app.analytics.sensitive import words as words_svc
|
|
from app.db.models import Message, SensitiveHit, SensitiveScanState
|
|
|
|
|
|
async def test_create_and_duplicate(make_client):
|
|
async def seed(session):
|
|
w = await words_svc.create_word(
|
|
session, word="飞单", category="私单", exclude_words=["飞单群"]
|
|
)
|
|
assert w.id is not None and w.enabled is True
|
|
try:
|
|
await words_svc.create_word(session, word="飞单")
|
|
raise AssertionError("should raise")
|
|
except ValueError:
|
|
pass
|
|
|
|
await make_client(seed)
|
|
|
|
|
|
async def test_preview_count_and_excludes(make_client):
|
|
async def seed(session):
|
|
ts = 1756900000000
|
|
for i, text in enumerate(["今天聊飞单的事", "飞单群公告", "正常聊天"]):
|
|
session.add(Message(msg_id=f"m{i}", channel="wechat",
|
|
owner_account_id="wx1", talker_id="t1",
|
|
timestamp=ts + i, is_send=False, type="text",
|
|
text=text, fetched_at=dt.datetime(2026, 9, 4)))
|
|
await session.commit()
|
|
n1 = await words_svc.preview_count(session, "飞单", None)
|
|
assert n1 == 2 # SQLite 测试库走 LIKE 路径
|
|
n2 = await words_svc.preview_count(session, "飞单", ["飞单群"])
|
|
assert n2 == 1 # 排除词剔除「飞单群公告」
|
|
|
|
await make_client(seed)
|
|
|
|
|
|
async def test_api_words_crud(make_client):
|
|
async def seed(session):
|
|
...
|
|
|
|
client = await make_client(seed)
|
|
r = await client.post("/api/analytics/sensitive/words",
|
|
json={"word": "私单", "category": "飞单"})
|
|
assert r.status_code == 200 and r.json()["word"] == "私单"
|
|
r = await client.post("/api/analytics/sensitive/words",
|
|
json={"word": "私单"})
|
|
assert r.status_code == 409
|
|
r = await client.post("/api/analytics/sensitive/words/preview",
|
|
json={"word": "私单"})
|
|
assert r.status_code == 200 and r.json()["count"] == 0
|
|
wid = 1
|
|
r = await client.patch(f"/api/analytics/sensitive/words/{wid}",
|
|
json={"enabled": False})
|
|
assert r.status_code == 200 and r.json()["enabled"] is False
|
|
r = await client.delete(f"/api/analytics/sensitive/words/{wid}")
|
|
assert r.status_code == 200
|
|
|
|
|
|
# 注:单字词 MySQL 拒预览分支(words.py ValueError)在 SQLite 测试库不可达
|
|
# ——dialect 判定不满足,不做单测;由注释 + 路由 400 映射保障。
|
|
|
|
|
|
async def test_api_patch_unknown_404(make_client):
|
|
async def seed(session):
|
|
...
|
|
|
|
client = await make_client(seed)
|
|
r = await client.patch("/api/analytics/sensitive/words/999",
|
|
json={"enabled": False})
|
|
assert r.status_code == 404
|
|
|
|
|
|
async def test_api_delete_unknown_404(make_client):
|
|
async def seed(session):
|
|
...
|
|
|
|
client = await make_client(seed)
|
|
r = await client.delete("/api/analytics/sensitive/words/999")
|
|
assert r.status_code == 404
|
|
|
|
|
|
async def test_api_create_empty_word_409(make_client):
|
|
async def seed(session):
|
|
...
|
|
|
|
client = await make_client(seed)
|
|
r = await client.post("/api/analytics/sensitive/words",
|
|
json={"word": " "})
|
|
assert r.status_code == 409
|
|
|
|
|
|
# ---------------------------------------------------------------- C1 scanner
|
|
|
|
def _msg(msg_id, text, fetched, ts=1756900000000, talker="t1"):
|
|
return Message(msg_id=msg_id, channel="wechat", owner_account_id="wx1",
|
|
talker_id=talker, timestamp=ts, is_send=False, type="text",
|
|
text=text, fetched_at=fetched)
|
|
|
|
|
|
async def test_scan_incremental_and_idempotent(make_client):
|
|
async def seed(session):
|
|
f1 = dt.datetime(2026, 9, 4, 10, 0)
|
|
session.add(_msg("m1", "聊聊私单", f1))
|
|
session.add(_msg("m2", "正常", f1))
|
|
await words_svc.create_word(session, word="私单")
|
|
await session.commit()
|
|
r1 = await scanner.run_incremental(session)
|
|
await session.commit()
|
|
assert r1["hits"] == 1
|
|
assert r1["new_watermark"] == f1
|
|
r2 = await scanner.run_incremental(session) # 再跑:水位之后无新行
|
|
await session.commit()
|
|
assert r2["hits"] == 0
|
|
from sqlalchemy import func, select
|
|
|
|
n = (await session.execute(
|
|
select(func.count()).select_from(SensitiveHit)
|
|
)).scalar_one()
|
|
assert n == 1 # 幂等不重复
|
|
|
|
await make_client(seed)
|
|
|
|
|
|
async def test_late_arriving_history_scanned(make_client):
|
|
"""迟到数据:timestamp 很老但 fetched_at 新 → 必须被扫到(P2 水位设计)。"""
|
|
async def seed(session):
|
|
old = dt.datetime(2026, 9, 1, 8, 0)
|
|
session.add(_msg("m_old", "历史私单消息", old))
|
|
await words_svc.create_word(session, word="私单")
|
|
await session.commit()
|
|
r1 = await scanner.run_incremental(session)
|
|
await session.commit()
|
|
assert r1["hits"] == 1
|
|
# 迟到行:timestamp 比 m_old 的还老(fetched_at 现在)——
|
|
# 错误的 timestamp 水位实现会漏掉它,fetched_at 水位必须抓到
|
|
session.add(_msg("m_late", "迟到的私单", dt.datetime(2026, 9, 4, 12, 0),
|
|
ts=1735000000000))
|
|
await session.commit()
|
|
r2 = await scanner.run_incremental(session)
|
|
await session.commit()
|
|
assert r2["hits"] == 1 # 按 fetched_at 水位,迟到历史不漏
|
|
|
|
await make_client(seed)
|
|
|
|
|
|
async def test_single_char_like_fallback(make_client):
|
|
async def seed(session):
|
|
session.add(_msg("m1", "坑爹", dt.datetime(2026, 9, 4, 10, 0)))
|
|
await words_svc.create_word(session, word="坑") # 单字
|
|
await session.commit()
|
|
r = await scanner.run_incremental(session)
|
|
await session.commit()
|
|
assert r["hits"] == 1 # 单字走 LIKE 子串,增量行少语义精确
|
|
|
|
await make_client(seed)
|
|
|
|
|
|
async def test_disposition_preserved(make_client):
|
|
"""重扫已处置行不覆盖 disposition。"""
|
|
async def seed(session):
|
|
session.add(_msg("m1", "私单", dt.datetime(2026, 9, 4, 10, 0)))
|
|
await words_svc.create_word(session, word="私单")
|
|
await session.commit()
|
|
await scanner.run_incremental(session)
|
|
await session.commit()
|
|
from sqlalchemy import select
|
|
|
|
hit = (await session.execute(
|
|
select(SensitiveHit)
|
|
)).scalars().one()
|
|
hit.disposition = "false_positive"
|
|
await session.commit()
|
|
# 重扫同词同消息(水位回拨模拟)
|
|
st = await session.get(SensitiveScanState, "message_text")
|
|
st.last_fetched_at = None
|
|
await session.commit()
|
|
await scanner.run_incremental(session)
|
|
await session.commit()
|
|
hit2 = (await session.execute(
|
|
select(SensitiveHit)
|
|
)).scalars().one()
|
|
assert hit2.disposition == "false_positive"
|
|
|
|
await make_client(seed)
|