From 017db8eb214bdb235f75ebc11990dcffbafbc289 Mon Sep 17 00:00:00 2001 From: HAN Fang <91113050+ShiTan-677@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:34:00 +0800 Subject: [PATCH 1/4] fix(contacts): require a unique match before read or export --- scripts/common/export_chat.py | 4 +- scripts/common/query.py | 4 +- tests/test_contact_ambiguity.py | 83 +++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 tests/test_contact_ambiguity.py diff --git a/scripts/common/export_chat.py b/scripts/common/export_chat.py index 47ad227..31ce6d2 100644 --- a/scripts/common/export_chat.py +++ b/scripts/common/export_chat.py @@ -219,8 +219,8 @@ def main(): if not matched: print(f"未找到匹配 '{args.contact}' 的联系人", file=sys.stderr) sys.exit(1) - if len(matched) > 5: - print(f"匹配太多 ({len(matched)} 个),请更精确:", file=sys.stderr) + if len(matched) > 1: + print(f"匹配不唯一 ({len(matched)} 个),请使用具体 wxid 选择:", file=sys.stderr) for _, wxid, display in matched[:10]: print(f" {display} (wxid: {wxid})", file=sys.stderr) sys.exit(1) diff --git a/scripts/common/query.py b/scripts/common/query.py index 6ddbeb2..d6449c1 100644 --- a/scripts/common/query.py +++ b/scripts/common/query.py @@ -79,8 +79,8 @@ def read_chat(contact: str, limit: int = 50, days: int = 7) -> dict: matched = contacts.find_contact(contact, name2id) if not matched: return {"error": f"未找到匹配 '{contact}' 的联系人", "candidates": []} - if len(matched) > 5: - return {"error": f"匹配 '{contact}' 的联系人太多({len(matched)})", + if len(matched) > 1: + return {"error": f"匹配 '{contact}' 的联系人不唯一({len(matched)}),请使用具体 wxid 选择", "candidates": [{"wxid": w, "display": d} for _t, w, d in matched[:10]]} out = [] for table, wxid, display in matched: diff --git a/tests/test_contact_ambiguity.py b/tests/test_contact_ambiguity.py new file mode 100644 index 0000000..cecbeaa --- /dev/null +++ b/tests/test_contact_ambiguity.py @@ -0,0 +1,83 @@ +import os +import sqlite3 +import sys + +import pytest + + +@pytest.fixture(autouse=True) +def _clear_contact_cache(): + import contacts + + contacts.invalidate_cache() + yield + contacts.invalidate_cache() + + +def _same_named_contacts(root, count): + import contacts + import db + from conftest import SAMPLE_WXID + + storage = os.path.join(root, 'wxid_test001_a2f4', 'db_storage') + with sqlite3.connect(os.path.join(storage, 'contact', 'contact.db')) as con: + con.execute("UPDATE contact SET nick_name='Synthetic Alex', remark='' WHERE username=?", (SAMPLE_WXID,)) + con.executemany('INSERT INTO contact VALUES (?, ?, ?, ?, ?)', [ + (f'wxid_peer{index}', '', 'Synthetic Alex', '', 3) for index in range(1, count) + ]) + with sqlite3.connect(os.path.join(storage, 'message', 'message_0.db')) as con: + con.executemany('INSERT INTO Name2Id VALUES (?)', [(f'wxid_peer{i}',) for i in range(1, count)]) + db.reset_caches() + contacts.invalidate_cache() + + +@pytest.mark.parametrize('count', [2, 5, 6]) +def test_read_requires_selection_before_message_reads(win_backend, monkeypatch, count): + import query + + _same_named_contacts(win_backend, count) + monkeypatch.setattr(query, '_msg_dbs_tables', lambda: pytest.fail('Ambiguous reads must not fetch messages')) + result = query.read_chat('Synthetic Alex') + assert 'error' in result + assert len(result['candidates']) == count + assert 'chats' not in result + + +@pytest.mark.parametrize('count', [2, 5, 6]) +def test_export_requires_selection_before_fetch_or_file_write(win_backend, tmp_path, monkeypatch, capsys, count): + import export_chat + + _same_named_contacts(win_backend, count) + output = tmp_path / 'existing.txt' + output.write_text('leave this file unchanged') + monkeypatch.setattr(sys, 'argv', ['export_chat.py', 'Synthetic Alex', '--no-transcribe', '-o', str(output)]) + monkeypatch.setattr(export_chat, 'fetch', lambda *_: pytest.fail('Ambiguous exports must not fetch messages')) + with pytest.raises(SystemExit) as exc: + export_chat.main() + assert exc.value.code == 1 + assert output.read_text() == 'leave this file unchanged' + assert 'wxid_peer1' in capsys.readouterr().err + + +def test_read_exact_identifier_selects_one_of_the_candidates(win_backend): + import query + from conftest import SAMPLE_WXID + + _same_named_contacts(win_backend, 2) + result = query.read_chat(SAMPLE_WXID, days=100000) + assert len(result['chats']) == 1 + assert result['chats'][0]['wxid'] == SAMPLE_WXID + assert len(result['chats'][0]['messages']) == 2 + assert query.read_chat('does-not-exist')['candidates'] == [] + + +def test_export_exact_identifier_still_works(win_backend, tmp_path, monkeypatch): + import export_chat + from conftest import SAMPLE_WXID + + _same_named_contacts(win_backend, 2) + output = tmp_path / 'selected.txt' + monkeypatch.setattr(sys, 'argv', ['export_chat.py', SAMPLE_WXID, '--start', '1970-01-01', + '--no-transcribe', '-o', str(output)]) + export_chat.main() + assert output.exists() From 807142c1f71c498f0cc0248cb3fd2005761fd205 Mon Sep 17 00:00:00 2001 From: HAN Fang <91113050+ShiTan-677@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:53:45 +0800 Subject: [PATCH 2/4] test(contacts): isolate selection checks from optional zstd availability --- tests/test_contact_ambiguity.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/test_contact_ambiguity.py b/tests/test_contact_ambiguity.py index cecbeaa..18f8dd6 100644 --- a/tests/test_contact_ambiguity.py +++ b/tests/test_contact_ambiguity.py @@ -14,6 +14,16 @@ def _clear_contact_cache(): contacts.invalidate_cache() +@pytest.fixture +def export_cli(monkeypatch): + import export_chat + + # These fixtures contain no compressed messages. Exercise contact selection + # independently of the optional zstd decoder's availability on CI hosts. + monkeypatch.setattr(export_chat.message, 'has_zstd_decoder', lambda: True) + return export_chat + + def _same_named_contacts(root, count): import contacts import db @@ -44,8 +54,8 @@ def test_read_requires_selection_before_message_reads(win_backend, monkeypatch, @pytest.mark.parametrize('count', [2, 5, 6]) -def test_export_requires_selection_before_fetch_or_file_write(win_backend, tmp_path, monkeypatch, capsys, count): - import export_chat +def test_export_requires_selection_before_fetch_or_file_write(win_backend, tmp_path, monkeypatch, capsys, count, export_cli): + export_chat = export_cli _same_named_contacts(win_backend, count) output = tmp_path / 'existing.txt' @@ -71,8 +81,8 @@ def test_read_exact_identifier_selects_one_of_the_candidates(win_backend): assert query.read_chat('does-not-exist')['candidates'] == [] -def test_export_exact_identifier_still_works(win_backend, tmp_path, monkeypatch): - import export_chat +def test_export_exact_identifier_still_works(win_backend, tmp_path, monkeypatch, export_cli): + export_chat = export_cli from conftest import SAMPLE_WXID _same_named_contacts(win_backend, 2) From ecfae2255d72ba0cc40c9a63cf28bcc154fdea82 Mon Sep 17 00:00:00 2001 From: HAN Fang <91113050+ShiTan-677@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:57:57 +0800 Subject: [PATCH 3/4] test(contacts): use portable timestamps for synthetic export fixtures --- tests/test_contact_ambiguity.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/test_contact_ambiguity.py b/tests/test_contact_ambiguity.py index 18f8dd6..935496d 100644 --- a/tests/test_contact_ambiguity.py +++ b/tests/test_contact_ambiguity.py @@ -83,11 +83,16 @@ def test_read_exact_identifier_selects_one_of_the_candidates(win_backend): def test_export_exact_identifier_still_works(win_backend, tmp_path, monkeypatch, export_cli): export_chat = export_cli - from conftest import SAMPLE_WXID + import db + from conftest import SAMPLE_TABLE, SAMPLE_WXID _same_named_contacts(win_backend, 2) + with sqlite3.connect(os.path.join(db.find_data_dir(), 'message', 'message_0.db')) as con: + # Use modern synthetic timestamps: naive epoch-boundary timestamps are + # outside the supported range of Windows' local-time conversion. + con.execute(f'UPDATE {SAMPLE_TABLE} SET create_time=1767225600+local_id') output = tmp_path / 'selected.txt' - monkeypatch.setattr(sys, 'argv', ['export_chat.py', SAMPLE_WXID, '--start', '1970-01-01', + monkeypatch.setattr(sys, 'argv', ['export_chat.py', SAMPLE_WXID, '--start', '2025-01-01', '--no-transcribe', '-o', str(output)]) export_chat.main() - assert output.exists() + assert 'hello' in output.read_text() From 80942a06cea7927a238bbeecd5df3dba63db06a6 Mon Sep 17 00:00:00 2001 From: HAN Fang <91113050+ShiTan-677@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:01:32 +0800 Subject: [PATCH 4/4] test(contacts): read synthetic transcripts as UTF-8 --- tests/test_contact_ambiguity.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_contact_ambiguity.py b/tests/test_contact_ambiguity.py index 935496d..a06b88d 100644 --- a/tests/test_contact_ambiguity.py +++ b/tests/test_contact_ambiguity.py @@ -59,13 +59,13 @@ def test_export_requires_selection_before_fetch_or_file_write(win_backend, tmp_p _same_named_contacts(win_backend, count) output = tmp_path / 'existing.txt' - output.write_text('leave this file unchanged') + output.write_text('leave this file unchanged', encoding='utf-8') monkeypatch.setattr(sys, 'argv', ['export_chat.py', 'Synthetic Alex', '--no-transcribe', '-o', str(output)]) monkeypatch.setattr(export_chat, 'fetch', lambda *_: pytest.fail('Ambiguous exports must not fetch messages')) with pytest.raises(SystemExit) as exc: export_chat.main() assert exc.value.code == 1 - assert output.read_text() == 'leave this file unchanged' + assert output.read_text(encoding='utf-8') == 'leave this file unchanged' assert 'wxid_peer1' in capsys.readouterr().err @@ -95,4 +95,4 @@ def test_export_exact_identifier_still_works(win_backend, tmp_path, monkeypatch, monkeypatch.setattr(sys, 'argv', ['export_chat.py', SAMPLE_WXID, '--start', '2025-01-01', '--no-transcribe', '-o', str(output)]) export_chat.main() - assert 'hello' in output.read_text() + assert 'hello' in output.read_text(encoding='utf-8')