Files
WechatExplorer/tests/unit/local-query-api-service.test.ts
T

85 lines
6.2 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from 'vitest'
const fixture = vi.hoisted(() => ({
contacts: [
{ m_nsUsrName: 'wxid-bobo', m_nsNickName: 'BOBO', md5: 'md5-bobo', type: 'user' as const },
{ m_nsUsrName: 'wxid-bobo-2', m_nsNickName: 'BOBO', md5: 'md5-bobo-2', type: 'user' as const }
],
messages: [
{ id: 'm1', from: 'user', type: '图片', datetime: '2026/8/2 10:00:00', content: '', contentData: { type: 'image', md5: 'image-md5' }, isSender: false, name: 'BOBO', createTime: Math.floor(new Date('2026-08-02T10:00:00+08:00').getTime() / 1000) },
{ id: 'm2', from: 'assistant', type: '文件', datetime: '2026/8/3 10:00:00', content: '', isSender: true, exportMediaType: 'file' as const, exportMediaName: 'a.pdf', createTime: Math.floor(new Date('2026-08-03T10:00:00+08:00').getTime() / 1000) }
]
}))
vi.mock('../../src/main/services/chat-service', () => ({
isReady: () => true,
listContactsAsync: vi.fn(async () => fixture.contacts),
listMessagesAsync: vi.fn(async () => fixture.messages)
}))
import { LocalQueryApiService } from '../../src/main/services/local-query-api-service'
describe('LocalQueryApiService', () => {
const knowledge = { search: vi.fn(async () => ({ state: 'ready', evidence: [{ conversationId: 'md5-bobo', messageId: 'm1', timestamp: 1, sender: 'BOBO', sourceKind: 'text', text: '你好' }], conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true }, voiceCoverage: undefined })) } as any
let service: LocalQueryApiService
beforeEach(() => { vi.clearAllMocks(); if (!fixture.contacts.some((contact) => contact.md5 === 'md5-bobo-2')) fixture.contacts.push({ m_nsUsrName: 'wxid-bobo-2', m_nsNickName: 'BOBO', md5: 'md5-bobo-2', type: 'user' }); service = new LocalQueryApiService(knowledge, () => new Date('2026-09-09T12:00:00+08:00')) })
it('publishes capability contract and resolves previous month', async () => {
expect(service.capabilities().tools.query_messages.messageTypes).toContain('file')
const result = await service.messages({ target: { query: 'BOBO' }, timeRange: { kind: 'previous_month' }, direction: 'any', order: 'asc', limit: 1 })
expect(result.status).toBe('ambiguous_contact')
fixture.contacts.splice(1)
const resolved = await service.messages({ target: { query: 'BOBO' }, timeRange: { kind: 'previous_month' }, direction: 'any', order: 'asc', limit: 1 })
expect(resolved.query?.resolvedTimeRange).toMatchObject({ startTime: expect.any(Number), endTime: expect.any(Number), label: '2026年8月' })
expect(resolved.returnedCount).toBe(1)
})
it('filters direction and message type, and rejects too many variants', async () => {
fixture.contacts.splice(1)
const result = await service.messages({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, direction: 'to_target', messageTypes: ['file'], limit: 1 })
expect(result.messages?.[0].sourceKind).toBe('file')
await expect(service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: 'x', variants: ['1', '2', '3', '4', '5'] })).resolves.toMatchObject({ status: 'invalid_request' })
})
it('returns canonical type and attachment for the earliest non-text message', async () => {
fixture.contacts.splice(1)
const result = await service.messages({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, order: 'asc', limit: 1, excludeSystem: true })
expect(result.messages?.[0]).toMatchObject({ messageType: 'image', sourceKind: 'image', attachment: { kind: 'image' } })
expect(result.messages?.[0]).not.toHaveProperty('text')
})
it('round-trips opaque refs from messages, search, and overview through context', async () => {
fixture.contacts.splice(1)
const queried = await service.messages({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, order: 'asc', limit: 1 })
const queriedRef = queried.messages?.[0]?.messageRef
expect(queriedRef).toEqual(expect.any(String))
await expect(service.context({ messageRef: queriedRef!, before: 0, after: 0 })).resolves.toMatchObject({
status: 'completed',
anchor: { messageRef: queriedRef }
})
knowledge.search.mockResolvedValueOnce({ state: 'ready', evidence: [{ conversationId: 'md5-bobo', messageId: 'local:m1', timestamp: 1, sender: 'BOBO', sourceKind: 'text', text: '你好' }], conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true }, voiceCoverage: undefined })
const searched = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '你好' })
const searchedRef = searched.evidence?.[0]?.messageRef
expect(searchedRef).toBe(queriedRef)
await expect(service.context({ messageRef: searchedRef!, before: 0, after: 0 })).resolves.toMatchObject({ status: 'completed' })
knowledge.search.mockResolvedValueOnce({ state: 'ready', evidence: [{ conversationId: 'md5-bobo', messageId: 'local:m1', timestamp: 1, sender: 'BOBO', sourceKind: 'text', text: '你好' }], conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true }, voiceCoverage: undefined })
const overview = await service.overview({ target: { query: 'BOBO' }, timeRange: { kind: 'all' } })
const overviewRef = overview.evidence?.[0]?.messageRef
expect(overviewRef).toBe(queriedRef)
await expect(service.context({ messageRef: overviewRef!, before: 0, after: 0 })).resolves.toMatchObject({ status: 'completed' })
})
it('sorts overview evidence while preserving the selected set and exposes sampling', async () => {
fixture.contacts.splice(1)
knowledge.search.mockResolvedValueOnce({ state: 'ready', evidence: [
{ conversationId: 'md5-bobo', messageId: 'late', timestamp: 300, sender: 'BOBO', sourceKind: 'text', text: 'late' },
{ conversationId: 'md5-bobo', messageId: 'early', timestamp: 100, sender: 'BOBO', sourceKind: 'text', text: 'early' }
], conversationRetrieval: { totalMessages: 82, chunkCount: 4, complete: true }, voiceCoverage: undefined })
const result = await service.overview({ target: { query: 'BOBO' }, timeRange: { kind: 'previous_month' } })
expect(result.sourceCoverage).toEqual({ state: 'complete', sourceMessageCount: 82 })
expect(result.selection).toEqual({ mode: 'temporal_coverage', selectedEvidenceCount: 2, sampled: true })
expect(result.evidence?.map((item) => item.timestamp)).toEqual([100, 300])
})
})