\`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 即可。
134 lines
5.4 KiB
Python
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()
|