Files
WechatExplorer/tests/unit/automation-rule-store.test.ts
电摇小子 4d5c6459fe feat: 定时日报并入自动化,统一规则管理与发送链路
发送目标与退群通知统一为 来源群 / 自己 / 文件传输助手 / 指定好友
日报页补「定时日报已并入自动化」指引条
2026-09-28 17:11:50 +07:00

775 lines
31 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-automation-rule-store-'))
})
vi.mock('electron', () => ({ app: { getPath: () => root } }))
import { BUILTIN_DAILY_REPORT_RULE_ID, BUILTIN_LEAVE_NOTIFICATION_RULE_ID } from '../../src/shared/automation'
import {
AutomationRuleStore,
type AutomationRuleStoreDependencies
} from '../../src/main/services/automation-rule-store'
import { AutomationExecutionLogService } from '../../src/main/services/automation-execution-log-service'
import type { AutomationExecution } from '../../src/shared/automation'
const rulesFile = (): string => path.join(root, 'automation', 'rules.json')
const executionsFile = (): string => path.join(root, 'automation', 'executions.json')
/** 只重定向目录,其余用真实实现 —— 目的是验证真实的读写与归一化。 */
const deps = (dir: string, now?: () => number): AutomationRuleStoreDependencies => ({
userDataPath: () => dir,
...(now ? { now } : {})
})
/** 写旧退群监控状态文件(退群通知迁移的唯一输入)。 */
function writeLegacyMonitorState(input: {
monitoredRoomIds?: string[]
notificationRoomIds?: string[]
notificationTemplate?: string
}): void {
fs.writeJsonSync(path.join(root, 'group-exit-monitor.json'), input)
}
describe('AutomationRuleStore', () => {
beforeEach(() => {
fs.removeSync(path.join(root, 'automation'))
})
afterAll(() => fs.removeSync(root))
it('首次加载会播种内置规则「@我生成日报」与「退群通知」并落盘', () => {
const store = new AutomationRuleStore(deps(root))
const rules = store.listRules()
expect(rules).toHaveLength(2)
const builtin = rules.find((rule) => rule.id === BUILTIN_DAILY_REPORT_RULE_ID)
expect(builtin).toBeDefined()
expect(builtin?.name).toBe('@我生成日报')
expect(builtin?.ruleType).toBe('daily_report')
expect(builtin?.enabled).toBe(true)
expect(builtin?.scope).toBe('group')
expect(builtin?.conditions.requireMentionMe).toBe(true)
expect(builtin?.conditions.keyword).toBe('日报')
expect(builtin?.conditions.ignoreSelf).toBe(true)
expect(builtin?.cooldownSeconds).toBe(60)
expect(builtin?.actions.map((action) => action.type)).toEqual([
'replyText',
'generateReport',
'sendReportImage'
])
expect(fs.pathExistsSync(rulesFile())).toBe(true)
})
it('全新安装的退群通知规则:默认目标是「当前群聊」', () => {
const store = new AutomationRuleStore(deps(root))
const leave = store.listRules().find((rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
expect(leave).toBeDefined()
expect(leave?.ruleType).toBe('leave_notification')
expect(leave?.name).toBe('退群通知')
// 「当前群聊」= 旧退群监控通知的原始行为,所以既是默认值也是第一项。
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
expect(leave?.leaveNotification?.targetNeedsReview).toBeUndefined()
// 退群通知不套用消息型规则的冷却,避免把两次退群合并成一次。
expect(leave?.cooldownSeconds).toBe(0)
expect(leave?.actions).toEqual([])
})
it('删掉内置规则后不会在下次启动时被重新塞回来', () => {
const store = new AutomationRuleStore(deps(root))
expect(store.deleteRule(BUILTIN_DAILY_REPORT_RULE_ID)).toBe(true)
expect(store.deleteRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)).toBe(true)
expect(store.listRules()).toHaveLength(0)
// 关键:新实例(等于重启)不应该重新播种。
const reopened = new AutomationRuleStore(deps(root))
expect(reopened.listRules()).toHaveLength(0)
})
it('createRule 归一化脏输入并补上 id 与时间戳', () => {
const now = 1_700_000_000_000
const store = new AutomationRuleStore(deps(root, () => now))
const created = store.createRule({
name: ' 关键词回复 ',
scope: 'bogus',
conditions: { keyword: '周报', keywordMatchMode: 'regex', conversationIds: ['a', ''] },
actions: [{ type: 'replyText', enabled: true, text: '好的' }],
cooldownSeconds: 30
})
expect(created.id).not.toBe(BUILTIN_DAILY_REPORT_RULE_ID)
expect(created.name).toBe('关键词回复')
expect(created.ruleType).toBe('daily_report')
expect(created.scope).toBe('group')
expect(created.conditions.keywordMatchMode).toBe('contains')
expect(created.conditions.conversationIds).toEqual(['a'])
expect(created.createdAt).toBe(now)
expect(created.updatedAt).toBe(now)
expect(store.listRules()).toHaveLength(3)
})
it('updateRule 保留 id 与 createdAt,刷新 updatedAt', () => {
let now = 1_000
const store = new AutomationRuleStore(deps(root, () => now))
const created = store.createRule({ name: 'A', conditions: {}, actions: [] })
now = 2_000
const updated = store.updateRule(created.id, {
name: 'B',
conditions: { keyword: 'x' },
actions: [{ type: 'generateReport', enabled: true }]
})
expect(updated?.id).toBe(created.id)
expect(updated?.createdAt).toBe(1_000)
expect(updated?.updatedAt).toBe(2_000)
expect(updated?.name).toBe('B')
expect(updated?.conditions.keyword).toBe('x')
})
it('updateRule 名称为空时沿用原名,而不是变成「未命名自动化」', () => {
const store = new AutomationRuleStore(deps(root))
const builtin = store.listRules()[0]
const updated = store.updateRule(builtin.id, { ...builtin, name: ' ' })
expect(updated?.name).toBe('@我生成日报')
})
it('setRuleEnabled 只改开关', () => {
const store = new AutomationRuleStore(deps(root))
const builtin = store.listRules()[0]
const disabled = store.setRuleEnabled(builtin.id, false)
expect(disabled?.enabled).toBe(false)
expect(disabled?.conditions.keyword).toBe('日报')
expect(store.listRules()[0].enabled).toBe(false)
})
it('对不存在的 id 做更新/启停返回 undefined,删除返回 false', () => {
const store = new AutomationRuleStore(deps(root))
expect(store.updateRule('nope', { name: 'x' })).toBeUndefined()
expect(store.setRuleEnabled('nope', true)).toBeUndefined()
expect(store.deleteRule('nope')).toBe(false)
})
it('规则文件损坏时降级为空并重新播种,而不是让功能整体不可用', () => {
fs.ensureDirSync(path.dirname(rulesFile()))
fs.writeFileSync(rulesFile(), '{ 这不是 JSON', 'utf8')
const store = new AutomationRuleStore(deps(root))
expect(store.listRules()).toHaveLength(2)
expect(store.listRules().map((rule) => rule.id)).toEqual(
expect.arrayContaining([BUILTIN_DAILY_REPORT_RULE_ID, BUILTIN_LEAVE_NOTIFICATION_RULE_ID])
)
})
it('已播种但规则列表为空的存档不会被再次播种「@我生成日报」', () => {
fs.ensureDirSync(path.dirname(rulesFile()))
fs.writeJsonSync(rulesFile(), {
version: 2,
builtinSeeded: true,
leaveNotificationMigrated: true,
rules: []
})
const store = new AutomationRuleStore(deps(root))
expect(store.listRules()).toHaveLength(0)
})
it('写入的是真实文件,重新打开能读到', () => {
const store = new AutomationRuleStore(deps(root))
store.createRule({ name: '持久化检查', conditions: {}, actions: [] })
const reopened = new AutomationRuleStore(deps(root))
expect(reopened.listRules().map((rule) => rule.name)).toContain('持久化检查')
})
it('saveLeaveNotificationRule 是 singleton upsert:保存多次也只有一条', () => {
const store = new AutomationRuleStore(deps(root))
for (let index = 0; index < 3; index += 1) {
store.saveLeaveNotificationRule({
name: '退群通知',
enabled: true,
ruleType: 'leave_notification',
scope: 'group',
conditions: {},
actions: [],
cooldownSeconds: 0,
replyDelaySeconds: 2,
leaveNotification: {
target: { type: 'contact', contactId: `wxid_friend_${index}` },
template: '[退群监测]\n用户: {user}'
}
})
}
const leaveRules = store
.listRules()
.filter((rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
expect(leaveRules).toHaveLength(1)
expect(leaveRules[0].leaveNotification?.target).toEqual({
type: 'contact',
contactId: 'wxid_friend_2'
})
})
/*
* 存量安装:早期版本把"部分勾选"判成无法无损映射,于是落盘了
* `targetNeedsReview: true`。这个标记会拦发送(`handleGroupExit` 第②道闸门),
* 所以**用户重存一次必须能清掉它** —— 否则那条规则永远发不出去。
*
* 注意种的是"已经落盘的存量状态",不是靠迁移产出:迁移现在不再产生这个标记了。
*/
it('保存退群通知会清掉迁移遗留的「目标待重选」标记', () => {
const store = new AutomationRuleStore(deps(root))
store.saveLeaveNotificationRule({
name: '退群通知',
enabled: false,
ruleType: 'leave_notification',
scope: 'group',
conditions: {},
actions: [],
cooldownSeconds: 0,
replyDelaySeconds: 2,
leaveNotification: {
target: { type: 'source_chat' },
template: '[退群监测]',
notifyScope: 'all',
notifyRoomIds: [],
targetNeedsReview: true
}
})
expect(
store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.targetNeedsReview
).toBe(true)
// 用户来重存一次:草稿里不带这个标记 ⇒ 归一化后它就该消失。
store.saveLeaveNotificationRule({
...store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID),
enabled: true,
leaveNotification: { target: { type: 'source_chat' }, template: '[退群监测]' }
})
const saved = store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
expect(saved?.leaveNotification?.targetNeedsReview).toBeUndefined()
expect(saved?.leaveNotification?.target).toEqual({ type: 'source_chat' })
})
/*
* 加上群名变量之前的默认模板(有「人数」行、没有「群聊」行)。
* 这正是老用户手上那份 —— 模板是他们的已保存数据。
*/
const PREVIOUS_DEFAULT_TEMPLATE = [
'[退群监测]',
'',
'用户: {user}',
'',
'群备注: {groupRemark}',
'',
'微信号: {wxid}',
'',
'人数: {previousCount} -> {currentCount}',
'',
'退群时间: {time}'
].join('\n')
/** 写一份"老用户"的规则文件(退群通知模板里没有群名变量)。 */
function writeRulesWithTemplate(
template: string,
flags: { notificationTemplateUpgraded?: boolean } = {}
): void {
fs.ensureDirSync(path.dirname(rulesFile()))
fs.writeJsonSync(rulesFile(), {
version: 2,
builtinSeeded: true,
leaveNotificationMigrated: true,
notificationTemplateUpgraded: flags.notificationTemplateUpgraded === true,
rules: [
{
id: BUILTIN_DAILY_REPORT_RULE_ID,
name: '@我生成日报',
enabled: true,
ruleType: 'daily_report',
trigger: 'message',
scope: 'group',
conditions: { requireMentionMe: true, keyword: '日报', keywordMatchMode: 'contains' },
actions: [{ type: 'generateReplyText', enabled: true }],
cooldownSeconds: 60,
replyDelaySeconds: 2,
createdAt: 1,
updatedAt: 1
},
{
id: BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
name: '退群通知',
enabled: true,
ruleType: 'leave_notification',
trigger: 'message',
scope: 'group',
conditions: { requireMentionMe: false, keyword: '', keywordMatchMode: 'contains' },
actions: [],
cooldownSeconds: 0,
replyDelaySeconds: 2,
leaveNotification: { target: { type: 'source_chat' }, template },
createdAt: 1,
updatedAt: 1
}
]
})
}
describe('替用户把群名变量补进模板(一次性)', () => {
it('老用户首次读盘时自动补上「群聊」这一行,其余内容保留', () => {
writeRulesWithTemplate(PREVIOUS_DEFAULT_TEMPLATE)
const rule = new AutomationRuleStore(deps(root)).getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
const template = rule?.leaveNotification?.template ?? ''
expect(template).toContain('群聊: {groupName}')
// 原来每一行都还在。
for (const line of PREVIOUS_DEFAULT_TEMPLATE.split('\n')) {
if (line.trim()) expect(template).toContain(line)
}
})
it('只补一次:之后用户删掉这一行,重启不会被加回来', () => {
writeRulesWithTemplate(PREVIOUS_DEFAULT_TEMPLATE)
// 第一轮:自动补上,并落盘标记。
const first = new AutomationRuleStore(deps(root))
expect(first.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.template).toContain(
'群聊: {groupName}'
)
// 用户主动把这一行删掉并保存。
const stripped = PREVIOUS_DEFAULT_TEMPLATE
first.saveLeaveNotificationRule({
...first.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID),
leaveNotification: { target: { type: 'source_chat' }, template: stripped }
})
// 重启:不能再替他补 —— "删不掉"比缺信息更讨厌。
const reopened = new AutomationRuleStore(deps(root))
expect(
reopened.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.template
).toBe(stripped)
})
it('已经含 {groupName} 的模板原样不动', () => {
const already = '[退群监测]\n\n群聊: {groupName}\n\n用户: {user}'
writeRulesWithTemplate(already)
const rule = new AutomationRuleStore(deps(root)).getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
expect(rule?.leaveNotification?.template).toBe(already)
})
it('用户完全自定义的模板也不会被覆盖内容', () => {
writeRulesWithTemplate('退群: {user}')
const rule = new AutomationRuleStore(deps(root)).getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
const template = rule?.leaveNotification?.template ?? ''
expect(template).toContain('退群: {user}')
expect(template).toContain('群聊: {groupName}')
})
it('标记已是 true 时不再动模板', () => {
writeRulesWithTemplate(PREVIOUS_DEFAULT_TEMPLATE, { notificationTemplateUpgraded: true })
const rule = new AutomationRuleStore(deps(root)).getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
expect(rule?.leaveNotification?.template).toBe(PREVIOUS_DEFAULT_TEMPLATE)
})
it('全新安装的模板本来就带群名,不需要补', () => {
const rule = new AutomationRuleStore(deps(root)).getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
expect(rule?.leaveNotification?.template).toContain('群聊: {groupName}')
})
})
describe('旧退群通知配置迁移', () => {
it('旧配置从不通知任何人 → 迁移成关闭,且保留自定义模板', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
notificationRoomIds: [],
notificationTemplate: '退群: {user}'
})
const leave = new AutomationRuleStore(deps(root)).getRule(
BUILTIN_LEAVE_NOTIFICATION_RULE_ID
)
expect(leave?.enabled).toBe(false)
// 关闭状态下 target 只是占位值(= 「发回本群」),不会发送任何东西。
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
// 范围本身也精确:`selected` + 空集 = 「一个群都不通知」。
expect(leave?.leaveNotification?.notifyScope).toBe('selected')
expect(leave?.leaveNotification?.notifyRoomIds).toEqual([])
// 用户写的模板内容保留;同一次启动的"补群名"升级会另加一行。
expect(leave?.leaveNotification?.template).toContain('退群: {user}')
expect(leave?.leaveNotification?.template).toContain('群聊: {groupName}')
})
it('旧配置在每个被监控群都通知 → 无损映射为「当前群聊」+ 全部范围,且保持启用', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
notificationRoomIds: ['a@chatroom', 'b@chatroom']
})
const leave = new AutomationRuleStore(deps(root)).getRule(
BUILTIN_LEAVE_NOTIFICATION_RULE_ID
)
expect(leave?.enabled).toBe(true)
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
expect(leave?.leaveNotification?.notifyScope).toBe('all')
expect(leave?.leaveNotification?.targetNeedsReview).toBeUndefined()
})
/*
* 关键的一条:旧「通知群聊」是**逐群**的多值配置,恢复 `notifyScope` 之后
* 它就是一个正常可表达的字段 —— 所以这里**不再降级成 needs_review**:
* 那份勾选被原样保留、规则保持启用、不会再多一次"被无辜停用"的体验。
*/
it('旧配置只勾了部分群 → 原样保留那份子集,保持启用,不做有损改写', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom', 'b@chatroom', 'c@chatroom'],
notificationRoomIds: ['b@chatroom'],
notificationTemplate: '退群: {user}'
})
const store = new AutomationRuleStore(deps(root))
const leave = store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
expect(leave?.enabled).toBe(true)
expect(leave?.leaveNotification?.targetNeedsReview).toBeUndefined()
// 收件人恒为事件所在群(旧版 notifyGroup 的事实)⇒ target 仍是 source_chat。
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
// 二次勾选逐字保留:只通知 b,不多不少。
expect(leave?.leaveNotification?.notifyScope).toBe('selected')
expect(leave?.leaveNotification?.notifyRoomIds).toEqual(['b@chatroom'])
// 用户写的模板内容保留;同一次启动的"补群名"升级会另加一行。
expect(leave?.leaveNotification?.template).toContain('退群: {user}')
expect(leave?.leaveNotification?.template).toContain('群聊: {groupName}')
const backup = fs.readJsonSync(
path.join(root, 'automation', 'leave-notification-migration-backup.json')
)
expect(backup).toMatchObject({
outcome: 'lossless_selected_groups',
legacy: { notificationRoomIds: ['b@chatroom'] }
})
expect(store.getLastLeaveNotificationMigration()?.outcome).toBe('lossless_selected_groups')
})
it('迁移只跑一次:重启两次也只有一条退群通知规则', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
notificationRoomIds: ['a@chatroom', 'b@chatroom']
})
const first = new AutomationRuleStore(deps(root))
const second = new AutomationRuleStore(deps(root))
for (const store of [first, second]) {
expect(
store.listRules().filter((rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
).toHaveLength(1)
}
})
it('迁移完成标记会被持久化,删掉退群通知规则后重启不会再被塞回来', () => {
const store = new AutomationRuleStore(deps(root))
expect(store.deleteRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)).toBe(true)
const reopened = new AutomationRuleStore(deps(root))
expect(reopened.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)).toBeUndefined()
})
it('旧状态文件在迁移后仍留在磁盘上(不做不可逆删除)', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom'],
notificationRoomIds: ['a@chatroom']
})
void new AutomationRuleStore(deps(root))
expect(fs.pathExistsSync(path.join(root, 'group-exit-monitor.json'))).toBe(true)
})
})
})
/**
* 旧「定时日报任务」→ `scheduled_report` 规则的 store 级迁移。
*
* 纯映射器的逐字段断言在 `scheduled-report-migration.test.ts`;
* 这里只守 store 负责的三件事:**插几条 / 幂等 / 解析器不可用时推迟**。
*/
describe('AutomationRuleStore · 定时日报迁移', () => {
const legacyTasksFile = (): string => path.join(root, 'scheduled-reports', 'tasks.json')
const backupFile = (): string =>
path.join(root, 'automation', 'scheduled-report-migration-backup.json')
/** 与 main 侧注入的真实解析器同口径(`@chatroom` 原样透传)。 */
const resolver = (raw: string): string | undefined => {
const key = raw.trim()
if (!key) return undefined
if (key.endsWith('@chatroom')) return key
return { 'tech-md5': 'tech@chatroom', 'product-md5': 'product@chatroom' }[key]
}
const writeLegacyTasks = (value: unknown): void => {
fs.ensureDirSync(path.dirname(legacyTasksFile()))
fs.writeJsonSync(legacyTasksFile(), value)
}
const legacyTasks = (): unknown[] => [
{
id: 'scheduled_report_a',
name: 'A 群日报',
group: 'tech-md5',
target: 'tech@chatroom',
scheduleTime: '09:00',
reportRange: 'yesterday',
enabled: true,
createdAt: '2026-08-01T01:00:00.000Z',
updatedAt: '2026-08-01T01:00:00.000Z'
},
{
id: 'scheduled_report_b',
name: 'B 群日报',
group: 'product-md5',
target: 'product@chatroom',
scheduleTime: '10:00',
reportRange: 'today',
enabled: false,
createdAt: '2026-08-02T01:00:00.000Z',
updatedAt: '2026-08-02T01:00:00.000Z'
},
{
id: 'scheduled_report_c',
name: '发到别的群',
group: 'tech-md5',
target: 'product@chatroom',
scheduleTime: '11:00',
reportRange: 'today',
enabled: true,
createdAt: '2026-08-03T01:00:00.000Z',
updatedAt: '2026-08-03T01:00:00.000Z'
}
]
beforeEach(() => {
fs.removeSync(path.join(root, 'automation'))
fs.removeSync(path.join(root, 'scheduled-reports'))
})
it('3 条旧任务 ⇒ 3 条 scheduled_report 规则(连同内置规则一起),且判定分布如实', () => {
writeLegacyTasks(legacyTasks())
const store = new AutomationRuleStore(deps(root))
store.setLegacyConversationResolver(resolver)
const scheduled = store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')
expect(scheduled.map((rule) => rule.id).sort()).toEqual([
'scheduled_report_a',
'scheduled_report_b',
'scheduled_report_c'
])
const summary = store.getLastScheduledReportMigration()
expect(summary).toEqual({
total: 3,
migrated: 3,
lossless: 2,
needsReview: 1,
duplicatesSkipped: 0
})
// 「发到别的群」那条:不自动改写目标,且必须停用。
const needsReview = scheduled.find((rule) => rule.id === 'scheduled_report_c')
expect(needsReview?.enabled).toBe(false)
expect(needsReview?.scheduledReport?.targetNeedsReview).toBe(true)
// 旧任务是启用的,但迁移后必须停用 —— 不能替用户决定发到哪儿。
expect(store.getRule('scheduled_report_a')?.enabled).toBe(true)
})
it('二次启动不会重复迁移(仍只有 3 条,不是 6 条)', () => {
writeLegacyTasks(legacyTasks())
const first = new AutomationRuleStore(deps(root))
first.setLegacyConversationResolver(resolver)
expect(
first.listRules().filter((rule) => rule.ruleType === 'scheduled_report')
).toHaveLength(3)
// 第二个实例 = 重启:从盘上读,旧 tasks.json 仍在原处。
const second = new AutomationRuleStore(deps(root))
second.setLegacyConversationResolver(resolver)
const ids = second
.listRules()
.filter((rule) => rule.ruleType === 'scheduled_report')
.map((rule) => rule.id)
expect(ids).toHaveLength(3)
expect(new Set(ids).size).toBe(3)
// 已迁移标记落盘 ⇒ 不再有待迁任务。
expect(second.hasPendingScheduledReportMigration()).toBe(false)
})
it('解析器不可用时**推迟**迁移:不置标记、不插规则、如实报告待迁', () => {
writeLegacyTasks(legacyTasks())
const store = new AutomationRuleStore(deps(root))
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(0)
expect(store.hasPendingScheduledReportMigration()).toBe(true)
expect(store.getLastScheduledReportMigration()).toBeNull()
// 数据库就绪后注入解析器 ⇒ 立刻补跑。
store.setLegacyConversationResolver(resolver)
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(3)
expect(store.hasPendingScheduledReportMigration()).toBe(false)
})
it('迁移前把旧任务原样落一份 backup(用户数据可回溯)', () => {
const tasks = legacyTasks()
writeLegacyTasks(tasks)
const store = new AutomationRuleStore(deps(root))
// 先注入解析器、后首次加载:这正是 main 的真实顺序(构造函数不读盘)。
// `setLegacyConversationResolver` 在未加载时只记住 resolver,
// 迁移推迟到第一次 `ensureLoaded()`,注入顺序不影响结果。
store.setLegacyConversationResolver(resolver)
store.listRules()
expect(fs.pathExistsSync(backupFile())).toBe(true)
const backup = fs.readJsonSync(backupFile()) as {
summary: { migrated: number }
legacyTasks: unknown[]
}
expect(backup.summary.migrated).toBe(3)
// 备份里保留**原始字段**(包括旧目标原文),否则出了问题无法回溯。
expect(backup.legacyTasks).toEqual(tasks)
// 旧 tasks.json 不被删除或改写 —— 迁移必须是可逆的。
expect(fs.readJsonSync(legacyTasksFile())).toEqual(tasks)
})
it('没有旧任务时走全新安装路径:不插任何定时日报规则,也不报待迁', () => {
const store = new AutomationRuleStore(deps(root))
store.setLegacyConversationResolver(resolver)
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(0)
expect(store.hasPendingScheduledReportMigration()).toBe(false)
expect(store.getLastScheduledReportMigration()).toBeNull()
expect(fs.pathExistsSync(backupFile())).toBe(false)
})
it('旧文件损坏时按「没有旧任务」处理,绝不抛异常挡住启动', () => {
fs.ensureDirSync(path.dirname(legacyTasksFile()))
fs.writeFileSync(legacyTasksFile(), 'not json at all', 'utf8')
const store = new AutomationRuleStore(deps(root))
store.setLegacyConversationResolver(resolver)
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(0)
})
})
describe('AutomationExecutionLogService', () => {
beforeEach(() => {
fs.removeSync(path.join(root, 'automation'))
})
const execution = (overrides: Partial<AutomationExecution> = {}): AutomationExecution => ({
executionId: 'exec-1',
ruleId: 'rule-1',
ruleName: '测试规则',
triggerTime: 1_700_000_000_000,
sourceDisplayName: '测试群',
status: 'success',
durationMs: 1_234,
steps: [{ key: 'received', label: '收到消息', status: 'success' }],
...overrides
})
it('记录并返回,新的在前', () => {
const log = new AutomationExecutionLogService({ userDataPath: () => root })
log.record(execution({ executionId: 'a', ruleName: '规则A' }))
log.record(execution({ executionId: 'b', ruleName: '规则B' }))
expect(log.list().map((item) => item.ruleName)).toEqual(['规则B', '规则A'])
})
it('相同 executionId 会覆盖而不是追加(先 running 后终态)', () => {
const log = new AutomationExecutionLogService({ userDataPath: () => root })
log.record(execution({ status: 'running', steps: [] }))
log.record(execution({ status: 'failed', errorSummary: '生成日报失败' }))
const records = log.list()
expect(records).toHaveLength(1)
expect(records[0].status).toBe('failed')
expect(records[0].errorSummary).toBe('生成日报失败')
})
it('容量有界,超出后丢弃最旧的', () => {
const log = new AutomationExecutionLogService({ userDataPath: () => root })
for (let index = 0; index < 250; index += 1) {
log.record(execution({ executionId: `exec-${index}`, ruleName: `规则${index}` }))
}
const records = log.list({ limit: 1000 })
expect(records).toHaveLength(200)
expect(records[0].ruleName).toBe('规则249')
expect(records.some((item) => item.ruleName === '规则49')).toBe(false)
})
it('countSince 只统计时间窗口内的记录', () => {
const log = new AutomationExecutionLogService({ userDataPath: () => root })
log.record(execution({ executionId: 'old', triggerTime: 1_000, status: 'success' }))
log.record(execution({ executionId: 'new-ok', triggerTime: 5_000, status: 'success' }))
log.record(execution({ executionId: 'new-bad', triggerTime: 6_000, status: 'failed' }))
expect(log.countSince(4_000)).toEqual({ total: 2, success: 1 })
expect(log.countSince(0)).toEqual({ total: 3, success: 2 })
})
it('clear 清空并落盘', () => {
const log = new AutomationExecutionLogService({ userDataPath: () => root })
log.record(execution())
expect(log.clear()).toBe(true)
expect(log.list()).toHaveLength(0)
expect(new AutomationExecutionLogService({ userDataPath: () => root }).list()).toHaveLength(0)
})
it('落盘后新实例能读到(重启后仍能看到近期执行记录)', () => {
const log = new AutomationExecutionLogService({ userDataPath: () => root })
log.record(execution({ ruleName: '重启检查' }))
expect(fs.pathExistsSync(executionsFile())).toBe(true)
const reloaded = new AutomationExecutionLogService({ userDataPath: () => root })
expect(reloaded.list()[0].ruleName).toBe('重启检查')
})
it('文件损坏时降级为空数组而不是抛异常', () => {
fs.ensureDirSync(path.dirname(executionsFile()))
fs.writeFileSync(executionsFile(), 'not json at all', 'utf8')
const log = new AutomationExecutionLogService({ userDataPath: () => root })
expect(log.list()).toEqual([])
})
it('丢弃缺少 executionId 的脏记录', () => {
const log = new AutomationExecutionLogService({ userDataPath: () => root })
log.record({ ...execution(), executionId: ' ' })
expect(log.list()).toHaveLength(0)
})
it('list 的 limit 参数生效', () => {
const log = new AutomationExecutionLogService({ userDataPath: () => root })
for (let index = 0; index < 10; index += 1) {
log.record(execution({ executionId: `exec-${index}` }))
}
expect(log.list({ limit: 3 })).toHaveLength(3)
})
})