Files
yunke/yunke-server/tests/test_api_export.py
T
浅念 2e526cd056 feat(staff-friends): 员工客户列表独立页——对齐云客员工详情,好友数/微信群数下钻
员工列表点「好友数」原先跳全局 /customers?owner_account_id=x,布局与全局
列表无异且计数/分页绑全局声明口径(同日 dfa4fdd 修的显示 bug),与云客
「员工名下客户列表」体验割裂。新页 /staff-friends/:ownerId/:tab:

- 微信客户/微信群两 tab(对齐云客截图);标题=员工显示名(入口直带,
  直链经 /api/accounts 解析),返回箭头回员工列表
- 搜索(昵称/微信号/备注名/手机号,服务端 q)+ 查询/导出 CSV
- 发送/接收条数=Customer.send_num/receive_num 累计值(CustomerOut 补
  两列,同步自云客 friend 记录,与截图同源)
- 勾选批量「导出聊天记录」:新端点 GET /api/export/chat-html-zip,复用
  chat-html 渲染器每会话一个 HTML 打 zip(文件名=备注>昵称>friend_id,
  非法字符清洗;单会话 5000 行截断告警;一次上限 50 会话防误选打爆)
- 行内:查看聊天记录→staff-chat;导出HTML聊天记录(时间范围弹窗)
- WA 行保持原入口(该页暂仅微信)

后端 605 测试绿(zip 新 4 例);本地浏览器端到端验证:过滤 9 条/勾选
激活/群 tab/返回箭头;zip 产物 application/zip 双会话双气泡。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-08 16:14:44 +08:00

254 lines
12 KiB
Python

"""Tests for GET /api/export/chat-html(+zip) — 导出 1:1/群聊天为自包含 HTML。
对齐云客微信客户/群列表「导出HTML聊天记录」:按 (channel, owner, talker_id|room_id)
拉消息(可选 start/end 时间范围),渲染为左右气泡 HTML 作为 attachment 返回下载。
zip 版:员工客户页勾选多个客户批量「导出聊天记录」(2026-09-08)。
"""
from __future__ import annotations
import functools
import io
import zipfile
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.routers.export import EXPORT_MAX_ROWS, ZIP_MAX_CONVS
from app.db.models import Customer, Message
async def _seed(session: AsyncSession) -> None:
session.add_all([
Message(msg_id="dm1", channel="wechat", owner_account_id="wx_owner",
talker_id="wx_friend", room_id=None, text="你好",
timestamp=1000, is_send=False, type="text", type_raw=1),
Message(msg_id="dm2", channel="wechat", owner_account_id="wx_owner",
talker_id="wx_friend", room_id=None, text="在的",
timestamp=2000, is_send=True, type="text", type_raw=1),
Message(msg_id="g1", channel="wechat", owner_account_id="wx_owner",
talker_id="room_topic", room_id="g1@chatroom", text="群消息",
timestamp=3000, is_send=True, type="text", type_raw=1),
Message(msg_id="g2", channel="wechat", owner_account_id="wx_owner",
talker_id="room_topic", room_id="g1@chatroom", text="范围外",
timestamp=9999999, is_send=True, type="text", type_raw=1),
# 图文消息 — OSS URL 应直接嵌入 <img>
Message(msg_id="dm3", channel="wechat", owner_account_id="wx_owner",
talker_id="wx_friend", room_id=None, text=None,
timestamp=2500, is_send=False, type="image", type_raw=3,
file_url="https://oss.example.com/x/p1.jpg"),
# 本地 /api/media 路径 — 应显示 "[本地媒体]" 占位
Message(msg_id="dm4", channel="wechat", owner_account_id="wx_owner",
talker_id="wx_friend", room_id=None, text=None,
timestamp=2600, is_send=True, type="voice", type_raw=34,
file_url="/api/media/abc123", duration=8),
])
await session.commit()
async def _seed_many(session: AsyncSession, n: int) -> None:
"""Seed ``n`` DM messages so the row cap can be exercised."""
session.add_all([
Message(
msg_id=f"cap{i}",
channel="wechat",
owner_account_id="wx_owner",
talker_id="wx_friend",
room_id=None,
text=f"msg{i}",
timestamp=1000 + i,
is_send=i % 2 == 0,
type="text",
type_raw=1,
)
for i in range(n)
])
await session.commit()
async def test_export_dm_returns_html(make_client):
client = await make_client(_seed)
r = await client.get("/api/export/chat-html", params={
"channel": "wechat", "owner_account_id": "wx_owner", "talker_id": "wx_friend"})
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/html")
assert "attachment" in r.headers["content-disposition"]
body = r.text
assert "你好" in body and "在的" in body
assert "wx_owner" in body
async def test_export_group_with_time_range(make_client):
client = await make_client(_seed)
r = await client.get("/api/export/chat-html", params={
"channel": "wechat", "owner_account_id": "wx_owner", "room_id": "g1@chatroom",
"start": 0, "end": 5000})
assert r.status_code == 200
assert "群消息" in r.text
assert "范围外" not in r.text # end=5000 过滤掉 timestamp=9999999
async def test_export_empty(make_client):
client = await make_client(_seed)
r = await client.get("/api/export/chat-html", params={
"channel": "wechat", "owner_account_id": "wx_owner", "talker_id": "none"})
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/html")
assert "暂无消息" in r.text
async def test_export_embeds_media(make_client):
"""图片(OSS URL)直接 <img> 嵌入;本地 /api/ 路径显示 [本地媒体] 占位。"""
client = await make_client(_seed)
r = await client.get("/api/export/chat-html", params={
"channel": "wechat", "owner_account_id": "wx_owner", "talker_id": "wx_friend"})
assert r.status_code == 200
body = r.text
# OSS URL → <img> with the URL escaped into src
assert "<img" in body
assert "https://oss.example.com/x/p1.jpg" in body
# 本地路径 → 占位提示,不嵌 <audio>
assert "[本地媒体]" in body
assert "/api/media/abc123" not in body
async def test_export_caps_rows_and_emits_warning(make_client):
"""When the cap is hit, only EXPORT_MAX_ROWS rows render + a warning shows."""
# Seed twice the cap so the truncation is unambiguous.
seed = functools.partial(_seed_many, n=EXPORT_MAX_ROWS * 2)
client = await make_client(seed)
r = await client.get("/api/export/chat-html", params={
"channel": "wechat", "owner_account_id": "wx_owner", "talker_id": "wx_friend"})
assert r.status_code == 200
body = r.text
# The warning DIV is rendered (not just the CSS class in <style>).
assert '<div class="export-warning">' in body
assert str(EXPORT_MAX_ROWS) in body
# The first EXPORT_MAX_ROWS seeded rows (timestamps 1000..1000+N-1) render;
# anything past the cap must NOT appear. msg_id pattern "cap{i}" lets us
# count rendered bubbles precisely.
last_kept = EXPORT_MAX_ROWS - 1
last_dropped = EXPORT_MAX_ROWS # first row beyond the cap
assert f"msg{last_kept}" in body
assert f"msg{last_dropped}" not in body
async def test_export_no_warning_below_cap(make_client):
"""Sub-cap exports render no warning DIV."""
seed = functools.partial(_seed_many, n=10)
client = await make_client(seed)
r = await client.get("/api/export/chat-html", params={
"channel": "wechat", "owner_account_id": "wx_owner", "talker_id": "wx_friend"})
assert r.status_code == 200
# No warning DIV (the CSS class still lives in <style> — that's fine).
assert '<div class="export-warning">' not in r.text
# ---------------------------------------------------------------------------
# 群导出 owner 错位修复(2026-08-22): room 查询不带 owner
# ---------------------------------------------------------------------------
async def test_export_group_ignores_owner_mismatch(make_client):
"""群消息在 owner B 名下,用 owner A + room 导出仍能拿到(不按 owner 过滤)。"""
from app.db.models import Customer as _C # noqa: F401 (对齐既有 seed 风格)
async def seed(session):
session.add_all([
Message(msg_id="gm1", channel="wechat", owner_account_id="ownerB", talker_id=None, room_id="gX@chatroom", text="群消息1", timestamp=100),
Message(msg_id="gm2", channel="wechat", owner_account_id="ownerB", talker_id=None, room_id="gX@chatroom", text="群消息2", timestamp=200),
])
await session.commit()
client = await make_client(seed)
r = await client.get("/api/export/chat-html", params={
"channel": "wechat", "owner_account_id": "ownerA", "room_id": "gX@chatroom"})
assert r.status_code == 200
assert "群消息1" in r.text
assert "群消息2" in r.text
# ---------------------------------------------------------------------------
# GET /api/export/chat-html-zip — 批量导出(2026-09-08 员工客户页)
# ---------------------------------------------------------------------------
async def _seed_zip(session: AsyncSession) -> None:
"""两个客户各几条 DM + 一条同 talker 的群消息(不应混入 DM zip)。"""
session.add_all([
Customer(channel="wechat", owner_account_id="wx_owner", friend_id="f1",
friend_nickname="小蜜", friend_remark="蜜姐"),
Customer(channel="wechat", owner_account_id="wx_owner", friend_id="f2",
friend_nickname="老王"),
Customer(channel="wechat", owner_account_id="wx_owner", friend_id="f3",
friend_nickname='坏/名字<>:"|'),
Message(msg_id="z1", channel="wechat", owner_account_id="wx_owner",
talker_id="f1", room_id=None, text="f1你好", timestamp=1000,
is_send=False, type="text", type_raw=1),
Message(msg_id="z2", channel="wechat", owner_account_id="wx_owner",
talker_id="f2", room_id=None, text="f2在吗", timestamp=2000,
is_send=True, type="text", type_raw=1),
Message(msg_id="z3", channel="wechat", owner_account_id="wx_owner",
talker_id="f2", room_id=None, text="f2更早", timestamp=500,
is_send=True, type="text", type_raw=1),
# f1 名下的群消息:room_id 有值,zip 的 DM 查询必须排除
Message(msg_id="z4", channel="wechat", owner_account_id="wx_owner",
talker_id="f1", room_id="g@chatroom", text="群内消息不进zip",
timestamp=3000, is_send=True, type="text", type_raw=1),
])
await session.commit()
def _zip_entries(r) -> dict[str, str]:
assert r.headers["content-type"].startswith("application/zip")
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
return {name: zf.read(name).decode("utf-8") for name in zf.namelist()}
async def test_zip_two_conversations_with_display_names(make_client):
"""zip 含两个会话 HTML;文件名取备注>昵称;群消息不混入。"""
client = await make_client(_seed_zip)
r = await client.get("/api/export/chat-html-zip", params={
"channel": "wechat", "owner_account_id": "wx_owner",
"talker_ids": "f1,f2"})
assert r.status_code == 200
assert "attachment" in r.headers["content-disposition"]
entries = _zip_entries(r)
assert sorted(entries) == ["01_蜜姐.html", "02_老王.html"]
assert "f1你好" in entries["01_蜜姐.html"]
assert "f2在吗" in entries["02_老王.html"]
# DM 查询排除 room 消息
assert "群内消息不进zip" not in "".join(entries.values())
async def test_zip_time_range_filter(make_client):
"""start/end 同样作用于 zip 内每个会话。"""
client = await make_client(_seed_zip)
r = await client.get("/api/export/chat-html-zip", params={
"channel": "wechat", "owner_account_id": "wx_owner",
"talker_ids": "f2", "start": 1000})
assert r.status_code == 200
entries = _zip_entries(r)
assert "f2在吗" in entries["01_老王.html"]
assert "f2更早" not in entries["01_老王.html"]
async def test_zip_filename_sanitized_and_fallback(make_client):
"""非法字符客户名清洗;无 customer 行的 talker 回退 friend_id。"""
client = await make_client(_seed_zip)
r = await client.get("/api/export/chat-html-zip", params={
"channel": "wechat", "owner_account_id": "wx_owner",
"talker_ids": "f3,f_unknown"})
assert r.status_code == 200
names = sorted(_zip_entries(r))
assert names[0].startswith("01_") and "/" not in names[0] and "<" not in names[0]
assert names[1] == "02_f_unknown.html"
async def test_zip_rejects_empty_and_over_limit(make_client):
"""空列表/超上限 422,防误传。"""
client = await make_client(_seed_zip)
r_empty = await client.get("/api/export/chat-html-zip", params={
"channel": "wechat", "owner_account_id": "wx_owner", "talker_ids": " , "})
assert r_empty.status_code == 422
many = ",".join(f"t{i}" for i in range(ZIP_MAX_CONVS + 1))
r_many = await client.get("/api/export/chat-html-zip", params={
"channel": "wechat", "owner_account_id": "wx_owner", "talker_ids": many})
assert r_many.status_code == 422