From 97c74859c03d1fb8b2d20ad78d67ff7c06fb1861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B5=E6=91=87=E5=B0=8F=E5=AD=90?= <84499436+Wxw-Gu@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:47:33 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=81=94=E7=B3=BB?= =?UTF-8?q?=E4=BA=BA=20API=20=E5=9B=A0=E6=98=B5=E7=A7=B0=E5=A4=87=E6=B3=A8?= =?UTF-8?q?=E6=9C=AA=E8=A1=A5=E5=85=A8=E5=AF=BC=E8=87=B4=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E4=B8=BA=E7=A9=BA=20#51?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes https://github.com/Wxw-Gu/TraceMemo/issues/51 --- src/main/http-server.ts | 14 +- tests/integration/local-api-auth.test.ts | 1 + .../local-api-contact-search.test.ts | 139 ++++++++++++++++++ .../unit/chat-service-contact-search.test.ts | 71 +++++++++ tests/unit/contact-search-index.test.ts | 44 ++++++ 5 files changed, 265 insertions(+), 4 deletions(-) create mode 100644 tests/integration/local-api-contact-search.test.ts create mode 100644 tests/unit/chat-service-contact-search.test.ts create mode 100644 tests/unit/contact-search-index.test.ts diff --git a/src/main/http-server.ts b/src/main/http-server.ts index ca31e0e..e90193a 100644 --- a/src/main/http-server.ts +++ b/src/main/http-server.ts @@ -3,6 +3,7 @@ import http, { IncomingMessage, ServerResponse, Server } from 'http' import { isReady, listContacts, + listContactsAsync, listMessages, getGroupSnapshot, listRecentChat, @@ -230,21 +231,26 @@ const routes: Record = { }) }, - '/api/v1/contact': ({ res, url }) => { + '/api/v1/contact': async ({ res, url }) => { if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化') const filter = url.searchParams.get('filter') || undefined const type = url.searchParams.get('type') || undefined - let contacts = listContacts(filter) + // Use the hydrated data source: on macOS the session cache only carries raw + // ids until display names / contact identities are hydrated, so the sync + // `listContacts` would miss nickname and remark matches (Issue #51). + let contacts = await listContactsAsync(filter) if (type === 'user' || type === 'group') { contacts = contacts.filter((c) => c.type === type) } sendJson(res, 200, { count: contacts.length, contacts }) }, - '/api/v1/chatroom': ({ res, url }) => { + '/api/v1/chatroom': async ({ res, url }) => { if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化') const keyword = url.searchParams.get('keyword') || '' - let groups = listContacts().filter((c) => c.type === 'group') + // Same hydration requirement as /api/v1/contact: group display names are + // exactly the fields that stay un-hydrated on macOS. + let groups = (await listContactsAsync()).filter((c) => c.type === 'group') if (keyword) { const lower = keyword.toLowerCase() groups = groups.filter( diff --git a/tests/integration/local-api-auth.test.ts b/tests/integration/local-api-auth.test.ts index 107b4ae..57d4e35 100644 --- a/tests/integration/local-api-auth.test.ts +++ b/tests/integration/local-api-auth.test.ts @@ -40,6 +40,7 @@ vi.mock('electron', () => ({ vi.mock('../../src/main/services/chat-service', () => ({ isReady: () => true, listContacts: () => fixture.contacts, + listContactsAsync: async () => fixture.contacts, listMessages: () => fixture.chatlogMessages, getGroupSnapshot: () => ({ members: [] }), listRecentChat: () => [], diff --git a/tests/integration/local-api-contact-search.test.ts b/tests/integration/local-api-contact-search.test.ts new file mode 100644 index 0000000..7442d9f --- /dev/null +++ b/tests/integration/local-api-contact-search.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { getPath: () => '/tmp/wxe-local-api-contact-search' }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (buffer: Buffer) => buffer.toString('utf8').replace(/^encrypted:/, '') + } +})) + +vi.mock('../../src/main/group-report-service', () => ({ + exportGroupReport: vi.fn(async () => ({ success: true })) +})) + +vi.mock('../../src/main/services/agent-group-report-service', () => ({ + generateAgentGroupReport: vi.fn(async () => ({ success: true })) +})) + +vi.mock('../../src/main/services/agent-hub-service', () => ({ + agentHubService: { + getStatus: () => ({ + hub: 'online', + connector: 'online', + dataApi: 'online', + databaseReady: true + }), + testSend: vi.fn(async () => ({ success: true, status: 'sent' })) + } +})) + +// NOTE: chat-service is intentionally NOT mocked here — the HTTP layer must be +// exercised against the real contact search path so hydration is on the hook. +import type { WechatDb } from '../../src/main/wechat-db' +import { setChatDb } from '../../src/main/services/chat-service' +import { startHttpServer, type HttpServerHandle } from '../../src/main/http-server' + +const TOKEN = 'B'.repeat(43) +const AUTH_HEADERS = { Authorization: `Bearer ${TOKEN}` } +const handles: HttpServerHandle[] = [] + +interface ContactSearchResponse { + count: number + contacts: Array<{ type: string; m_nsNickName: string }> +} + +async function startServer(): Promise { + const handle = await startHttpServer('127.0.0.1', 0, { tokenProvider: () => TOKEN }) + handles.push(handle) + return `http://${handle.host}:${handle.port}` +} + +/** + * A WechatDb stand-in that reproduces macOS behaviour: raw ids in the session + * cache until `getSessionsAsync({ hydrateDisplayNames: true })` runs. + */ +function installMacOsStyleFixtureDb(): void { + const userSession = { username: 'wxid_fixture_user', nickname: 'wxid_fixture_user' } + const groupSession = { username: 'fixture_group@chatroom', nickname: 'fixture_group@chatroom' } + + const getSessionsAsync = vi.fn(async (options: { hydrateDisplayNames?: boolean }) => { + if (options.hydrateDisplayNames) { + userSession.nickname = '张三' + groupSession.nickname = '张三的测试群' + } + return [userSession, groupSession] + }) + + const fakeDb = { + close: vi.fn(), + md5: (value: string) => `md5-${value}`, + hydrateContactIdentitiesAsync: vi.fn(async () => undefined), + getAllGroupContacts: () => ({ [`md5-${groupSession.username}`]: groupSession.nickname }), + getUserList: () => [ + { + m_nsUsrName: userSession.username, + nickname: userSession.nickname, + wxid: userSession.username + }, + { + m_nsUsrName: groupSession.username, + nickname: groupSession.nickname + } + ], + getWcdb4Client: () => ({ getSessionsAsync }) + } as unknown as WechatDb + + setChatDb(fakeDb) +} + +describe('Local API contact search (Issue #51)', () => { + beforeEach(() => installMacOsStyleFixtureDb()) + + afterEach(async () => { + await Promise.all(handles.splice(0).map((handle) => handle.close())) + setChatDb(null) + }) + + it('returns the contact and the group when one keyword matches both', async () => { + const base = await startServer() + const response = await fetch(`${base}/api/v1/contact?filter=${encodeURIComponent('张三')}`, { + headers: AUTH_HEADERS + }) + + expect(response.status).toBe(200) + const body = (await response.json()) as ContactSearchResponse + expect(body.count).toBe(2) + expect(body.contacts.map((contact) => contact.type).sort()).toEqual(['group', 'user']) + }) + + it('keeps the type=user and type=group filters intact', async () => { + const base = await startServer() + const query = `filter=${encodeURIComponent('张三')}` + + const users = (await ( + await fetch(`${base}/api/v1/contact?${query}&type=user`, { headers: AUTH_HEADERS }) + ).json()) as ContactSearchResponse + expect(users.count).toBe(1) + expect(users.contacts.every((contact) => contact.type === 'user')).toBe(true) + expect(users.contacts.some((contact) => contact.m_nsNickName === '张三')).toBe(true) + + const groups = (await ( + await fetch(`${base}/api/v1/contact?${query}&type=group`, { headers: AUTH_HEADERS }) + ).json()) as ContactSearchResponse + expect(groups.count).toBe(1) + expect(groups.contacts.every((contact) => contact.type === 'group')).toBe(true) + }) + + it('hydrates group names before the chatroom keyword search runs', async () => { + const base = await startServer() + const response = await fetch(`${base}/api/v1/chatroom?keyword=${encodeURIComponent('张三')}`, { + headers: AUTH_HEADERS + }) + + expect(response.status).toBe(200) + const body = (await response.json()) as { count: number } + expect(body.count).toBe(1) + }) +}) diff --git a/tests/unit/chat-service-contact-search.test.ts b/tests/unit/chat-service-contact-search.test.ts new file mode 100644 index 0000000..8a7a324 --- /dev/null +++ b/tests/unit/chat-service-contact-search.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WechatDb } from '../../src/main/wechat-db' +import { listContacts, listContactsAsync, setChatDb } from '../../src/main/services/chat-service' + +/** + * Issue #51 root-cause evidence. + * + * `listContactsAsync` hydrates display names / identities before filtering, + * while the synchronous `listContacts` reads whatever the session cache holds. + * On macOS the session cache frequently only carries raw ids (mirrors the + * production comment in `listContactsAsync`), so the sync path cannot match a + * Chinese nickname even though hydration makes both the contact and the group + * searchable. + */ + +const HYDRATED_USER_NAME = '张三' +const HYDRATED_GROUP_NAME = '张三的测试群' + +function createMacOsStyleFixtureDb(): WechatDb { + // Before hydration the macOS session rows only expose raw identifiers. + const userSession = { username: 'wxid_fixture_user', nickname: 'wxid_fixture_user' } + const groupSession = { username: 'fixture_group@chatroom', nickname: 'fixture_group@chatroom' } + + const getSessionsAsync = vi.fn(async (options: { hydrateDisplayNames?: boolean }) => { + if (options.hydrateDisplayNames) { + userSession.nickname = HYDRATED_USER_NAME + groupSession.nickname = HYDRATED_GROUP_NAME + } + return [userSession, groupSession] + }) + + const fakeDb = { + close: vi.fn(), + md5: (value: string) => `md5-${value}`, + hydrateContactIdentitiesAsync: vi.fn(async () => undefined), + getAllGroupContacts: () => ({ [`md5-${groupSession.username}`]: groupSession.nickname }), + getUserList: () => [ + { + m_nsUsrName: userSession.username, + nickname: userSession.nickname, + wxid: userSession.username + }, + { + m_nsUsrName: groupSession.username, + nickname: groupSession.nickname + } + ], + getWcdb4Client: () => ({ getSessionsAsync }) + } as unknown as WechatDb + + return fakeDb +} + +describe('chat service contact search on un-hydrated macOS sessions', () => { + afterEach(() => setChatDb(null)) + + it('sync listContacts cannot see display names that only hydration provides', () => { + setChatDb(createMacOsStyleFixtureDb()) + + expect(listContacts('张三')).toEqual([]) + }) + + it('async listContactsAsync hydrates first and returns both the contact and the group', async () => { + setChatDb(createMacOsStyleFixtureDb()) + + const matches = await listContactsAsync('张三') + + expect(matches.map((contact) => contact.type).sort()).toEqual(['group', 'user']) + expect(matches.every((contact) => contact.m_nsNickName.includes('张三'))).toBe(true) + }) +}) diff --git a/tests/unit/contact-search-index.test.ts b/tests/unit/contact-search-index.test.ts new file mode 100644 index 0000000..389729e --- /dev/null +++ b/tests/unit/contact-search-index.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import type { Contact } from '../../src/shared/types' +import { buildContactSearchIndex, filterContactSearchIndex } from '../../src/shared/contact-search' + +/** + * Issue #51 regression guard for the *shared* search primitives. + * + * The bug report claims that when one keyword matches a private contact AND a + * group chat, the API returns nothing. These tests pin down that multi-match is + * itself NOT the problem: the shared index/filter pair already returns every + * match. The real defect lived in the data the HTTP layer fed into it. + */ + +const user: Contact = { + m_nsUsrName: 'wxid_fixture_user', + m_nsNickName: '张三', + md5: 'fixture-user-md5', + type: 'user', + remark: '张三' +} + +const group: Contact = { + m_nsUsrName: 'fixture_group@chatroom', + m_nsNickName: '张三的测试群', + md5: 'fixture-group-md5', + type: 'group' +} + +describe('filterContactSearchIndex with a keyword hitting a contact and a group', () => { + it('returns both matches instead of collapsing to an empty list', () => { + const index = buildContactSearchIndex([user, group]) + expect(filterContactSearchIndex(index, '张三')).toEqual([user, group]) + }) + + it('still isolates the results with type=user', () => { + const index = buildContactSearchIndex([user, group]) + expect(filterContactSearchIndex(index, '张三', 'user')).toEqual([user]) + }) + + it('still isolates the results with type=group', () => { + const index = buildContactSearchIndex([user, group]) + expect(filterContactSearchIndex(index, '张三', 'group')).toEqual([group]) + }) +})