Files
zWorkFlow/tests/test_get_chat_images_multishard.py
Belugary 5bc275b81c feat(mcp): get_chat_images/get_voice_messages 加 offset/time_range
\`get_chat_images\` 和 \`get_voice_messages\` 仅有 \`limit\`, 接口与
\`get_chat_history\` / \`search_messages\` (\`offset\` + \`start_time\` +
\`end_time\`) 不对齐:

1. 查不了"某段时间内的图片/语音"
2. 不支持分页, 单次取 \`limit=1000\` 一次性拉
3. LLM 用同样模式调不同工具时签名不一致, 容易出错

两个工具各加 3 个可选参数:
- \`offset: int = 0\`
- \`start_time: str = ""\`
- \`end_time: str = ""\`

复用上游已有的 \`_validate_pagination\` + \`_parse_time_range\` helpers。

- 输入校验失败立即报错 (offset 负数 / 时间格式错 / start > end)
- 每 shard 拉 \`limit + offset\` 张候选, 合并后全局 \`create_time DESC\`
  排序, 切片 \`[offset : offset + limit]\` 出本页
- 单 shard 凑得起本页, 避免某 shard 缺数据时本页变短
- header 显示 offset/limit 和时间范围 (传了才显示)

- 加 \`start_ts=None\` / \`end_ts=None\` 参数
- SQL 动态拼 \`create_time >= ?\` / \`<= ?\` clause
- 不传时序参数完全等价旧行为 (向后兼容)

- 同样 3 个参数 + \`_validate_pagination\` + \`_parse_time_range\`
- VoiceInfo 表 SQL 动态拼 \`chat_name_id = ? AND create_time ?...\`
- 多 shard 各取 \`limit + offset\` 后合并切片

新增 \`tests/test_chat_images_query_align.py\` 8 个 case:
- offset 负数报错
- start > end 报错
- candidate_limit = limit + offset (shard 调用确认)
- 时间参数正确解析为 unix 秒并透传
- offset=2, limit=2 切到全局排序后第 3-4 张
- header 包含时间范围
- header 包含 offset/limit
- 默认调用(不传新参)行为与旧接口一致

修改 \`tests/test_get_chat_images_multishard.py\` 的 fake_list 签名:
- 旧: \`(db_path, table_name, username, lim)\` 位置参
- 新: \`(db_path, table_name, username, limit=20, start_ts=None, end_ts=None)\`
- 既支持旧调用模式 (kwargs), 也兼容新签名

全量 \`pytest tests/\` 208/208 通过。

3 个可选参数全部带默认值 → 既有调用方零修改。

shard candidate=\`limit+offset\` 的成本: 大 offset 时单 shard 请求量
增大。但 image/voice 表每 chat 单 shard 一般 < 10K 条, 实际 cost 可
忽略。如果将来要做"翻 100 页"级深翻, 可以加 keyset pagination, 现
在 offset 模式与 \`get_chat_history\` 一致即可。

与 #103 / #104 触碰同一文件, 合并顺序无所谓 — 后合的 rebase 即可。
2026-05-14 15:36:00 +08:00

134 lines
5.4 KiB
Python

"""Tests for `get_chat_images` multi-shard scanning.
WeChat rolls a chat's messages over to the next `message_N.db` shard once
the current one fills up, so any chat older than the current shard window
has its history split across multiple shards. The other query tools
(`get_chat_history`, `search_messages`, `decode_image`) already scan all
shards via `_find_msg_tables_for_user`; before this fix `get_chat_images`
used the single-shard `_find_msg_table_for_user`, so it silently dropped
every image that lived in a non-first shard.
These tests pin the corrected behaviour: results come from all matching
shards, are sorted by `create_time` DESC across shards, and respect the
`limit` cap.
"""
import unittest
from unittest.mock import patch
import mcp_server
class GetChatImagesMultiShardTests(unittest.TestCase):
def setUp(self):
# `resolve_username` / `get_contact_names` would hit real DBs; stub them.
self._patches = [
patch.object(mcp_server, "resolve_username",
side_effect=lambda x: "wxid_demo"),
patch.object(mcp_server, "get_contact_names",
return_value={"wxid_demo": "Demo"}),
]
for p in self._patches:
p.start()
self.addCleanup(p.stop)
def _run(self, shards, shard_images_map, limit=20):
"""Helper: stub the two collaborators and call the tool."""
def fake_list(db_path, table_name, username, limit=20, start_ts=None, end_ts=None):
return shard_images_map.get(db_path, [])
with patch.object(mcp_server, "_find_msg_tables_for_user",
return_value=shards), \
patch.object(mcp_server._image_resolver, "list_chat_images",
side_effect=fake_list):
return mcp_server.get_chat_images("Demo", limit=limit)
def test_collects_images_from_every_shard(self):
shards = [
{"db_path": "/m/message_1.db", "table_name": "Msg_x",
"max_create_time": 1_800_000_000},
{"db_path": "/m/message_2.db", "table_name": "Msg_x",
"max_create_time": 1_700_000_000},
]
shard_images = {
"/m/message_1.db": [
{"local_id": 11, "create_time": 1_800_000_000, "md5": "a" * 32, "size": 1024},
],
"/m/message_2.db": [
{"local_id": 22, "create_time": 1_700_000_000, "md5": "b" * 32, "size": 2048},
],
}
out = self._run(shards, shard_images)
# Both shards' images must appear; before the fix the message_2.db
# image was silently dropped.
self.assertIn("local_id=11", out)
self.assertIn("local_id=22", out)
self.assertIn("2 张图片", out)
def test_global_sort_by_create_time_desc(self):
# Older shard happens to contain a NEWER image (e.g. when shards are
# ordered by max_create_time but individual rows interleave): the
# output must still be globally sorted, not per-shard concatenated.
shards = [
{"db_path": "/m/message_1.db", "table_name": "Msg_x",
"max_create_time": 1_800_000_000},
{"db_path": "/m/message_2.db", "table_name": "Msg_x",
"max_create_time": 1_700_000_000},
]
shard_images = {
"/m/message_1.db": [
{"local_id": 11, "create_time": 1_750_000_000, "md5": "a" * 32},
],
"/m/message_2.db": [
# Older shard, but this single image is newer than the one above.
{"local_id": 22, "create_time": 1_799_000_000, "md5": "b" * 32},
],
}
out = self._run(shards, shard_images)
pos_22 = out.find("local_id=22")
pos_11 = out.find("local_id=11")
self.assertGreaterEqual(pos_22, 0)
self.assertGreaterEqual(pos_11, 0)
self.assertLess(pos_22, pos_11) # newer first
def test_limit_truncates_globally_across_shards(self):
shards = [
{"db_path": "/m/message_1.db", "table_name": "Msg_x",
"max_create_time": 1_800_000_000},
{"db_path": "/m/message_2.db", "table_name": "Msg_x",
"max_create_time": 1_700_000_000},
]
shard_images = {
"/m/message_1.db": [
{"local_id": i, "create_time": 1_800_000_000 - i}
for i in range(0, 5)
],
"/m/message_2.db": [
{"local_id": 100 + i, "create_time": 1_700_000_000 - i}
for i in range(0, 5)
],
}
out = self._run(shards, shard_images, limit=3)
# 3 newest overall = local_id=0, 1, 2 (all from shard 1, but the
# decision is global, not "first shard wins").
self.assertIn("3 张图片", out)
self.assertIn("local_id=0", out)
self.assertIn("local_id=1", out)
self.assertIn("local_id=2", out)
self.assertNotIn("local_id=100", out)
def test_no_shards_returns_not_found(self):
out = self._run(shards=[], shard_images_map={})
self.assertIn("找不到", out)
def test_all_shards_empty_returns_no_images(self):
shards = [
{"db_path": "/m/message_1.db", "table_name": "Msg_x",
"max_create_time": 0},
]
out = self._run(shards, shard_images_map={"/m/message_1.db": []})
self.assertIn("无图片消息", out)
if __name__ == "__main__":
unittest.main()