Files
WechatExplorer/tests/integration/local-agent-api.test.ts
T

646 lines
22 KiB
TypeScript

import fs from 'fs-extra'
import path from 'node:path'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const root = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const fs = require('node:fs') as typeof import('node:fs')
// eslint-disable-next-line @typescript-eslint/no-require-imports
const os = require('node:os') as typeof import('node:os')
// eslint-disable-next-line @typescript-eslint/no-require-imports
const path = require('node:path') as typeof import('node:path')
return fs.mkdtempSync(path.join(os.tmpdir(), 'tracememo-agent-api-'))
})
vi.mock('electron', () => ({
app: { getPath: () => root, getVersion: () => 'test-version' },
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (value: string) => Buffer.from(value, 'utf8'),
decryptString: (value: Buffer) => value.toString('utf8')
}
}))
vi.mock('../../src/main/services/chat-service', () => ({
isReady: () => true,
listContacts: () => [],
listContactsAsync: async () => [],
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' }),
testSend: vi.fn()
}
}))
vi.mock('../../src/main/services/group-exit-monitor-service', () => ({
groupExitMonitorService: {
getState: () => ({ enabled: false, running: false, monitoredRoomIds: [] })
}
}))
import {
BUILTIN_DAILY_REPORT_RULE_ID,
BUILTIN_LEAVE_NOTIFICATION_RULE_ID
} from '../../src/shared/automation'
import { GROUP_EXIT_NOTIFICATION_TEMPLATE } from '../../src/shared/group-exit-monitor'
import type { Contact } from '../../src/shared/types'
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
import { AutomationExecutionLogService } from '../../src/main/services/automation-execution-log-service'
import { AutomationRuleStore } from '../../src/main/services/automation-rule-store'
import { LocalAgentApiService } from '../../src/main/services/local-agent-api-service'
import {
apiServer,
startHttpServer,
type HttpServerHandle,
type HttpServerOptions
} from '../../src/main/http-server'
import type { LocalQueryApiService } from '../../src/main/services/local-query-api-service'
const TOKEN = 'T'.repeat(43)
const handles: HttpServerHandle[] = []
type TestDailyDraft = {
name: string
ruleType: string
scope: string
conditions: {
requireMentionMe: boolean
keyword: string
keywordMatchMode: string
conversationIds: string[]
ignoreSelf: boolean
}
actions: Array<{ type: string; enabled: boolean; text?: string }>
cooldownSeconds: number
replyDelaySeconds: number
}
type TestScheduledDraft = {
name: string
ruleType: string
scheduledReport: {
schedule: { time: string }
report: {
sourceConversationId: string
range: string
messageTypes: string[]
templateId: string
memberNameMode: string
timeoutSeconds: number
}
target: { type: string; contactId: string }
postfixText: string
}
}
type TestLeaveNotificationDraft = {
name: string
ruleType: string
leaveNotification: {
target: { type: string }
template: string
notifyScope: string
notifyRoomIds: string[]
targetNeedsReview?: boolean
}
}
const contacts: Contact[] = [
{
m_nsUsrName: 'room@chatroom',
m_nsNickName: '产品群',
md5: 'group-md5',
type: 'group'
},
{
m_nsUsrName: 'wxid_friend',
wxid: 'wxid_friend',
m_nsNickName: 'Alice',
md5: 'friend-md5',
type: 'user'
}
]
let databaseReady = true
let personalReady = false
let currentStore: AutomationRuleStore
let currentExecutionLog: AutomationExecutionLogService
function createApiService(
store = currentStore,
executionLog = currentExecutionLog
): LocalAgentApiService {
const personal = {
supported: true,
ready: personalReady,
status: personalReady ? 'ready' : 'needs_binding',
capabilities: { text: personalReady, image: false, voice: false },
senderStatus: { executablePath: '/private/wechat/runtime' }
} as unknown as PersonalWechatSendCapability
return new LocalAgentApiService({
automationRuleStore: store,
automationExecutionLogService: executionLog,
listContacts: async () => contacts,
isDatabaseReady: () => databaseReady,
getVersion: () => 'test-version',
getPersonalWechatCapability: async () => personal,
getAgentHubStatus: () => ({ connector: 'offline' }),
getGroupExitMonitorState: () => ({ enabled: false, running: false, monitoredRoomIds: [] })
})
}
async function startServer(
api = createApiService(),
queryApiService?: HttpServerOptions['queryApiService']
): Promise<HttpServerHandle> {
const handle = await startHttpServer('127.0.0.1', 0, {
tokenProvider: () => TOKEN,
agentApiService: api,
queryApiService
})
handles.push(handle)
return handle
}
function url(handle: HttpServerHandle, pathName: string): string {
return `http://${handle.host}:${handle.port}${pathName}`
}
async function request(
handle: HttpServerHandle,
pathName: string,
method = 'GET',
body?: unknown,
headers: Record<string, string> = {}
): Promise<Response> {
return fetch(url(handle, pathName), {
method,
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
...headers
},
...(body !== undefined ? { body: JSON.stringify(body) } : {})
})
}
const validDailyDraft = (): TestDailyDraft => ({
name: '产品群日报自动化',
ruleType: 'daily_report',
scope: 'group',
conditions: {
requireMentionMe: true,
keyword: '日报',
keywordMatchMode: 'contains',
conversationIds: ['room@chatroom'],
ignoreSelf: true
},
actions: [{ type: 'replyText', enabled: true, text: '收到' }],
cooldownSeconds: 60,
replyDelaySeconds: 0
})
const validScheduledDraft = (): TestScheduledDraft => ({
name: '产品群每日日报',
ruleType: 'scheduled_report',
scheduledReport: {
schedule: { time: '20:00' },
report: {
sourceConversationId: 'room@chatroom',
range: 'today',
messageTypes: ['text', 'image'],
templateId: 'v1',
memberNameMode: 'groupNickname',
timeoutSeconds: 300
},
target: { type: 'contact', contactId: 'wxid_friend' },
postfixText: '日报已生成'
}
})
const validLeaveNotificationDraft = (): TestLeaveNotificationDraft => ({
name: '退群通知',
ruleType: 'leave_notification',
leaveNotification: {
target: { type: 'file_transfer' },
template: GROUP_EXIT_NOTIFICATION_TEMPLATE,
notifyScope: 'all',
notifyRoomIds: []
}
})
describe('Local Agent API', () => {
beforeEach(() => {
fs.removeSync(path.join(root, 'automation'))
databaseReady = true
personalReady = false
currentStore = new AutomationRuleStore({ userDataPath: () => root })
currentExecutionLog = new AutomationExecutionLogService({ userDataPath: () => root })
})
afterEach(async () => {
await Promise.all(handles.splice(0).map((handle) => handle.close()))
await apiServer.stop()
})
afterAll(() => fs.removeSync(root))
it('reports application version and separates supported from runtime availability', async () => {
const handle = await startServer()
let response = await request(handle, '/api/v1/capabilities')
expect(response.status).toBe(200)
let body = await response.json()
expect(body).toMatchObject({
version: 'test-version',
apiVersion: 'v1',
database: { ready: true },
query: { supported: true, available: true },
automations: {
supported: true,
available: true,
ruleTypes: ['daily_report', 'scheduled_report', 'leave_notification']
},
wechat: {
personal: { supported: true, available: false, status: 'needs_binding' },
ilink: { supported: true, available: false, status: 'offline' }
}
})
expect(JSON.stringify(body)).not.toMatch(/private\/wechat|executablePath|token|context_token/i)
databaseReady = false
response = await request(handle, '/api/v1/capabilities')
body = await response.json()
expect(body).toMatchObject({
database: { ready: false },
query: { supported: true, available: false, reason: 'database_not_ready' },
groupStats: { supported: true, available: false, reason: 'database_not_ready' }
})
})
it('uses the Agent error envelope for authorization, method, missing route, and size failures', async () => {
const handle = await startServer()
const unauthorized = await fetch(url(handle, '/api/v1/capabilities'))
expect(unauthorized.status).toBe(401)
expect(await unauthorized.json()).toMatchObject({
error: { code: 'UNAUTHORIZED' },
requestId: expect.any(String)
})
const wrongMethod = await request(handle, '/api/v1/capabilities', 'POST')
expect(wrongMethod.status).toBe(405)
expect(wrongMethod.headers.get('allow')).toBe('GET')
expect(await wrongMethod.json()).toMatchObject({
error: { code: 'METHOD_NOT_ALLOWED' },
requestId: expect.any(String)
})
const missingRoute = await request(handle, '/api/v1/automations/rule-1/run')
expect(missingRoute.status).toBe(404)
expect(await missingRoute.json()).toMatchObject({
error: { code: 'NOT_FOUND' },
requestId: expect.any(String)
})
const oversized = await fetch(url(handle, '/api/v1/automations'), {
method: 'POST',
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify('x'.repeat(1024 * 1024 + 1))
})
expect(oversized.status).toBe(413)
expect(oversized.headers.get('x-request-id')).toEqual(expect.any(String))
expect(await oversized.json()).toMatchObject({
error: { code: 'PAYLOAD_TOO_LARGE' },
requestId: expect.any(String)
})
})
it('preserves the existing Query operation request and response contract', async () => {
const queryApi = {
messages: vi.fn(async (payload: unknown) => ({ status: 'completed', payload }))
} as unknown as LocalQueryApiService
const handle = await startServer(createApiService(), queryApi)
const payload = { target: { query: 'Alice' }, timeRange: { kind: 'all' } }
const response = await request(handle, '/api/v1/query/messages', 'POST', payload)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ status: 'completed', payload })
expect(queryApi.messages).toHaveBeenCalledWith(payload)
})
it('validates rule fields and resolves scheduled IDs without persisting', async () => {
const handle = await startServer()
const scheduled = validScheduledDraft()
const response = await request(handle, '/api/v1/automations/validate', 'POST', scheduled)
expect(response.status).toBe(200)
const result = await response.json()
expect(result).toMatchObject({ valid: true, normalized: { enabled: false } })
expect(result.normalized.scheduledReport.report.sourceConversationId).toBe('room@chatroom')
expect(result.normalized.scheduledReport.target.contactId).toBe('wxid_friend')
expect(result.effects).toMatchObject({ sendsWechatMessage: true })
expect(result.nextRunAt).toEqual(expect.any(String))
expect(currentStore.listRules().some((rule) => rule.name === '产品群每日日报')).toBe(false)
const mentionOnly = validDailyDraft()
mentionOnly.conditions.keyword = ''
const mentionOnlyResponse = await request(
handle,
'/api/v1/automations/validate',
'POST',
mentionOnly
)
expect((await mentionOnlyResponse.json()).valid).toBe(true)
const invalidEnum = validDailyDraft()
invalidEnum.conditions.keywordMatchMode = 'regex'
const enumResponse = await request(handle, '/api/v1/automations/validate', 'POST', invalidEnum)
const enumResult = await enumResponse.json()
expect(enumResult.valid).toBe(false)
expect(enumResult.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: 'conditions.keywordMatchMode', code: 'invalid_enum' })
])
)
const invalidTime = validScheduledDraft()
invalidTime.scheduledReport.schedule.time = '29:90'
const timeResponse = await request(handle, '/api/v1/automations/validate', 'POST', invalidTime)
expect((await timeResponse.json()).issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: 'scheduledReport.schedule.time', code: 'invalid_time' })
])
)
const unknownFieldResponse = await request(handle, '/api/v1/automations/validate', 'POST', {
...validDailyDraft(),
callbackUrl: 'http://127.0.0.1'
})
expect((await unknownFieldResponse.json()).issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: 'draft.callbackUrl', code: 'unknown_field' })
])
)
const invalidRuleTypeResponse = await request(handle, '/api/v1/automations/validate', 'POST', {
...validDailyDraft(),
ruleType: 'webhook'
})
expect((await invalidRuleTypeResponse.json()).issues).toEqual(
expect.arrayContaining([expect.objectContaining({ path: 'ruleType', code: 'invalid_enum' })])
)
const enabledInputResponse = await request(handle, '/api/v1/automations/validate', 'POST', {
...validDailyDraft(),
enabled: true
})
expect((await enabledInputResponse.json()).issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: 'enabled', code: 'use_enable_operation' })
])
)
const invalidAllScopeRoomResponse = await request(
handle,
'/api/v1/automations/validate',
'POST',
{
...validLeaveNotificationDraft(),
leaveNotification: {
...validLeaveNotificationDraft().leaveNotification,
notifyRoomIds: ['missing-room@chatroom']
}
}
)
expect((await invalidAllScopeRoomResponse.json()).issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: 'leaveNotification.notifyRoomIds[0]',
code: 'contact_not_found'
})
])
)
})
it('creates disabled rules, updates them, validates on enable, and protects system rules', async () => {
const handle = await startServer()
const createResponse = await request(handle, '/api/v1/automations', 'POST', validDailyDraft(), {
'X-Request-Id': 'agent:create-rule'
})
expect(createResponse.status).toBe(201)
expect(createResponse.headers.get('x-request-id')).toBe('agent:create-rule')
const created = (await createResponse.json()).rule
expect(created).toMatchObject({ enabled: false, ruleType: 'daily_report' })
expect(created.conditions.conversationIds).toEqual(['room@chatroom'])
const getResponse = await request(handle, `/api/v1/automations/${created.id}`)
expect(getResponse.status).toBe(200)
expect((await getResponse.json()).rule.id).toBe(created.id)
const patchResponse = await request(handle, `/api/v1/automations/${created.id}`, 'PATCH', {
name: '更新后的名称'
})
expect((await patchResponse.json()).rule).toMatchObject({
id: created.id,
name: '更新后的名称',
enabled: false
})
const immutableResponse = await request(handle, `/api/v1/automations/${created.id}`, 'PATCH', {
ruleType: 'scheduled_report'
})
expect(immutableResponse.status).toBe(400)
expect(await immutableResponse.json()).toMatchObject({
error: { code: 'RULE_TYPE_IMMUTABLE' },
requestId: expect.any(String)
})
const metadataResponse = await request(handle, `/api/v1/automations/${created.id}`, 'PATCH', {
createdAt: 0
})
expect(metadataResponse.status).toBe(400)
expect((await metadataResponse.json()).error.code).toBe('IMMUTABLE_FIELD')
databaseReady = false
const unavailableEnableResponse = await request(
handle,
`/api/v1/automations/${created.id}/enable`,
'POST'
)
expect(unavailableEnableResponse.status).toBe(409)
expect((await unavailableEnableResponse.json()).error.code).toBe('VALIDATION_FAILED')
expect(currentStore.getRule(created.id)?.enabled).toBe(false)
databaseReady = true
const enableResponse = await request(handle, `/api/v1/automations/${created.id}/enable`, 'POST')
expect((await enableResponse.json()).rule.enabled).toBe(true)
const disableResponse = await request(
handle,
`/api/v1/automations/${created.id}/disable`,
'POST'
)
expect((await disableResponse.json()).rule.enabled).toBe(false)
const filteredResponse = await request(
handle,
'/api/v1/automations?type=daily_report&enabled=false'
)
const filtered = await filteredResponse.json()
expect(filtered.rules.some((rule: { id: string }) => rule.id === created.id)).toBe(true)
const protectedDaily = await request(
handle,
`/api/v1/automations/${BUILTIN_DAILY_REPORT_RULE_ID}`,
'DELETE'
)
expect(protectedDaily.status).toBe(409)
expect((await protectedDaily.json()).error.code).toBe('PROTECTED_RULE')
const protectedLeave = await request(
handle,
`/api/v1/automations/${BUILTIN_LEAVE_NOTIFICATION_RULE_ID}`,
'DELETE'
)
expect(protectedLeave.status).toBe(409)
const nonBuiltinLeave = currentStore.createRule(validLeaveNotificationDraft())
const protectedNonBuiltinLeave = await request(
handle,
`/api/v1/automations/${nonBuiltinLeave.id}`,
'DELETE'
)
expect(protectedNonBuiltinLeave.status).toBe(409)
expect((await protectedNonBuiltinLeave.json()).error.code).toBe('PROTECTED_RULE')
const duplicateLeave = await request(handle, '/api/v1/automations', 'POST', {
name: '第二条退群通知',
ruleType: 'leave_notification',
leaveNotification: {
target: { type: 'file_transfer' },
template: '{groupName} {user}',
notifyScope: 'all',
notifyRoomIds: []
}
})
expect(duplicateLeave.status).toBe(409)
expect((await duplicateLeave.json()).error.code).toBe('SINGLETON_RULE')
const deleteCustom = await request(handle, `/api/v1/automations/${created.id}`, 'DELETE')
expect(deleteCustom.status).toBe(200)
expect((await deleteCustom.json()).deletedId).toBe(created.id)
})
it('lists bounded execution records with structured filters', async () => {
const handle = await startServer()
currentExecutionLog.record({
executionId: 'execution-1',
ruleId: BUILTIN_DAILY_REPORT_RULE_ID,
ruleName: '内置日报',
triggerTime: Date.parse('2025-01-02T03:04:05.000Z'),
trigger: 'message',
sourceDisplayName: '产品群',
status: 'failed',
durationMs: 1_250,
steps: [],
errorSummary: '日报生成失败'
})
const response = await request(
handle,
'/api/v1/automations/executions?ruleId=builtin-mention-me-daily-report&status=failed&since=2025-01-01T00%3A00%3A00Z&until=2025-01-03T00%3A00%3A00Z&limit=10'
)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
count: 1,
executions: [
{
executionId: 'execution-1',
ruleId: BUILTIN_DAILY_REPORT_RULE_ID,
ruleType: 'daily_report',
trigger: 'message',
status: 'failed',
startedAt: '2025-01-02T03:04:05.000Z',
finishedAt: '2025-01-02T03:04:06.250Z',
error: '日报生成失败'
}
]
})
const invalidLimit = await request(handle, '/api/v1/automations/executions?limit=201')
expect(invalidLimit.status).toBe(400)
expect((await invalidLimit.json()).error.code).toBe('INVALID_ARGUMENT')
})
it('only clears target review when a valid replacement target is supplied', async () => {
const handle = await startServer()
currentStore.saveLeaveNotificationRule({
...validLeaveNotificationDraft(),
enabled: false,
leaveNotification: {
...validLeaveNotificationDraft().leaveNotification,
targetNeedsReview: true
}
})
const bypass = await request(
handle,
`/api/v1/automations/${BUILTIN_LEAVE_NOTIFICATION_RULE_ID}`,
'PATCH',
{ leaveNotification: { targetNeedsReview: false } }
)
expect(bypass.status).toBe(400)
expect((await bypass.json()).error.code).toBe('IMMUTABLE_FIELD')
expect(
currentStore.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.targetNeedsReview
).toBe(true)
const invalidTarget = await request(
handle,
`/api/v1/automations/${BUILTIN_LEAVE_NOTIFICATION_RULE_ID}`,
'PATCH',
{ leaveNotification: { target: {} } }
)
expect(invalidTarget.status).toBe(422)
expect(
currentStore.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.targetNeedsReview
).toBe(true)
const updated = await request(
handle,
`/api/v1/automations/${BUILTIN_LEAVE_NOTIFICATION_RULE_ID}`,
'PATCH',
{ leaveNotification: { target: { type: 'self' } } }
)
expect(updated.status).toBe(200)
expect((await updated.json()).rule.leaveNotification).not.toHaveProperty('targetNeedsReview')
expect(
currentStore.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.target
).toEqual({ type: 'self' })
})
it('returns a standard persistence error and rolls back the in-memory rule on write failure', async () => {
const blockedPath = path.join(root, 'not-a-directory')
fs.writeFileSync(blockedPath, 'file')
const failingStore = new AutomationRuleStore({ userDataPath: () => blockedPath })
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const handle = await startServer(createApiService(failingStore, currentExecutionLog))
try {
const response = await request(handle, '/api/v1/automations', 'POST', {
...validDailyDraft(),
name: '不得假成功'
})
expect(response.status).toBe(500)
expect(await response.json()).toMatchObject({
error: { code: 'PERSISTENCE_FAILED' },
requestId: expect.any(String)
})
expect(failingStore.listRules().some((rule) => rule.name === '不得假成功')).toBe(false)
} finally {
warn.mockRestore()
}
})
})