mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 02:53:47 +08:00
517 lines
19 KiB
TypeScript
517 lines
19 KiB
TypeScript
import { mkdtemp, writeFile } from 'fs/promises'
|
||
import { tmpdir } from 'os'
|
||
import { join } from 'path'
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tracememo-test-user-data' } }))
|
||
|
||
import {
|
||
ScheduledReportService,
|
||
calculateNextRunAt,
|
||
resolveDueScheduledSlot,
|
||
validateScheduleTime
|
||
} from '../../src/main/services/scheduled-report-service'
|
||
import type { ScheduledReportDependencies } from '../../src/main/services/scheduled-report-service'
|
||
import type { AgentHubStatus } from '../../src/shared/agent-hub'
|
||
import {
|
||
createDefaultScheduledReportRule,
|
||
normalizeScheduledReportConfig,
|
||
type AutomationRule
|
||
} from '../../src/shared/automation'
|
||
import type { ScheduledRuleRunOutcome } from '../../src/shared/automation'
|
||
|
||
/**
|
||
* 定时日报服务(退役后)只剩三件事:调度 / 异常通知 / 旧数据只读存档。
|
||
*
|
||
* 这一组测试守的是**任务来源已经换成 AutomationRuleStore**:
|
||
* - 调度器**只读规则**,执行一律委派给注入的 `executeRule`;
|
||
* - 槽位已消费 / 已停用 / 目标待重选 / 数据库未就绪 ⇒ **不执行**;
|
||
* - 定时失败 ⇒ 推异常通知;手动执行 / `NO_MESSAGES` ⇒ **不推**;
|
||
* - 旧 `tasks.json` / `executions.json` **只读**,且新执行日志按 ruleId 投影。
|
||
*/
|
||
|
||
const onlineAgentHubStatus = (): AgentHubStatus => ({
|
||
hub: 'online' as const,
|
||
connector: 'online' as const,
|
||
updatedAt: Date.now()
|
||
})
|
||
|
||
const SCHEDULE_TIME = '18:30'
|
||
|
||
/** 按本地时区构造 ISO 串:调度走 `Date#setHours`,写死偏移量会让用例只在东八区通过。 */
|
||
const localISO = (year: number, month: number, day: number, hour: number, minute: number): string =>
|
||
new Date(year, month - 1, day, hour, minute, 0, 0).toISOString()
|
||
|
||
/** 一个"当天 18:30 之后"的时刻,保证当日槽位已到点。 */
|
||
const AFTER_SLOT = new Date(2026, 7, 27, 18, 31)
|
||
/** 必须早于所有参与断言的槽位,且固定,不能用 `Date.now()`。 */
|
||
const CREATED_AT = new Date(2026, 7, 20, 0, 0).getTime()
|
||
|
||
function makeRule(overrides: Partial<AutomationRule> = {}): AutomationRule {
|
||
return {
|
||
...createDefaultScheduledReportRule(CREATED_AT, {
|
||
id: 'rule-1',
|
||
name: '每日晚报',
|
||
config: normalizeScheduledReportConfig({
|
||
schedule: { time: SCHEDULE_TIME },
|
||
report: { sourceConversationId: 'g1@chatroom', messageTypes: ['text'] }
|
||
})
|
||
}),
|
||
...overrides
|
||
}
|
||
}
|
||
|
||
interface Harness {
|
||
service: ScheduledReportService
|
||
storageDir: string
|
||
rules: AutomationRule[]
|
||
/** **真正注入进 service 的那个** mock(可能来自 `overrides`)。 */
|
||
executeRule: ReturnType<typeof vi.fn>
|
||
sendNotification: ReturnType<typeof vi.fn>
|
||
listExecutions: ReturnType<typeof vi.fn>
|
||
}
|
||
|
||
async function makeHarness(
|
||
overrides: Partial<ScheduledReportDependencies> = {},
|
||
rules: AutomationRule[] = [makeRule()]
|
||
): Promise<Harness> {
|
||
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-'))
|
||
const executeRule = vi.fn(
|
||
async (): Promise<ScheduledRuleRunOutcome> => ({
|
||
executed: true,
|
||
executionId: 'exec-1',
|
||
status: 'success',
|
||
reportGenerated: true
|
||
})
|
||
)
|
||
const sendNotification = vi.fn().mockResolvedValue({ success: true, status: 'sent' })
|
||
const listExecutions = vi.fn().mockReturnValue([])
|
||
const deps = {
|
||
storageDir,
|
||
executeRule,
|
||
listRules: () => rules,
|
||
listExecutions,
|
||
sendNotification,
|
||
getNotificationRecipient: () => 'owner-wxid',
|
||
getAgentHubStatus: onlineAgentHubStatus,
|
||
isDatabaseReady: () => true,
|
||
now: () => AFTER_SLOT,
|
||
...overrides
|
||
} as unknown as ScheduledReportDependencies
|
||
const service = new ScheduledReportService(deps)
|
||
return {
|
||
service,
|
||
storageDir,
|
||
rules,
|
||
// 用 dep 上的**最终**实现回填,避免 `overrides` 覆盖后断言打空。
|
||
executeRule: deps.executeRule as unknown as ReturnType<typeof vi.fn>,
|
||
sendNotification: deps.sendNotification as unknown as ReturnType<typeof vi.fn>,
|
||
listExecutions: deps.listExecutions as unknown as ReturnType<typeof vi.fn>
|
||
}
|
||
}
|
||
|
||
describe('定时日报 · 时间计算', () => {
|
||
it('校验 HH:mm 并计算下一次本地时刻', () => {
|
||
expect(validateScheduleTime('09:05')).toBe(true)
|
||
expect(validateScheduleTime('24:00')).toBe(false)
|
||
expect(validateScheduleTime('9:5')).toBe(false)
|
||
const from = new Date(2026, 7, 27, 10, 0)
|
||
expect(calculateNextRunAt('18:30', from)).toBe(localISO(2026, 8, 27, 18, 30))
|
||
// 已经过了今天的点 ⇒ 顺延到明天。
|
||
expect(calculateNextRunAt('09:00', from)).toBe(localISO(2026, 8, 28, 9, 0))
|
||
})
|
||
|
||
it('resolveDueScheduledSlot 给出"已经到点、最近的那个"槽位', () => {
|
||
// 18:31 时,18:30 已经到点 ⇒ 槽位是今天 18:30。
|
||
expect(resolveDueScheduledSlot(SCHEDULE_TIME, AFTER_SLOT)).toBe(localISO(2026, 8, 27, 18, 30))
|
||
// 18:29 时,今天的点还没到 ⇒ 最近一个槽位是昨天。
|
||
expect(resolveDueScheduledSlot(SCHEDULE_TIME, new Date(2026, 7, 27, 18, 29))).toBe(
|
||
localISO(2026, 8, 26, 18, 30)
|
||
)
|
||
})
|
||
})
|
||
|
||
describe('定时日报 · 调度器', () => {
|
||
it('到点且未消费 ⇒ 以 schedule 触发,并把槽位交给 AutomationService', async () => {
|
||
const { service, executeRule } = await makeHarness()
|
||
|
||
await service.tick(AFTER_SLOT)
|
||
await service.settle()
|
||
|
||
expect(executeRule).toHaveBeenCalledTimes(1)
|
||
const [ruleId, options] = executeRule.mock.calls[0]
|
||
expect(ruleId).toBe('rule-1')
|
||
expect(options).toEqual({
|
||
trigger: 'schedule',
|
||
scheduledSlot: localISO(2026, 8, 27, 18, 30)
|
||
})
|
||
})
|
||
|
||
it('槽位已消费 ⇒ 不再触发(重启后不重复补跑)', async () => {
|
||
const rule = makeRule({
|
||
scheduledReport: {
|
||
...normalizeScheduledReportConfig({
|
||
schedule: { time: SCHEDULE_TIME },
|
||
report: { sourceConversationId: 'g1@chatroom', messageTypes: ['text'] }
|
||
}),
|
||
lastScheduledSlot: localISO(2026, 8, 27, 18, 30)
|
||
}
|
||
})
|
||
const { service, executeRule } = await makeHarness({}, [rule])
|
||
|
||
await service.tick(AFTER_SLOT)
|
||
await service.settle()
|
||
|
||
expect(executeRule).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('停用的规则不调度', async () => {
|
||
const { service, executeRule } = await makeHarness({}, [makeRule({ enabled: false })])
|
||
await service.tick(AFTER_SLOT)
|
||
await service.settle()
|
||
expect(executeRule).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('目标待重选的规则不调度(执行必然失败)', async () => {
|
||
const rule = makeRule({
|
||
scheduledReport: {
|
||
...normalizeScheduledReportConfig({
|
||
schedule: { time: SCHEDULE_TIME },
|
||
report: { sourceConversationId: 'g1@chatroom', messageTypes: ['text'] }
|
||
}),
|
||
targetNeedsReview: true
|
||
}
|
||
})
|
||
const { service, executeRule } = await makeHarness({}, [rule])
|
||
await service.tick(AFTER_SLOT)
|
||
await service.settle()
|
||
expect(executeRule).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('数据库未就绪时不调度', async () => {
|
||
const { service, executeRule } = await makeHarness({ isDatabaseReady: () => false })
|
||
await service.tick(AFTER_SLOT)
|
||
await service.settle()
|
||
expect(executeRule).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('今天的点还没到 + 昨天已消费 ⇒ 不触发', async () => {
|
||
// 规则在昨天 18:30 跑过(游标 = 昨天 18:30),现在是今天 18:29:
|
||
// 最近的槽位仍然是「昨天 18:30」,但它已经消费过 ⇒ 什么都别做。
|
||
const rule = makeRule({
|
||
scheduledReport: {
|
||
...normalizeScheduledReportConfig({
|
||
schedule: { time: SCHEDULE_TIME },
|
||
report: { sourceConversationId: 'g1@chatroom', messageTypes: ['text'] }
|
||
}),
|
||
lastScheduledSlot: localISO(2026, 8, 26, 18, 30)
|
||
}
|
||
})
|
||
const { service, executeRule } = await makeHarness({}, [rule])
|
||
|
||
await service.tick(new Date(2026, 7, 27, 18, 29))
|
||
await service.settle()
|
||
|
||
expect(executeRule).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('槽位早于规则创建时间 ⇒ 不触发(新建规则不会被立刻补发)', async () => {
|
||
// 今天 12:00 建的规则、执行时间 18:30。今天 18:30 还没到,
|
||
// 最近的槽位是**昨天 18:30** —— 它早于 createdAt,不属于这条规则。
|
||
// 少了这道闸,新建规则会在下一次 15 秒 tick 里立刻发一份昨天的报告。
|
||
const rule = makeRule({ createdAt: new Date(2026, 7, 27, 12, 0).getTime() })
|
||
const { service, executeRule } = await makeHarness({}, [rule])
|
||
|
||
await service.tick(new Date(2026, 7, 27, 18, 29))
|
||
await service.settle()
|
||
|
||
expect(executeRule).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('停机错过一个槽位 ⇒ 补跑最近那个未消费的槽位(不静默丢一天)', async () => {
|
||
// 规则 08-20 创建、游标停在 08-25 18:30,08-26 那晚应用没开:
|
||
// 08-27 18:29 启动时,最近未消费的槽位是 08-26 18:30,应当补上。
|
||
const rule = makeRule({
|
||
scheduledReport: {
|
||
...normalizeScheduledReportConfig({
|
||
schedule: { time: SCHEDULE_TIME },
|
||
report: { sourceConversationId: 'g1@chatroom', messageTypes: ['text'] }
|
||
}),
|
||
lastScheduledSlot: localISO(2026, 8, 25, 18, 30)
|
||
}
|
||
})
|
||
const { service, executeRule } = await makeHarness({}, [rule])
|
||
|
||
await service.tick(new Date(2026, 7, 27, 18, 29))
|
||
await service.settle()
|
||
|
||
expect(executeRule).toHaveBeenCalledTimes(1)
|
||
expect(executeRule.mock.calls[0][1]).toEqual({
|
||
trigger: 'schedule',
|
||
scheduledSlot: localISO(2026, 8, 26, 18, 30)
|
||
})
|
||
})
|
||
|
||
it('游标比槽位新(用户改过执行时间)⇒ 旧槽位已过期,不再补', async () => {
|
||
const rule = makeRule({
|
||
scheduledReport: {
|
||
...normalizeScheduledReportConfig({
|
||
schedule: { time: SCHEDULE_TIME },
|
||
report: { sourceConversationId: 'g1@chatroom', messageTypes: ['text'] }
|
||
}),
|
||
lastScheduledSlot: localISO(2026, 8, 27, 18, 30)
|
||
}
|
||
})
|
||
const { service, executeRule } = await makeHarness({}, [rule])
|
||
|
||
// 18:31 时最近槽位正好等于游标 ⇒ 不触发;再把时间回拨到 18:29,
|
||
// 槽位退化成 08-26 18:30,仍然**比游标旧** ⇒ 同样不触发。
|
||
await service.tick(AFTER_SLOT)
|
||
await service.tick(new Date(2026, 7, 27, 18, 29))
|
||
await service.settle()
|
||
|
||
expect(executeRule).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('schedule 触发的执行结果不会走 conversation gate(并发由 AutomationService 管)', async () => {
|
||
// 这里只断言"调度器把两次 tick 都原样交给 executeRule":
|
||
// 真正的 ruleId inFlight 去重在 AutomationService,见 automation-service 的单测。
|
||
const { service, executeRule } = await makeHarness()
|
||
await service.tick(AFTER_SLOT)
|
||
await service.tick(AFTER_SLOT)
|
||
await service.settle()
|
||
expect(executeRule).toHaveBeenCalledTimes(2)
|
||
})
|
||
})
|
||
|
||
describe('定时日报 · 立即执行', () => {
|
||
it('手动执行走 manual,且**不消耗槽位**', async () => {
|
||
const { service, executeRule } = await makeHarness()
|
||
|
||
const result = await service.runScheduledReportNow('rule-1')
|
||
|
||
expect(result.success).toBe(true)
|
||
expect(executeRule).toHaveBeenCalledWith('rule-1', { trigger: 'manual' })
|
||
})
|
||
|
||
it('规则不存在时如实报错,不执行', async () => {
|
||
const { service, executeRule } = await makeHarness()
|
||
const result = await service.runScheduledReportNow('missing')
|
||
expect(result.success).toBe(false)
|
||
expect(result.error).toContain('未找到')
|
||
expect(executeRule).not.toHaveBeenCalled()
|
||
})
|
||
})
|
||
|
||
describe('定时日报 · 微信异常通知', () => {
|
||
it('定时执行失败 ⇒ 推一条异常通知(能力开启时)', async () => {
|
||
const { service, storageDir, executeRule, sendNotification } = await makeHarness({
|
||
executeRule: vi.fn(async () => ({
|
||
executed: true,
|
||
executionId: 'exec-fail',
|
||
status: 'failed',
|
||
reportGenerated: false,
|
||
errorSummary: '日报生成失败'
|
||
})) as unknown as ScheduledReportDependencies['executeRule']
|
||
})
|
||
await writeFile(join(storageDir, 'settings.json'), JSON.stringify({ enabled: true }))
|
||
|
||
await service.tick(AFTER_SLOT)
|
||
await service.settle()
|
||
|
||
expect(executeRule).toHaveBeenCalledTimes(1)
|
||
expect(sendNotification).toHaveBeenCalledTimes(1)
|
||
const text = sendNotification.mock.calls[0][0].text as string
|
||
expect(text).toContain('每日晚报')
|
||
expect(text).toContain('日报生成失败')
|
||
|
||
const notifications = await service.listNotifications()
|
||
expect(notifications).toHaveLength(1)
|
||
expect(notifications[0].status).toBe('sent')
|
||
})
|
||
|
||
it('NO_MESSAGES 不是"失败",不打扰用户', async () => {
|
||
const { service, storageDir, sendNotification } = await makeHarness({
|
||
executeRule: vi.fn(async () => ({
|
||
executed: true,
|
||
executionId: 'exec-empty',
|
||
status: 'failed',
|
||
reportGenerated: false,
|
||
errorCode: 'NO_MESSAGES',
|
||
errorSummary: '暂无可生成的日报'
|
||
})) as unknown as ScheduledReportDependencies['executeRule']
|
||
})
|
||
await writeFile(join(storageDir, 'settings.json'), JSON.stringify({ enabled: true }))
|
||
|
||
await service.tick(AFTER_SLOT)
|
||
await service.settle()
|
||
|
||
expect(sendNotification).not.toHaveBeenCalled()
|
||
expect(await service.listNotifications()).toHaveLength(0)
|
||
})
|
||
|
||
it('通知关闭时不推送,也不排队', async () => {
|
||
const { service, sendNotification } = await makeHarness({
|
||
executeRule: vi.fn(async () => ({
|
||
executed: true,
|
||
executionId: 'exec-fail',
|
||
status: 'failed',
|
||
reportGenerated: false,
|
||
errorSummary: '日报生成失败'
|
||
})) as unknown as ScheduledReportDependencies['executeRule']
|
||
})
|
||
|
||
await service.tick(AFTER_SLOT)
|
||
await service.settle()
|
||
|
||
expect(sendNotification).not.toHaveBeenCalled()
|
||
expect(await service.listNotifications()).toHaveLength(0)
|
||
})
|
||
|
||
it('手动执行失败**不**推异常通知(与旧行为一致)', async () => {
|
||
const { service, storageDir, sendNotification } = await makeHarness({
|
||
executeRule: vi.fn(async () => ({
|
||
executed: true,
|
||
executionId: 'exec-manual',
|
||
status: 'failed',
|
||
reportGenerated: false,
|
||
errorSummary: '日报生成失败'
|
||
})) as unknown as ScheduledReportDependencies['executeRule']
|
||
})
|
||
await writeFile(join(storageDir, 'settings.json'), JSON.stringify({ enabled: true }))
|
||
|
||
await service.runScheduledReportNow('rule-1')
|
||
|
||
expect(sendNotification).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('开启通知前会先做能力检测并试发一条', async () => {
|
||
const { service, sendNotification } = await makeHarness(
|
||
{ getAgentHubStatus: () => ({ hub: 'offline', connector: 'disconnected', updatedAt: 0 }) },
|
||
[]
|
||
)
|
||
const result = await service.setNotificationEnabled(true)
|
||
expect(result.success).toBe(false)
|
||
expect(result.reason).toBe('agent_hub_offline')
|
||
expect(sendNotification).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('Agent Hub 在线且已绑定接收者 ⇒ 开启成功并落盘', async () => {
|
||
const { service, storageDir, sendNotification } = await makeHarness({}, [])
|
||
const result = await service.setNotificationEnabled(true)
|
||
expect(result.success).toBe(true)
|
||
expect(sendNotification).toHaveBeenCalledTimes(1)
|
||
expect(await service.getNotificationSettings()).toEqual({ enabled: true })
|
||
// 设置**真的**落盘了(不是只改了内存)。
|
||
const { readFile } = await import('fs/promises')
|
||
const raw = await readFile(join(storageDir, 'settings.json'), 'utf8')
|
||
expect(JSON.parse(raw)).toEqual({ enabled: true })
|
||
})
|
||
})
|
||
|
||
describe('定时日报 · 旧数据只读存档', () => {
|
||
it('旧执行记录原样可读,且按旧 taskId / 新 ruleId 双向匹配', async () => {
|
||
const { service, storageDir } = await makeHarness()
|
||
await writeFile(
|
||
join(storageDir, 'executions.json'),
|
||
JSON.stringify([
|
||
{
|
||
id: 'old-exec-1',
|
||
taskId: 'scheduled_report_old',
|
||
startedAt: '2026-08-01T10:00:00.000Z',
|
||
status: 'success',
|
||
message: '日报生成成功,微信发送成功'
|
||
}
|
||
])
|
||
)
|
||
// 重新建一个实例,确保从盘上读(而不是复用内存)。
|
||
const fresh = new ScheduledReportService({
|
||
storageDir,
|
||
listRules: () => [],
|
||
isDatabaseReady: () => true
|
||
})
|
||
|
||
const all = await fresh.listLegacyExecutions()
|
||
expect(all).toHaveLength(1)
|
||
expect(all[0].id).toBe('old-exec-1')
|
||
|
||
const byLegacyId = await fresh.listLegacyExecutions('scheduled_report_old')
|
||
expect(byLegacyId).toHaveLength(1)
|
||
|
||
// 迁移后 ruleId 可能形如 `scheduled-report:<旧 id>`(旧 id 缺失时的确定性映射)。
|
||
const byMappedId = await fresh.listLegacyExecutions('scheduled-report:scheduled_report_old')
|
||
expect(byMappedId).toHaveLength(1)
|
||
|
||
expect(await fresh.listLegacyExecutions('unrelated')).toHaveLength(0)
|
||
void service
|
||
})
|
||
|
||
it('项目只读投影只暴露「发回来源群」的规则(旧 HTTP 契约能如实表达的那一种)', async () => {
|
||
const { service } = await makeHarness({}, [
|
||
makeRule({
|
||
id: 'rule-source',
|
||
// 默认目标是 `file_transfer`,旧 HTTP 契约表达不了 ⇒ 这里显式选「发回来源群」。
|
||
scheduledReport: normalizeScheduledReportConfig({
|
||
schedule: { time: SCHEDULE_TIME },
|
||
report: { sourceConversationId: 'g1@chatroom', messageTypes: ['text'] },
|
||
target: { type: 'source_chat' }
|
||
})
|
||
}),
|
||
makeRule({
|
||
id: 'rule-file',
|
||
scheduledReport: normalizeScheduledReportConfig({
|
||
schedule: { time: SCHEDULE_TIME },
|
||
report: { sourceConversationId: 'g2@chatroom', messageTypes: ['text'] },
|
||
target: { type: 'file_transfer' }
|
||
})
|
||
})
|
||
])
|
||
|
||
const tasks = await service.listTasks()
|
||
expect(tasks.map((task) => task.id)).toEqual(['rule-source'])
|
||
})
|
||
|
||
it('新执行日志按 ruleId 投影成旧执行形状(只读)', async () => {
|
||
const { service } = await makeHarness({
|
||
listExecutions: (() => [
|
||
{
|
||
executionId: 'exec-1',
|
||
ruleId: 'rule-1',
|
||
ruleName: '每日晚报',
|
||
triggerTime: Date.parse('2026-08-27T10:00:00.000Z'),
|
||
trigger: 'schedule',
|
||
sourceDisplayName: '每日晚报来源群',
|
||
status: 'failed' as const,
|
||
durationMs: 1200,
|
||
steps: [
|
||
{ key: 'report_generating' as const, label: '生成日报', status: 'failed' as const },
|
||
{ key: 'report_sent' as const, label: '发送日报', status: 'pending' as const }
|
||
],
|
||
errorSummary: '日报生成失败'
|
||
}
|
||
]) as unknown as ScheduledReportDependencies['listExecutions']
|
||
})
|
||
|
||
const executions = await service.listExecutions('rule-1')
|
||
expect(executions).toHaveLength(1)
|
||
expect(executions[0].taskId).toBe('rule-1')
|
||
expect(executions[0].triggerType).toBe('scheduled')
|
||
expect(executions[0].status).toBe('failed')
|
||
expect(executions[0].error).toBe('日报生成失败')
|
||
})
|
||
})
|
||
|
||
describe('定时日报 · 启动与停止', () => {
|
||
beforeEach(() => {
|
||
vi.useRealTimers()
|
||
})
|
||
|
||
it('start 会立刻跑一次 tick,stop 后不再有计时器', async () => {
|
||
const { service, executeRule } = await makeHarness()
|
||
await service.start()
|
||
await service.settle()
|
||
service.stop()
|
||
|
||
expect(executeRule).toHaveBeenCalledTimes(1)
|
||
})
|
||
})
|