mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
660 lines
24 KiB
TypeScript
660 lines
24 KiB
TypeScript
import fs from 'fs-extra'
|
||
import path from 'path'
|
||
import { afterAll, 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(), 'wxe-scheduled-report-automation-'))
|
||
})
|
||
|
||
const SELF = 'my_account_9527'
|
||
|
||
/** 联系人表必须在 `vi.hoisted` 里 —— `vi.mock` 的工厂是提前求值的。 */
|
||
const contacts = vi.hoisted(() => [
|
||
{
|
||
md5: 'tech-md5',
|
||
m_nsUsrName: 'tech@chatroom',
|
||
m_nsNickName: '技术交流群',
|
||
type: 'group' as const
|
||
},
|
||
{
|
||
m_nsUsrName: 'wxid_friend',
|
||
m_nsNickName: '好友昵称',
|
||
remark: '好友备注',
|
||
type: 'user' as const
|
||
}
|
||
])
|
||
|
||
vi.mock('electron', () => ({
|
||
app: { getPath: () => root },
|
||
safeStorage: {
|
||
isEncryptionAvailable: () => false,
|
||
encryptString: (value: string) => Buffer.from(value, 'utf8'),
|
||
decryptString: (value: Buffer) => value.toString('utf8')
|
||
},
|
||
BrowserWindow: { getAllWindows: () => [] }
|
||
}))
|
||
vi.mock('../../src/main/services/chat-service', () => ({
|
||
listContacts: () => contacts,
|
||
getSelfAccountInfo: () => ({ wxid: 'my_account_9527' }),
|
||
// 头像补全是可选增强:这里显式给一个空实现,避免 runner 落到未 mock 的导出上刷警告。
|
||
getContactAvatars: async () => ({})
|
||
}))
|
||
|
||
import type {
|
||
PersonalWechatSendCapability,
|
||
PersonalWechatSendRequest
|
||
} from '../../src/shared/personal-wechat'
|
||
import type { Wcdb4Client } from '../../src/main/wcdb4-client'
|
||
import type { GeneratedReportRecord } from '../../src/shared/report-history'
|
||
import type { AgentGroupReportResult } from '../../src/main/services/agent-group-report-service'
|
||
import type {
|
||
AutomationRule,
|
||
AutomationRuleDraft,
|
||
ScheduledReportTargetType
|
||
} from '../../src/shared/automation'
|
||
import { WechatActionGateway } from '../../src/main/services/wechat-action-gateway'
|
||
import { AutomationRuleStore } from '../../src/main/services/automation-rule-store'
|
||
import { AutomationExecutionLogService } from '../../src/main/services/automation-execution-log-service'
|
||
import { AutomationActionRunner } from '../../src/main/services/automation-action-runner'
|
||
import { AutomationService } from '../../src/main/services/automation-service'
|
||
import { ScheduledReportService } from '../../src/main/services/scheduled-report-service'
|
||
|
||
/**
|
||
* 定时日报的**端到端链路**。
|
||
*
|
||
* Scheduler(`ScheduledReportService.tick`)→ `AutomationService.executeScheduledRule`
|
||
* → `AutomationActionRunner.runScheduledReport` → `WechatActionGateway` → 被替换掉的传输层。
|
||
*
|
||
* 除了「微信怎么把图片发出去」,其余**全部是生产实现**。这个文件的存在理由:
|
||
* - `AUTOMATION_PURPOSE_ALLOWLIST` 一旦漏登记 `automation_scheduled_report`,
|
||
* 整条链会在策略层被**静默**拦下(ACTION_NOT_ALLOWED),而每个单元测试都还是绿的;
|
||
* - `scheduledInFlight` / 确定性 executionId 这两道并发闸,只有真的拼起来才看得出来;
|
||
* - 四个发送目标、槽位游标、执行日志的 trigger 区分,都是"拼起来"才成立的语义。
|
||
*/
|
||
|
||
function readyCapability(): PersonalWechatSendCapability {
|
||
return {
|
||
supported: true,
|
||
ready: true,
|
||
status: 'ready',
|
||
capabilities: { text: true, image: true, voice: false },
|
||
message: '个人微信已准备好发送日报',
|
||
senderStatus: {
|
||
state: 'online',
|
||
platform: 'darwin',
|
||
arch: 'arm64',
|
||
sipDisabled: false,
|
||
wechatRunning: true,
|
||
endpoint: '127.0.0.1:0',
|
||
endpointReady: true,
|
||
runtimeReady: true,
|
||
attachReady: true,
|
||
baseAddressReady: true,
|
||
textHookInstalled: true,
|
||
textHookReady: true,
|
||
imageHookInstalled: true,
|
||
imageHookReady: true,
|
||
messageListenerReady: true,
|
||
canSend: true,
|
||
canSendText: true,
|
||
canSendImage: true,
|
||
canSendVoice: false,
|
||
message: '个人微信已准备好发送日报'
|
||
}
|
||
}
|
||
}
|
||
|
||
const RULE_NAME = '技术交流群 · 每日晚报'
|
||
const SCHEDULE_TIME = '18:30'
|
||
/** 规则的创建时间固定在 2026-08-20,必须早于所有参与断言的槽位。 */
|
||
const CREATED_AT = Date.parse('2026-08-20T09:00:00+08:00')
|
||
/** 2026-08-27 18:31,当天 18:30 的槽位已经到点。 */
|
||
const AFTER_SLOT = new Date('2026-08-27T18:31:00+08:00')
|
||
/**
|
||
* `AutomationService` / `AutomationRuleStore` 的 `now` 是**毫秒数**,
|
||
* `ScheduledReportService` 的 `now` 是 **`Date`** —— 两者刻意分开命名,
|
||
* 避免把毫秒数丢给需要 `Date` 的那一个(那会在推通知时抛 `toISOString` 错误)。
|
||
*/
|
||
const AFTER_SLOT_MS = AFTER_SLOT.getTime()
|
||
const SLOT_ISO = new Date('2026-08-27T18:30:00+08:00').toISOString()
|
||
|
||
function scheduledDraft(
|
||
target: ScheduledReportTargetType,
|
||
options: { contactId?: string; enabled?: boolean; postfixText?: string } = {}
|
||
): AutomationRuleDraft {
|
||
return {
|
||
name: RULE_NAME,
|
||
enabled: options.enabled !== false,
|
||
ruleType: 'scheduled_report',
|
||
trigger: 'message',
|
||
scope: 'group',
|
||
conditions: {
|
||
requireMentionMe: false,
|
||
keyword: '',
|
||
keywordMatchMode: 'contains',
|
||
conversationIds: [],
|
||
ignoreSelf: true
|
||
},
|
||
actions: [],
|
||
cooldownSeconds: 0,
|
||
replyDelaySeconds: 2,
|
||
scheduledReport: {
|
||
schedule: { time: SCHEDULE_TIME },
|
||
report: {
|
||
sourceConversationId: 'tech@chatroom',
|
||
range: 'yesterday',
|
||
messageTypes: ['text'],
|
||
templateId: 'v1',
|
||
memberNameMode: 'groupNickname',
|
||
timeoutSeconds: 300
|
||
},
|
||
target:
|
||
target === 'contact'
|
||
? { type: 'contact', contactId: options.contactId ?? 'wxid_friend' }
|
||
: { type: target },
|
||
// 只有显式给了后置词才写进规则:空字符串意味着"只发图片",
|
||
// 这正是存量规则迁移过来的形态,不能在测试工厂里被悄悄补上默认值。
|
||
...(options.postfixText ? { postfixText: options.postfixText } : {})
|
||
}
|
||
}
|
||
}
|
||
|
||
interface Chain {
|
||
scheduler: ScheduledReportService
|
||
service: AutomationService
|
||
store: AutomationRuleStore
|
||
log: AutomationExecutionLogService
|
||
sends: PersonalWechatSendRequest[]
|
||
ruleId: string
|
||
}
|
||
|
||
function buildChain(
|
||
options: {
|
||
capability?: PersonalWechatSendCapability
|
||
target?: ScheduledReportTargetType
|
||
contactId?: string
|
||
enabled?: boolean
|
||
postfixText?: string
|
||
generate?: () => Promise<AgentGroupReportResult>
|
||
} = {}
|
||
): Chain {
|
||
const sends: PersonalWechatSendRequest[] = []
|
||
const savedReports: GeneratedReportRecord[] = []
|
||
const capability = options.capability ?? readyCapability()
|
||
|
||
const gateway = new WechatActionGateway({
|
||
getUserDataPath: () => root,
|
||
getCapability: async () => capability,
|
||
// 传输层是唯一被替换的部分:策略、幂等、审计、节流都是真的。
|
||
send: async (request) => {
|
||
sends.push(request)
|
||
return { success: true, status: readyCapability().senderStatus }
|
||
},
|
||
wait: async () => undefined
|
||
})
|
||
|
||
// `now` 固定:否则 `createdAt` 会变成墙上时钟,把「槽位必须晚于创建时间」那道闸踩掉。
|
||
const store = new AutomationRuleStore({ userDataPath: () => root, now: () => CREATED_AT })
|
||
const log = new AutomationExecutionLogService({ userDataPath: () => root })
|
||
const runner = new AutomationActionRunner({
|
||
executeAction: (request) => gateway.execute(request),
|
||
generateReport:
|
||
options.generate ??
|
||
(async () => ({
|
||
success: true,
|
||
groupName: '技术交流群',
|
||
pngPath: path.join(root, 'scheduled-report.png'),
|
||
messageCount: 42
|
||
})),
|
||
saveGeneratedReport: async (request) => {
|
||
const record: GeneratedReportRecord = {
|
||
id: 'report-1',
|
||
contactId: request.contactId,
|
||
contactName: request.contactName,
|
||
source: 'scheduled',
|
||
dateRange: request.dateRange,
|
||
messageCount: request.messageCount,
|
||
generatedAt: request.generatedAt,
|
||
reportDate: '2026-08-26',
|
||
pngPath: path.join(root, 'scheduled-report.png'),
|
||
htmlStatus: 'ready',
|
||
pngStatus: 'ready'
|
||
}
|
||
savedReports.push(record)
|
||
return { success: true, record }
|
||
},
|
||
resolveReportContact: () => ({
|
||
md5: 'tech-md5',
|
||
m_nsUsrName: 'tech@chatroom',
|
||
m_nsNickName: '技术交流群'
|
||
})
|
||
})
|
||
|
||
const client = {
|
||
getMyUsernameCandidates: () => [SELF],
|
||
getSessions: () => [{ username: 'tech@chatroom', nickname: '技术交流群' }]
|
||
} as unknown as Wcdb4Client
|
||
|
||
const service = new AutomationService(client, {
|
||
ruleStore: store,
|
||
executionLog: log,
|
||
runner,
|
||
getCapability: async () => capability,
|
||
now: () => AFTER_SLOT_MS
|
||
})
|
||
|
||
const ruleId = store.createRule(
|
||
scheduledDraft(options.target ?? 'source_chat', {
|
||
...(options.contactId ? { contactId: options.contactId } : {}),
|
||
...(options.enabled === false ? { enabled: false } : {}),
|
||
...(options.postfixText ? { postfixText: options.postfixText } : {})
|
||
})
|
||
).id
|
||
|
||
const scheduler = new ScheduledReportService({
|
||
storageDir: path.join(root, 'scheduled-reports-legacy'),
|
||
executeRule: (id, runOptions) => service.executeScheduledRule(id, runOptions),
|
||
listRules: () => store.listRules(),
|
||
listExecutions: (input) => log.list(input),
|
||
isDatabaseReady: () => true,
|
||
now: () => AFTER_SLOT
|
||
})
|
||
|
||
return { scheduler, service, store, log, sends, ruleId }
|
||
}
|
||
|
||
/** 每个用例都换一个干净的 userData 目录,避免审计 / 幂等记录互相污染。 */
|
||
const workdirs: string[] = []
|
||
function isolatedRoot(): void {
|
||
const dir = fs.mkdtempSync(path.join(root, 'case-'))
|
||
workdirs.push(dir)
|
||
}
|
||
|
||
describe('scheduled report end-to-end chain', () => {
|
||
beforeEach(() => {
|
||
fs.removeSync(path.join(root, 'automation'))
|
||
fs.removeSync(path.join(root, 'actions'))
|
||
fs.removeSync(path.join(root, 'scheduled-reports-legacy'))
|
||
isolatedRoot()
|
||
})
|
||
|
||
afterAll(() => {
|
||
for (const dir of workdirs) fs.removeSync(dir)
|
||
fs.removeSync(root)
|
||
})
|
||
|
||
it('到点 ⇒ 生成日报 → 落日报历史 → 穿过 Gateway 发出,并记录 schedule 触发', async () => {
|
||
const chain = buildChain()
|
||
|
||
await chain.scheduler.tick(AFTER_SLOT)
|
||
await chain.scheduler.settle()
|
||
|
||
// 真的发出去了:说明 purpose 通过了 `AUTOMATION_PURPOSE_ALLOWLIST`。
|
||
expect(chain.sends).toHaveLength(1)
|
||
expect(chain.sends[0]).toMatchObject({
|
||
type: 'image',
|
||
to: 'tech@chatroom',
|
||
isGroup: true,
|
||
filePath: path.join(root, 'scheduled-report.png')
|
||
})
|
||
|
||
const record = chain.log.list()[0]
|
||
expect(record.trigger).toBe('schedule')
|
||
expect(record.status).toBe('success')
|
||
expect(record.steps.map((step) => `${step.key}:${step.status}`)).toEqual([
|
||
'schedule_triggered:success',
|
||
'report_generating:success',
|
||
'report_generated:success',
|
||
'send_resolved:success',
|
||
'report_sent:success'
|
||
])
|
||
// `send_resolved` 的 detail 是**用户可读的目标名**,不是 roomId。
|
||
expect(record.steps.find((step) => step.key === 'send_resolved')?.detail).toBe('技术交流群')
|
||
})
|
||
|
||
it('配置了后置词 ⇒ 图片 sent 之后补发一条文本,两条各自独立', async () => {
|
||
const chain = buildChain({ postfixText: '今日日报' })
|
||
|
||
await chain.scheduler.tick(AFTER_SLOT)
|
||
await chain.scheduler.settle()
|
||
|
||
/*
|
||
* 两条:先图后文。
|
||
*
|
||
* 后置词走的是**另一个 purpose**(`automation_scheduled_report_postfix`)——
|
||
* 只要它在 `AUTOMATION_PURPOSE_ALLOWLIST` 里漏登记,第二次发送就会被策略层
|
||
* 以 ACTION_NOT_ALLOWED 静默拦下,这里会只剩 1 条。这正是本用例存在的理由。
|
||
*/
|
||
expect(chain.sends.map((send) => send.type)).toEqual(['image', 'text'])
|
||
expect(chain.sends[0]).toMatchObject({
|
||
type: 'image',
|
||
to: 'tech@chatroom',
|
||
isGroup: true,
|
||
filePath: path.join(root, 'scheduled-report.png')
|
||
})
|
||
expect(chain.sends[1]).toMatchObject({
|
||
type: 'text',
|
||
to: 'tech@chatroom',
|
||
isGroup: true,
|
||
text: '今日日报'
|
||
})
|
||
// 两条都成功,整次执行才是 success(后置词失败会拖垮整次判定,见下一条)。
|
||
expect(chain.log.list()[0].status).toBe('success')
|
||
})
|
||
|
||
it('未配置后置词 ⇒ 只发图片,绝不"顺手补一句"', async () => {
|
||
const chain = buildChain()
|
||
|
||
await chain.scheduler.tick(AFTER_SLOT)
|
||
await chain.scheduler.settle()
|
||
|
||
expect(chain.sends.map((send) => send.type)).toEqual(['image'])
|
||
expect(chain.log.list()[0].status).toBe('success')
|
||
})
|
||
|
||
it('图片能力缺失 ⇒ 后置词绝不发送(图片没发出去,就没有"之后")', async () => {
|
||
const capability = readyCapability()
|
||
const chain = buildChain({
|
||
postfixText: '今日日报',
|
||
capability: { ...capability, capabilities: { ...capability.capabilities, image: false } }
|
||
})
|
||
|
||
await chain.scheduler.tick(AFTER_SLOT)
|
||
await chain.scheduler.settle()
|
||
|
||
// 图片都没发出去,后置词更不可能发 —— 传输层零调用。
|
||
expect(chain.sends).toHaveLength(0)
|
||
expect(chain.log.list()[0].status).toBe('failed')
|
||
})
|
||
|
||
it('图片已发出但后置词失败 ⇒ 如实判失败,不吞掉也不回滚', async () => {
|
||
const capability = readyCapability()
|
||
// 文字能力缺失:图片照发,但后置词那一步会被 SEND_NOT_READY 拦下。
|
||
const chain = buildChain({
|
||
postfixText: '今日日报',
|
||
capability: { ...capability, capabilities: { ...capability.capabilities, text: false } }
|
||
})
|
||
|
||
await chain.scheduler.tick(AFTER_SLOT)
|
||
await chain.scheduler.settle()
|
||
|
||
// 图片确实发出去了 —— 微信发送不支持回滚,所以**必须留痕**,不能假装没发生。
|
||
expect(chain.sends.map((send) => send.type)).toEqual(['image'])
|
||
const record = chain.log.list()[0]
|
||
expect(record.status).toBe('failed')
|
||
// 失败点必须精确落在"后置词",而不是伪装成"日报没生成/没发送"。
|
||
expect(record.steps.find((step) => step.key === 'report_sent')?.error).toContain(
|
||
'后置词发送失败'
|
||
)
|
||
})
|
||
|
||
it('同一槽位重放 ⇒ 图片与后置词各自幂等,都不会重复发', async () => {
|
||
const chain = buildChain({ postfixText: '今日日报' })
|
||
|
||
await chain.service.executeScheduledRule(chain.ruleId, {
|
||
trigger: 'schedule',
|
||
scheduledSlot: SLOT_ISO
|
||
})
|
||
await chain.service.executeScheduledRule(chain.ruleId, {
|
||
trigger: 'schedule',
|
||
scheduledSlot: SLOT_ISO
|
||
})
|
||
|
||
// 两次重放合起来仍然只有一条图 + 一条文:两条各有独立幂等位,
|
||
// 少给后置词一个 key 会让它在第二次重放时"被图片的幂等位短路掉"。
|
||
expect(chain.sends.map((send) => send.type)).toEqual(['image', 'text'])
|
||
})
|
||
|
||
it('四个发送目标各自解析正确(同一个 Gateway,不绑作用域锁)', async () => {
|
||
const cases: Array<{ target: ScheduledReportTargetType; to: string; isGroup: boolean }> = [
|
||
{ target: 'source_chat', to: 'tech@chatroom', isGroup: true },
|
||
{ target: 'file_transfer', to: 'filehelper', isGroup: false },
|
||
{ target: 'self', to: SELF, isGroup: false },
|
||
{ target: 'contact', to: 'wxid_friend', isGroup: false }
|
||
]
|
||
|
||
for (const item of cases) {
|
||
const chain = buildChain({ target: item.target })
|
||
|
||
await chain.scheduler.tick(AFTER_SLOT)
|
||
await chain.scheduler.settle()
|
||
|
||
expect(
|
||
chain.sends.map((send) => ({ type: send.type, to: send.to, isGroup: send.isGroup }))
|
||
).toEqual([{ type: 'image', to: item.to, isGroup: item.isGroup }])
|
||
expect(chain.log.list()[0].status).toBe('success')
|
||
}
|
||
})
|
||
|
||
it('目标失效 ⇒ 生成成功但**绝不** fallback 到别处,且如实判失败', async () => {
|
||
const chain = buildChain({ target: 'contact', contactId: 'wxid_gone' })
|
||
|
||
await chain.scheduler.tick(AFTER_SLOT)
|
||
await chain.scheduler.settle()
|
||
|
||
expect(chain.sends).toHaveLength(0)
|
||
const record = chain.log.list()[0]
|
||
expect(record.status).toBe('failed')
|
||
// 失败点在"发到哪",不能伪装成"生成失败"。
|
||
expect(record.steps.find((step) => step.key === 'report_generating')?.status).toBe('success')
|
||
expect(record.steps.find((step) => step.key === 'send_resolved')?.status).toBe('failed')
|
||
expect(record.steps.find((step) => step.key === 'report_sent')?.status).toBe('skipped')
|
||
})
|
||
|
||
it('停用的规则完全不执行(连日报都不生成)', async () => {
|
||
const chain = buildChain({ enabled: false })
|
||
|
||
await chain.scheduler.tick(AFTER_SLOT)
|
||
await chain.scheduler.settle()
|
||
|
||
expect(chain.sends).toHaveLength(0)
|
||
expect(chain.log.list()).toHaveLength(0)
|
||
})
|
||
|
||
it('图片能力缺失 ⇒ 日报照生成,但不发送,并说明原因', async () => {
|
||
const capability = readyCapability()
|
||
const chain = buildChain({
|
||
capability: { ...capability, capabilities: { ...capability.capabilities, image: false } }
|
||
})
|
||
|
||
await chain.scheduler.tick(AFTER_SLOT)
|
||
await chain.scheduler.settle()
|
||
|
||
expect(chain.sends).toHaveLength(0)
|
||
const record = chain.log.list()[0]
|
||
expect(record.status).toBe('failed')
|
||
expect(record.steps.find((step) => step.key === 'report_generating')?.status).toBe('success')
|
||
expect(record.steps.find((step) => step.key === 'report_sent')?.error).toContain('无法发送图片')
|
||
})
|
||
|
||
it('手动执行与定时执行**共用同一条链路**,只有 trigger 不同', async () => {
|
||
const chain = buildChain()
|
||
|
||
const outcome = await chain.service.executeScheduledRule(chain.ruleId, { trigger: 'manual' })
|
||
|
||
expect(outcome.executed).toBe(true)
|
||
expect(chain.sends).toHaveLength(1)
|
||
expect(chain.log.list()[0].trigger).toBe('manual')
|
||
})
|
||
|
||
it('同一条规则并发触发 ⇒ 第二个被 inFlight 挡掉,只发一次', async () => {
|
||
const chain = buildChain()
|
||
|
||
const [first, second] = await Promise.all([
|
||
chain.service.executeScheduledRule(chain.ruleId, {
|
||
trigger: 'schedule',
|
||
scheduledSlot: SLOT_ISO
|
||
}),
|
||
chain.service.executeScheduledRule(chain.ruleId, {
|
||
trigger: 'schedule',
|
||
scheduledSlot: SLOT_ISO
|
||
})
|
||
])
|
||
|
||
const executed = [first, second].filter((item) => item.executed)
|
||
const blocked = [first, second].filter((item) => !item.executed)
|
||
expect(executed).toHaveLength(1)
|
||
expect(blocked).toHaveLength(1)
|
||
expect(blocked[0].reason).toBe('in_flight')
|
||
expect(chain.sends).toHaveLength(1)
|
||
})
|
||
|
||
it('同一槽位被重复触发 ⇒ 确定性 executionId 让 Gateway 去重,不会重复发', async () => {
|
||
const chain = buildChain()
|
||
|
||
const first = await chain.service.executeScheduledRule(chain.ruleId, {
|
||
trigger: 'schedule',
|
||
scheduledSlot: SLOT_ISO
|
||
})
|
||
const second = await chain.service.executeScheduledRule(chain.ruleId, {
|
||
trigger: 'schedule',
|
||
scheduledSlot: SLOT_ISO
|
||
})
|
||
|
||
// executionId 由 ruleId + slot 确定性派生,所以两次完全一致。
|
||
expect(first.executionId).toBeTruthy()
|
||
expect(second.executionId).toBe(first.executionId)
|
||
// 传输层只被调用一次 —— 幂等拦在 Gateway,不是靠上层自觉。
|
||
expect(chain.sends).toHaveLength(1)
|
||
})
|
||
|
||
it('槽位游标:定时触发消费槽位,手动执行不消费', async () => {
|
||
const chain = buildChain()
|
||
|
||
await chain.service.executeScheduledRule(chain.ruleId, { trigger: 'manual' })
|
||
const afterManual = chain.store.getRule(chain.ruleId)?.scheduledReport
|
||
expect(afterManual?.lastRunAt).toBeTruthy()
|
||
// 手动执行若写了槽位,就会把今天安排好的那次定时执行一起吞掉。
|
||
expect(afterManual?.lastScheduledSlot).toBeUndefined()
|
||
|
||
await chain.service.executeScheduledRule(chain.ruleId, {
|
||
trigger: 'schedule',
|
||
scheduledSlot: SLOT_ISO
|
||
})
|
||
expect(chain.store.getRule(chain.ruleId)?.scheduledReport?.lastScheduledSlot).toBe(SLOT_ISO)
|
||
})
|
||
|
||
it('调度器只读规则:槽位已消费后重启不再补跑', async () => {
|
||
const chain = buildChain()
|
||
|
||
await chain.scheduler.tick(AFTER_SLOT)
|
||
await chain.scheduler.settle()
|
||
expect(chain.sends).toHaveLength(1)
|
||
|
||
// 新建一个调度器实例(= 应用重启):规则与游标都从盘上读。
|
||
const restarted = new ScheduledReportService({
|
||
storageDir: path.join(root, 'scheduled-reports-legacy'),
|
||
executeRule: (id, runOptions) => chain.service.executeScheduledRule(id, runOptions),
|
||
listRules: () => chain.store.listRules(),
|
||
listExecutions: (input) => chain.log.list(input),
|
||
isDatabaseReady: () => true,
|
||
now: () => AFTER_SLOT
|
||
})
|
||
await restarted.tick(AFTER_SLOT)
|
||
await restarted.settle()
|
||
|
||
expect(chain.sends).toHaveLength(1)
|
||
})
|
||
|
||
it('执行日志与步骤 detail 不泄露 roomId / wxid / 文件路径', async () => {
|
||
const chain = buildChain({ target: 'contact' })
|
||
|
||
await chain.scheduler.tick(AFTER_SLOT)
|
||
await chain.scheduler.settle()
|
||
|
||
const record = chain.log.list()[0]
|
||
const serialized = JSON.stringify(record)
|
||
expect(serialized).not.toContain('@chatroom')
|
||
expect(serialized).not.toContain('wxid_friend')
|
||
expect(serialized).not.toContain(root)
|
||
// 但必须留下可读的目标名(备注优先)。
|
||
expect(record.steps.find((step) => step.key === 'send_resolved')?.detail).toBe('好友备注')
|
||
})
|
||
|
||
it('定时失败 ⇒ 推一条微信异常通知,且"今天执行"统计与那条日志是同一份', async () => {
|
||
const store = new AutomationRuleStore({ userDataPath: () => root, now: () => CREATED_AT })
|
||
const log = new AutomationExecutionLogService({ userDataPath: () => root })
|
||
const runner = new AutomationActionRunner({
|
||
// 生成直接失败 ⇒ 定时执行判 failed。
|
||
generateReport: async () => ({
|
||
success: false,
|
||
error: '模型调用超时',
|
||
errorCode: 'AI_TIMEOUT'
|
||
})
|
||
})
|
||
const service = new AutomationService(
|
||
{
|
||
getMyUsernameCandidates: () => [SELF],
|
||
getSessions: () => [{ username: 'tech@chatroom', nickname: '技术交流群' }]
|
||
} as unknown as Wcdb4Client,
|
||
{
|
||
ruleStore: store,
|
||
executionLog: log,
|
||
runner,
|
||
getCapability: async () => readyCapability(),
|
||
now: () => AFTER_SLOT_MS
|
||
}
|
||
)
|
||
const ruleId = store.createRule(scheduledDraft('source_chat')).id
|
||
|
||
const notifications: string[] = []
|
||
const scheduler = new ScheduledReportService({
|
||
storageDir: path.join(root, 'scheduled-reports-legacy'),
|
||
executeRule: (id, runOptions) => service.executeScheduledRule(id, runOptions),
|
||
listRules: () => store.listRules(),
|
||
listExecutions: (input) => log.list(input),
|
||
sendNotification: async (request) => {
|
||
notifications.push(request.text)
|
||
return { success: true, status: 'sent' }
|
||
},
|
||
getNotificationRecipient: () => SELF,
|
||
getAgentHubStatus: () => ({ hub: 'online', connector: 'online', updatedAt: 0 }),
|
||
isDatabaseReady: () => true,
|
||
now: () => AFTER_SLOT
|
||
})
|
||
|
||
// 开启异常通知会先试发一条,把它排掉,后面只看执行失败那一条。
|
||
await scheduler.setNotificationEnabled(true)
|
||
expect(notifications).toHaveLength(1)
|
||
notifications.length = 0
|
||
|
||
await scheduler.tick(AFTER_SLOT)
|
||
await scheduler.settle()
|
||
|
||
expect(log.list()[0].status).toBe('failed')
|
||
expect(log.list()[0].ruleId).toBe(ruleId)
|
||
expect(notifications).toHaveLength(1)
|
||
expect(notifications[0]).toContain(RULE_NAME)
|
||
expect(notifications[0]).toContain('模型调用超时')
|
||
})
|
||
|
||
it('规则不存在 / 目标待重选 ⇒ 明确拒绝,不执行也不发送', async () => {
|
||
const chain = buildChain()
|
||
|
||
const missing = await chain.service.executeScheduledRule('rule_does_not_exist', {
|
||
trigger: 'manual'
|
||
})
|
||
expect(missing.executed).toBe(false)
|
||
expect(missing.reason).toBe('rule_not_found')
|
||
|
||
// 把规则改成「目标待重选」:迁移遗留态,执行必然失败 ⇒ 直接拒绝。
|
||
const current = chain.store.getRule(chain.ruleId) as AutomationRule
|
||
chain.store.updateRule(chain.ruleId, {
|
||
...(current as unknown as AutomationRuleDraft),
|
||
scheduledReport: { ...current.scheduledReport!, targetNeedsReview: true }
|
||
})
|
||
const needsReview = await chain.service.executeScheduledRule(chain.ruleId, {
|
||
trigger: 'manual'
|
||
})
|
||
expect(needsReview.executed).toBe(false)
|
||
expect(needsReview.reason).toBe('target_needs_review')
|
||
expect(chain.sends).toHaveLength(0)
|
||
})
|
||
})
|