mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 04:20:34 +08:00
feat: 新增 API(capabilities / 退群监控 / 自动化 / 群统计)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user