import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' const fixture = vi.hoisted(() => ({ root: `/tmp/tracememo-agent-api-phase2-${process.pid}`, contacts: [ { m_nsUsrName: 'room@chatroom', m_nsNickName: '产品群', md5: 'room-md5', type: 'group' as const }, { m_nsUsrName: 'wxid_friend', m_nsNickName: 'Alice', md5: 'friend-md5', type: 'user' as const, wxid: 'wxid_friend' } ], state: { enabled: true, running: true, nativeMonitorActive: true, monitoredRoomIds: ['room@chatroom'], monitoredGroupCount: 1, monitorSelectionConfigured: true, totalEventCount: 2, lastCheckedAt: 1_728_000_000_000, lastReadAt: 1_727_000_000_000, unreadCount: 1 }, events: [ { id: 'event-2', contactId: 'room-md5', roomId: 'room@chatroom', groupName: '产品群', memberWxid: 'wxid_b', memberName: '乙', wechatName: '乙', groupRemark: '群乙', contactRemark: '', previousCount: 3, currentCount: 2, delta: -1, message: '乙退出了产品群', detectedAt: 1_728_000_000_000 }, { id: 'event-1', contactId: 'room-md5', roomId: 'room@chatroom', groupName: '产品群', memberWxid: 'wxid_a', memberName: '甲', wechatName: '甲', groupRemark: '', contactRemark: '', previousCount: 4, currentCount: 3, delta: -1, message: '甲退出了产品群', detectedAt: 1_727_000_000_000 } ] })) vi.mock('electron', () => ({ app: { getPath: () => fixture.root, getVersion: () => 'test-version' }, safeStorage: { isEncryptionAvailable: () => true, encryptString: (value: string) => Buffer.from(value), decryptString: (value: Buffer) => value.toString() } })) vi.mock('../../src/main/services/chat-service', () => ({ isReady: () => true, listContacts: () => fixture.contacts, listContactsAsync: async () => fixture.contacts, listMessages: () => [], getGroupSnapshot: () => null, listRecentChat: () => [], resolveMd5: () => null })) vi.mock('../../src/main/group-report-service', () => ({ exportGroupReport: vi.fn() })) vi.mock('../../src/main/services/agent-group-report-service', () => ({ generateAgentGroupReport: vi.fn() })) vi.mock('../../src/main/services/agent-hub-service', () => ({ agentHubService: { getStatus: () => ({ connector: 'offline' }) } })) import { LocalAgentApiService } from '../../src/main/services/local-agent-api-service' import { startHttpServer, type HttpServerHandle } from '../../src/main/http-server' import type { GroupMemberStatsResult } from '../../src/shared/group-stats' const TOKEN = 'T'.repeat(43) const handles: HttpServerHandle[] = [] function createApi(): LocalAgentApiService { const monitorState = fixture.state return new LocalAgentApiService({ automationRuleStore: { listRules: () => [], getRule: () => undefined, createRule: () => { throw new Error('not used') }, updateRule: () => undefined, deleteRule: () => false, setRuleEnabled: () => undefined } as never, automationExecutionLogService: { list: () => [] } as never, listContacts: async () => fixture.contacts, isDatabaseReady: () => true, getVersion: () => 'test-version', getPersonalWechatCapability: async () => ({ supported: true, ready: false, status: 'needs_binding', capabilities: { text: false, image: false, voice: false } } as never), getAgentHubStatus: () => ({ connector: 'offline' }), getGroupExitMonitorState: () => ({ ...monitorState }), configureGroupExitMonitor: async (configuration) => { if (configuration.monitoredRoomIds !== undefined) { monitorState.monitoredRoomIds = [...configuration.monitoredRoomIds] monitorState.monitoredGroupCount = monitorState.monitoredRoomIds.length } if (configuration.enabled !== undefined) monitorState.enabled = configuration.enabled monitorState.running = monitorState.enabled return { ...monitorState } }, listGroupExitMonitorEvents: ({ roomId, sinceMs, untilMs, limit }) => fixture.events .filter((event) => !roomId || event.roomId === roomId) .filter((event) => sinceMs === undefined || event.detectedAt >= sinceMs) .filter((event) => untilMs === undefined || event.detectedAt <= untilMs) .sort((left, right) => left.detectedAt - right.detectedAt) .slice(-limit!), getGroupMemberStats: async (query): Promise => ({ conversationId: query.userMd5, startTime: query.startTime, endTime: query.endTime, freshness: 'stale', complete: false, memberCount: 2, activeMemberCount: 1, silentMemberCount: 1, activeMembers: [{ senderId: 'wxid_a', displayName: '甲', groupNickname: '群甲', messageCount: 3, lastMessageTime: 1_728_000_000_000 }], silentMembers: [{ senderId: 'wxid_b', displayName: '乙', groupNickname: '' }], unattributedMessages: 4, excludedSystemMessages: 5, firstMessageTime: 1_727_000_000_000, limitations: ['本地索引尚未完全同步,以下结果可能不完整。'] }) }) } async function startServer(): Promise { const handle = await startHttpServer('127.0.0.1', 0, { tokenProvider: () => TOKEN, agentApiService: createApi() }) handles.push(handle) return handle } async function request( handle: HttpServerHandle, pathname: string, method = 'GET', body?: unknown ): Promise { return fetch(`http://${handle.host}:${handle.port}${pathname}`, { method, headers: { Authorization: `Bearer ${TOKEN}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }) } describe('Agent API Phase 2', () => { afterEach(async () => { await Promise.all(handles.splice(0).map((handle) => handle.close())) }) afterAll(async () => { await Promise.all(handles.splice(0).map((handle) => handle.close())) }) it('advertises only the Phase 2 monitor and stats operations', async () => { const handle = await startServer() const response = await request(handle, '/api/v1/capabilities') const body = await response.json() expect(response.status).toBe(200) expect(body.groupExitMonitor.operations).toEqual([ 'read_state', 'configure_scope', 'enable', 'disable', 'list_events' ]) expect(body.groupStats.operations).toEqual(['member_stats']) }) it('validates the complete monitor patch before changing scope', async () => { const handle = await startServer() const invalid = await request(handle, '/api/v1/monitors/group-exits', 'PATCH', { enabled: false, monitoredConversationIds: ['room@chatroom', 'missing@chatroom'] }) expect(invalid.status).toBe(422) expect(fixture.state.enabled).toBe(true) expect(fixture.state.monitoredRoomIds).toEqual(['room@chatroom']) const updated = await request(handle, '/api/v1/monitors/group-exits', 'PATCH', { enabled: false, monitoredConversationIds: [] }) expect(updated.status).toBe(200) expect(await updated.json()).toMatchObject({ enabled: false, monitoredConversationIds: [], eventCount: 2 }) }) it('returns stable event DTOs with time filters and ascending order', async () => { const handle = await startServer() const response = await request( handle, '/api/v1/monitors/group-exits/events?conversationId=room%40chatroom&since=2024-10-01T00%3A00%3A00%2B07%3A00&limit=1' ) const body = await response.json() expect(response.status).toBe(200) expect(body).toMatchObject({ count: 1 }) expect(body.events[0]).toMatchObject({ eventId: 'event-2', conversationId: 'room@chatroom', memberId: 'wxid_b', detectedAt: new Date(1_728_000_000_000).toISOString() }) expect(body.events[0].read).toBeUndefined() }) it('adapts stable group IDs and preserves stats freshness diagnostics', async () => { const handle = await startServer() const response = await request( handle, '/api/v1/groups/room%40chatroom/member-stats?start=2026-10-01T00%3A00%3A00%2B07%3A00&end=2026-10-02T00%3A00%3A00%2B07%3A00' ) const body = await response.json() expect(response.status).toBe(200) expect(body).toMatchObject({ conversation: { id: 'room@chatroom', name: '产品群' }, conversationId: 'room@chatroom', freshness: 'stale', complete: false, unattributedMessages: 4, excludedSystemMessages: 5, firstMessageAt: new Date(1_727_000_000_000).toISOString() }) expect(body.activeMembers[0]).toMatchObject({ memberId: 'wxid_a', lastMessageAt: new Date(1_728_000_000_000).toISOString() }) const direct = await request( handle, '/api/v1/groups/wxid_friend/member-stats?start=2026-10-01T00%3A00%3A00%2B07%3A00&end=2026-10-02T00%3A00%3A00%2B07%3A00' ) expect(direct.status).toBe(422) expect((await direct.json()).error.code).toBe('NOT_GROUP_CONVERSATION') }) })