feat: 新增 API(capabilities / 退群监控 / 自动化 / 群统计)

This commit is contained in:
电摇小子
2026-10-04 02:31:34 +07:00
parent e83e85fe64
commit 713232bd62
20 changed files with 3637 additions and 135 deletions
+287
View File
@@ -0,0 +1,287 @@
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<GroupMemberStatsResult> => ({
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<HttpServerHandle> {
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<Response> {
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')
})
})
+645
View File
@@ -0,0 +1,645 @@
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()
}
})
})
+83 -5
View File
@@ -29,7 +29,7 @@ const fixture = vi.hoisted(() => ({
}))
vi.mock('electron', () => ({
app: { getPath: () => fixture.root },
app: { getPath: () => fixture.root, getVersion: () => 'test-version' },
safeStorage: {
isEncryptionAvailable: () => fixture.storageAvailable,
encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'),
@@ -111,7 +111,7 @@ describe('Local API authentication', () => {
const health = await fetch(`${baseUrl(handle)}/api/v1/health`)
expect(health.status).toBe(200)
const healthBody = await health.json()
expect(healthBody).toMatchObject({ ok: true, service: 'TraceMemo Reader' })
expect(healthBody).toMatchObject({ ok: true, service: 'TraceMemo Reader', version: 'test-version' })
expect(JSON.stringify(healthBody)).not.toMatch(
/token|authorization|wxid|databasePath|provider/i
)
@@ -128,6 +128,64 @@ describe('Local API authentication', () => {
await expect(response.json()).resolves.toMatchObject({ count: 1 })
})
it.each([
['POST', '/api/v1/health', 'GET'],
['PATCH', '/api/v1/current_time', 'GET'],
['POST', '/api/v1/contact', 'GET'],
['POST', '/api/v1/chatroom', 'GET'],
['POST', '/api/v1/recent_chat', 'GET'],
['POST', '/api/v1/chatlog', 'GET'],
['POST', '/api/v1/group_snapshot', 'GET'],
['POST', '/api/v1/resolve', 'GET'],
['POST', '/api/v1/agent/status', 'GET'],
['GET', '/api/v1/report', 'POST'],
['PATCH', '/api/v1/query/capabilities', 'GET'],
['GET', '/api/v1/query/messages', 'POST'],
['POST', '/api/v1/media/image-1', 'GET, HEAD']
])('rejects unsupported methods with Allow: %s', async (method, pathname, allow) => {
const handle = await startFixtureServer()
const requestId = 'phase1:method-guard'
const response = await fetch(`${baseUrl(handle)}${pathname}`, {
method,
headers: { 'X-Request-Id': requestId }
})
expect(response.status).toBe(405)
expect(response.headers.get('allow')).toBe(allow)
expect(response.headers.get('x-request-id')).toBe(requestId)
await expect(response.json()).resolves.toMatchObject({ requestId })
})
it('rejects oversized JSON request bodies with 413 and a request ID', async () => {
const handle = await startFixtureServer()
const requestId = 'phase1:body-limit'
const response = await fetch(`${baseUrl(handle)}/api/v1/report`, {
method: 'POST',
headers: {
Authorization: `Bearer ${VALID_TOKEN}`,
'Content-Type': 'application/json',
'X-Request-Id': requestId
},
body: 'x'.repeat(1024 * 1024 + 1)
})
expect(response.status).toBe(413)
expect(response.headers.get('x-request-id')).toBe(requestId)
await expect(response.json()).resolves.toMatchObject({ status: 413, requestId })
})
it('replaces malformed or oversized client request IDs with a server UUID', async () => {
const handle = await startFixtureServer()
const malformedId = 'x'.repeat(129)
const response = await fetch(`${baseUrl(handle)}/api/v1/health`, {
headers: { 'X-Request-Id': malformedId }
})
expect(response.status).toBe(200)
expect(response.headers.get('x-request-id')).toMatch(/^[0-9a-f-]{36}$/i)
expect(response.headers.get('x-request-id')).not.toBe(malformedId)
})
it('exposes the query capability catalog only with the existing bearer token', async () => {
const handle = await startFixtureServer()
const path = `${baseUrl(handle)}/api/v1/query/capabilities`
@@ -181,6 +239,23 @@ describe('Local API authentication', () => {
expect(JSON.stringify(response.headers)).not.toMatch(/path|token|database/i)
})
it('preserves authenticated HEAD requests for media without returning a body', async () => {
const provider = vi.fn(async () => ({
buffer: Buffer.from([0xff, 0xd8, 0xff, 0xd9]),
mimeType: 'image/jpeg'
}))
const handle = await startFixtureServer(() => VALID_TOKEN, provider)
const response = await fetch(`${baseUrl(handle)}/api/v1/media/message-1`, {
method: 'HEAD',
headers: { Authorization: `Bearer ${VALID_TOKEN}` }
})
expect(response.status).toBe(200)
expect(response.headers.get('content-length')).toBe('4')
expect((await response.arrayBuffer()).byteLength).toBe(0)
expect(provider).toHaveBeenCalledOnce()
})
it('adds media metadata to chatlog while redacting image keys', async () => {
const handle = await startFixtureServer()
const response = await fetch(`${baseUrl(handle)}/api/v1/chatlog?talker=测试联系人`, {
@@ -265,9 +340,10 @@ describe('Local API authentication', () => {
headers: { Authorization: authorization }
})
expect(response.status).toBe(401)
await expect(response.json()).resolves.toEqual({
await expect(response.json()).resolves.toMatchObject({
error: 'unauthorized',
message: 'Valid API token required'
message: 'Valid API token required',
requestId: expect.any(String)
})
}
)
@@ -344,7 +420,9 @@ describe('Local API authentication', () => {
expect(response.headers.get('access-control-allow-origin')).toBe(origin)
expect(response.headers.get('access-control-allow-methods')).toContain('PATCH')
expect(response.headers.get('access-control-allow-methods')).toContain('DELETE')
expect(response.headers.get('access-control-allow-headers')).toBe('Content-Type, Authorization')
expect(response.headers.get('access-control-allow-headers')).toBe(
'Content-Type, Authorization, X-Request-Id'
)
})
it.each([
@@ -38,6 +38,7 @@ vi.mock('electron', () => ({
vi.mock('../../src/main/services/chat-service', () => ({
isReady: () => true,
listContacts: () => fixture.contacts,
listContactsAsync: async () => fixture.contacts,
listMessages: () => [],
getGroupSnapshot: () => null,
listRecentChat: () => [],