Files
WechatExplorer/tests/unit/scheduled-report-service.test.ts
T

799 lines
29 KiB
TypeScript

import { mkdtemp, readFile, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tracememo-test-user-data' } }))
import {
calculateNextRunAt,
ScheduledReportService,
validateScheduleTime
} from '../../src/main/services/scheduled-report-service'
import type { AgentHubStatus } from '../../src/shared/agent-hub'
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
import type { GeneratedReportRecord } from '../../src/shared/report-history'
import type {
ScheduledReportExecution,
ScheduledReportNotification
} from '../../src/shared/scheduled-report'
import type { ScheduledReportDependencies } from '../../src/main/services/scheduled-report-service'
const capability: PersonalWechatSendCapability = {
supported: true,
ready: true,
status: 'ready',
capabilities: { text: true, image: true, voice: true },
senderStatus: {} as never,
message: 'ready'
}
const unavailableCapability: PersonalWechatSendCapability = {
...capability,
ready: false,
status: 'needs_verification',
capabilities: { text: false, image: false, voice: false },
message: '请先完成微信消息能力检测'
}
const reportRecord = (pngPath: string): GeneratedReportRecord => ({
id: 'report-1',
contactId: 'group-md5',
contactName: '研发群',
dateRange: '昨日',
messageCount: 12,
generatedAt: '2026-08-27T01:00:00.000Z',
reportDate: '2026-08-26',
pngPath,
htmlPath: `${pngPath}.html`,
htmlStatus: 'ready' as const,
pngStatus: 'ready' as const
})
function makeDependencies(overrides: Record<string, unknown> = {}): ScheduledReportDependencies {
return {
getCapability: async () => capability,
generateReport: async () => ({
success: true,
groupName: '研发群',
pngPath: '/tmp/generated.png',
messageCount: 12
}),
saveGeneratedReport: async () => ({
success: true,
record: reportRecord('/tmp/saved.png')
}),
send: async () => ({ success: true, status: capability.senderStatus }),
sendNotification: async () => ({ success: true, status: 'sent' as const }),
getNotificationRecipient: () => 'owner-wxid',
isDatabaseReady: () => true,
...overrides
}
}
async function enableNotifications(storageDir: string): Promise<void> {
await writeFile(join(storageDir, 'settings.json'), JSON.stringify({ enabled: true }))
}
async function runScheduled(
service: ScheduledReportService,
taskId: string
): Promise<ScheduledReportExecution> {
const task = (await service.listTasks()).find((item) => item.id === taskId)
if (!task) throw new Error('scheduled task not found')
await service.tick(new Date(Date.parse(task.nextRunAt) + 1_000))
for (let attempt = 0; attempt < 20; attempt += 1) {
const execution = (await service.listExecutions(taskId))[0]
if (execution && execution.status !== 'running') return execution
await new Promise((resolve) => setTimeout(resolve, 10))
}
throw new Error('scheduled execution did not finish')
}
const onlineAgentHubStatus = (): AgentHubStatus => ({
hub: 'online' as const,
connector: 'online' as const,
updatedAt: Date.now()
})
describe('scheduled report scheduling', () => {
it('validates daily HH:mm and computes the next local occurrence', () => {
expect(validateScheduleTime('09:05')).toBe(true)
expect(validateScheduleTime('24:00')).toBe(false)
const from = new Date('2026-08-27T10:00:00+08:00')
expect(calculateNextRunAt('09:05', from)).toBe('2026-08-28T01:05:00.000Z')
expect(calculateNextRunAt('11:05', from)).toBe('2026-08-27T03:05:00.000Z')
})
it('defaults notification settings to off and persists a successful enablement', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-settings-'))
const sendNotification = vi.fn(async () => ({ success: true, status: 'sent' as const }))
const service = new ScheduledReportService({
storageDir,
getAgentHubStatus: onlineAgentHubStatus,
getNotificationRecipient: () => 'owner-wxid',
sendNotification
})
await expect(service.getNotificationSettings()).resolves.toEqual({ enabled: false })
const enabled = await service.setNotificationEnabled(true)
expect(enabled).toEqual({ success: true, data: { enabled: true } })
expect(sendNotification).toHaveBeenCalledWith({
to: 'owner-wxid',
text: `✅ TraceMemo 定时日报通知已开启
以后定时日报生成或发送出现异常时,
我会通过这里通知你。`
})
const restored = new ScheduledReportService({ storageDir })
await expect(restored.getNotificationSettings()).resolves.toEqual({ enabled: true })
await expect(service.setNotificationEnabled(false)).resolves.toEqual({
success: true,
data: { enabled: false }
})
await expect(restored.getNotificationSettings()).resolves.toEqual({ enabled: true })
const afterClose = new ScheduledReportService({ storageDir })
await expect(afterClose.getNotificationSettings()).resolves.toEqual({ enabled: false })
})
it.each([
{
name: 'rejects an offline Agent Hub',
status: { hub: 'offline' as const, connector: 'online' as const, updatedAt: Date.now() },
recipient: 'owner-wxid',
reason: 'agent_hub_offline' as const
},
{
name: 'rejects an offline connector',
status: { hub: 'online' as const, connector: 'disconnected' as const, updatedAt: Date.now() },
recipient: 'owner-wxid',
reason: 'connector_offline' as const
},
{
name: 'rejects a missing notification recipient',
status: onlineAgentHubStatus(),
recipient: undefined,
reason: 'recipient_not_bound' as const
}
])('$name before saving enabled=true', async ({ status, recipient, reason }) => {
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-capability-'))
const sendNotification = vi.fn()
const service = new ScheduledReportService({
storageDir,
getAgentHubStatus: () => status,
getNotificationRecipient: () => recipient,
sendNotification
})
const result = await service.setNotificationEnabled(true)
expect(result).toMatchObject({ success: false, data: { enabled: false }, reason })
expect(sendNotification).not.toHaveBeenCalled()
await expect(service.getNotificationSettings()).resolves.toEqual({ enabled: false })
})
it('does not enable notifications when the real test send fails', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-send-test-'))
const sendNotification = vi.fn(async () => ({
success: false,
status: 'send_failed' as const,
error: '连接器返回 500'
}))
const service = new ScheduledReportService({
storageDir,
getAgentHubStatus: onlineAgentHubStatus,
getNotificationRecipient: () => 'owner-wxid',
sendNotification
})
const result = await service.setNotificationEnabled(true)
expect(result).toMatchObject({
success: false,
data: { enabled: false },
reason: 'send_failed',
error: '连接器返回 500'
})
await expect(service.getNotificationSettings()).resolves.toEqual({ enabled: false })
})
it('persists lifecycle, executes generation and image sending, and restores tasks', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-'))
const now = new Date('2026-08-27T01:00:00.000Z')
const generatedPath = join(storageDir, 'report.png')
let generated = 0
let sent = 0
const saveHistory = vi.fn().mockResolvedValue({ success: true })
const service = new ScheduledReportService({
storageDir,
now: () => now,
getCapability: async () => capability,
generateReport: async () => {
generated += 1
return { success: true, pngPath: generatedPath }
},
send: async () => {
sent += 1
return { success: true, status: capability.senderStatus }
},
saveGeneratedReport: saveHistory,
isDatabaseReady: () => true
})
const created = await service.createTask({
name: '每日群报',
group: '研发群',
target: '研发群@chatroom',
scheduleTime: '09:00'
})
expect(created.success).toBe(true)
expect(created.data?.reportRange).toBe('yesterday')
const taskId = created.data!.id
const run = await service.runScheduledReportNow(taskId)
expect(run.data?.status).toBe('success')
expect(generated).toBe(1)
expect(sent).toBe(1)
expect(saveHistory).toHaveBeenCalledWith(
expect.objectContaining({
contactName: '研发群',
reportDate: undefined,
pngPath: generatedPath,
messageCount: 0
})
)
expect(await service.listExecutions(taskId)).toHaveLength(1)
const restored = new ScheduledReportService({
storageDir,
getCapability: async () => capability
})
expect((await restored.listTasks())[0].id).toBe(taskId)
await service.setTaskEnabled(taskId, false)
expect((await service.listTasks())[0].enabled).toBe(false)
await service.deleteTask(taskId)
expect(await service.listTasks()).toHaveLength(0)
expect(
JSON.parse(await readFile(join(storageDir, 'executions.json'), 'utf8'))[0].message
).toContain('微信发送成功')
})
it('passes a selected today range through a scheduled 20:00 execution', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-today-'))
let currentTime = new Date(2026, 7, 27, 19, 59, 0)
const generateReport = vi.fn(async () => ({
success: true,
groupName: '研发群',
pngPath: '/tmp/generated.png',
messageCount: 1
}))
const service = new ScheduledReportService({
storageDir,
now: () => currentTime,
getCapability: async () => capability,
generateReport,
saveGeneratedReport: async () => ({ success: true, record: reportRecord('/tmp/saved.png') }),
send: async () => ({ success: true, status: capability.senderStatus }),
isDatabaseReady: () => true
})
const created = await service.createTask({
name: '今日日报',
group: '研发群',
target: '研发群@chatroom',
scheduleTime: '20:00',
reportRange: 'today'
})
expect(created.success).toBe(true)
currentTime = new Date(2026, 7, 27, 20, 0, 0)
const execution = await runScheduled(service, created.data!.id)
expect(execution.status).toBe('success')
expect(generateReport).toHaveBeenCalledWith(
expect.objectContaining({ group: '研发群', range: 'today' })
)
})
it('does not send a generated report when history persistence fails', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-history-'))
const generatedPath = join(storageDir, 'report.png')
const send = vi.fn().mockResolvedValue({ success: true, status: capability.senderStatus })
const service = new ScheduledReportService({
storageDir,
getCapability: async () => capability,
generateReport: async () => ({ success: true, pngPath: generatedPath }),
saveGeneratedReport: vi.fn().mockResolvedValue({ success: false, error: '磁盘不可写' }),
send,
isDatabaseReady: () => true
})
const created = await service.createTask({
name: '历史失败日报',
group: '研发群',
target: '研发群@chatroom',
scheduleTime: '09:00'
})
const result = await service.runScheduledReportNow(created.data!.id)
expect(result.success).toBe(false)
expect(result.data?.error).toContain('report_history_save_failed:磁盘不可写')
expect(send).not.toHaveBeenCalled()
})
it('records manual failures without creating user notifications', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-manual-error-'))
const sendNotification = vi.fn()
const service = new ScheduledReportService({
storageDir,
...makeDependencies({
sendNotification,
generateReport: async () => ({
success: false,
error: 'maximum context length exceeded',
errorStage: 'ai' as const
})
})
})
const created = await service.createTask({
name: '手动失败日报',
group: '研发群',
target: '研发群@chatroom',
scheduleTime: '09:00'
})
const result = await service.runScheduledReportNow(created.data!.id)
expect(result.data).toMatchObject({ status: 'failed', triggerType: 'manual' })
expect(result.data?.notificationStatus).toBe('not_needed')
expect(sendNotification).not.toHaveBeenCalled()
expect(await service.listNotifications()).toHaveLength(0)
})
it('sends a debug scheduled failure notification without generating or sending a report', async () => {
const storageDir = await mkdtemp(
join(tmpdir(), 'tracememo-scheduled-report-debug-notification-')
)
await enableNotifications(storageDir)
const generateReport = vi.fn()
const send = vi.fn()
const sendNotification = vi.fn(async () => ({ success: true, status: 'sent' as const }))
const service = new ScheduledReportService({
storageDir,
...makeDependencies({ generateReport, send, sendNotification })
})
const created = await service.createTask({
name: '调试日报',
group: '研发群',
target: '研发群@chatroom',
scheduleTime: '09:00'
})
const result = await service.testScheduledReportErrorNotification(created.data!.id)
expect(result.success).toBe(true)
expect(result.data).toMatchObject({
taskId: created.data!.id,
triggerType: 'scheduled',
status: 'failed',
errorCode: 'DEBUG_TEST_NOTIFICATION',
notificationStatus: 'sent'
})
expect(result.data?.technicalMessage).toContain('没有调用 AI')
expect(generateReport).not.toHaveBeenCalled()
expect(send).not.toHaveBeenCalled()
expect(sendNotification).toHaveBeenCalledWith({
to: 'owner-wxid',
text: expect.stringContaining('定时日报错误通知测试')
})
expect(await service.listNotifications()).toHaveLength(1)
})
it('turns NO_MESSAGES into a neutral skipped execution without notification', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-no-messages-'))
await enableNotifications(storageDir)
const sendNotification = vi.fn(async () => ({ success: true, status: 'sent' as const }))
const service = new ScheduledReportService({
storageDir,
...makeDependencies({
sendNotification,
getAgentHubStatus: onlineAgentHubStatus,
generateReport: async () => ({
success: false,
error: '所选时间范围没有可总结的消息',
errorCode: 'NO_MESSAGES',
errorStage: 'data' as const
})
})
})
const created = await service.createTask({
name: '空消息日报',
group: '研发群',
target: '研发群@chatroom',
scheduleTime: '09:00'
})
const execution = await runScheduled(service, created.data!.id)
expect(execution).toMatchObject({
status: 'skipped',
errorCode: 'NO_MESSAGES',
userTitle: '暂无可生成的日报',
userMessage: '所选时间范围内没有可总结的聊天消息。',
notificationStatus: 'not_needed'
})
expect(sendNotification).not.toHaveBeenCalled()
expect(await service.listNotifications()).toHaveLength(0)
})
it('suppresses pending notifications when disabled and does not flush them after reopening', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-suppressed-'))
await enableNotifications(storageDir)
let hubStatus = onlineAgentHubStatus()
const sendNotification = vi.fn(async () => ({
success: false,
status: 'connector_offline' as const,
error: '连接器暂时不可用'
}))
const service = new ScheduledReportService({
storageDir,
...makeDependencies({
getAgentHubStatus: () => hubStatus,
sendNotification,
getCapability: async () => unavailableCapability
})
})
const created = await service.createTask({
name: '待关闭通知日报',
group: '研发群',
target: '研发群@chatroom',
scheduleTime: '09:00'
})
expect(await service.getNotificationSettings()).toEqual({ enabled: true })
const execution = await runScheduled(service, created.data!.id)
expect(execution.notificationStatus).toBe('pending')
expect(await service.listNotifications()).toEqual([
expect.objectContaining({ status: 'pending' })
])
await expect(service.setNotificationEnabled(false)).resolves.toMatchObject({
success: true,
data: { enabled: false }
})
expect(await service.listNotifications()).toEqual([
expect.objectContaining({ status: 'suppressed' })
])
hubStatus = onlineAgentHubStatus()
sendNotification.mockResolvedValue({ success: true, status: 'sent' })
await expect(service.setNotificationEnabled(true)).resolves.toMatchObject({
success: true,
data: { enabled: true }
})
expect(sendNotification).toHaveBeenCalledTimes(2)
expect((await service.listNotifications())[0].status).toBe('suppressed')
})
it('suppresses stale pending notifications when already disabled', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-stale-pending-'))
const notification: ScheduledReportNotification = {
id: 'notification-1',
executionId: 'execution-1',
taskId: 'task-1',
type: 'failure',
severity: 'error',
title: '日报发送失败',
message: '连接器暂时不可用',
dedupeKey: 'execution-1:failure',
channel: 'agent_hub',
recipient: 'owner-wxid',
status: 'pending',
createdAt: '2026-09-02T02:00:00.000Z',
attempts: 0
}
await writeFile(join(storageDir, 'notifications.json'), JSON.stringify([notification]))
const service = new ScheduledReportService({
storageDir,
now: () => new Date('2026-09-02T03:00:00.000Z')
})
await expect(service.setNotificationEnabled(false)).resolves.toEqual({
success: true,
data: { enabled: false }
})
expect(await service.listNotifications()).toEqual([
expect.objectContaining({
status: 'suppressed',
suppressedAt: '2026-09-02T03:00:00.000Z',
lastError: '定时日报微信异常通知已关闭。'
})
])
})
it('allows creating and executing a report when personal WeChat is unavailable', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-waiting-'))
await enableNotifications(storageDir)
const generateReport = vi.fn(async () => ({
success: true,
pngPath: '/tmp/generated.png',
groupName: '研发群'
}))
const saveHistory = vi.fn(async () => ({
success: true as const,
record: reportRecord('/tmp/saved.png')
}))
const send = vi.fn()
const service = new ScheduledReportService({
storageDir,
...makeDependencies({
getCapability: async () => unavailableCapability,
generateReport,
saveGeneratedReport: saveHistory,
send
})
})
const created = await service.createTask({
name: '未发送日报',
group: '研发群',
target: '研发群@chatroom',
scheduleTime: '09:00'
})
expect(created.success).toBe(true)
const execution = await runScheduled(service, created.data!.id)
expect(execution).toMatchObject({
status: 'waiting_to_send',
errorCode: 'WECHAT_SEND_UNAVAILABLE',
pngPath: '/tmp/saved.png',
reportId: 'report-1',
sendStatus: 'unavailable',
notificationStatus: 'sent'
})
expect(generateReport).toHaveBeenCalledOnce()
expect(saveHistory).toHaveBeenCalledOnce()
expect(send).not.toHaveBeenCalled()
expect(await service.listNotifications()).toHaveLength(1)
})
it('keeps notifications pending when no reliable recipient is available', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-no-recipient-'))
await enableNotifications(storageDir)
const sendNotification = vi.fn()
const service = new ScheduledReportService({
storageDir,
...makeDependencies({
getCapability: async () => unavailableCapability,
getNotificationRecipient: () => undefined,
sendNotification
})
})
const created = await service.createTask({
name: '待通知日报',
group: '研发群',
target: '研发群@chatroom',
scheduleTime: '09:00'
})
const execution = await runScheduled(service, created.data!.id)
const [notification] = await service.listNotifications()
expect(execution.notificationStatus).toBe('pending')
expect(notification).toMatchObject({ status: 'pending', attempts: 0 })
expect(notification.recipient).toBeUndefined()
expect(sendNotification).not.toHaveBeenCalled()
})
it('keeps notifications pending when Agent Hub delivery fails', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-hub-offline-'))
await enableNotifications(storageDir)
const sendNotification = vi.fn(async () => ({
success: false,
status: 'connector_offline' as const,
error: 'Agent Hub 连接器不可用'
}))
const service = new ScheduledReportService({
storageDir,
...makeDependencies({
getCapability: async () => unavailableCapability,
sendNotification
})
})
const created = await service.createTask({
name: '连接器异常日报',
group: '研发群',
target: '研发群@chatroom',
scheduleTime: '09:00'
})
const execution = await runScheduled(service, created.data!.id)
const [notification] = await service.listNotifications()
expect(execution.notificationStatus).toBe('pending')
expect(notification).toMatchObject({
status: 'pending',
attempts: 1,
lastError: 'Agent Hub 连接器不可用'
})
expect(sendNotification).toHaveBeenCalledOnce()
})
it('keeps the generated artifact when the image send attempt fails', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-partial-'))
const service = new ScheduledReportService({
storageDir,
...makeDependencies({
send: vi.fn(async () => ({
success: false,
status: capability.senderStatus,
error: '连接器响应超时'
}))
})
})
const created = await service.createTask({
name: '发送失败日报',
group: '研发群',
target: '研发群@chatroom',
scheduleTime: '09:00'
})
const result = await service.runScheduledReportNow(created.data!.id)
expect(result.success).toBe(true)
expect(result.data).toMatchObject({
status: 'partial_success',
errorCode: 'WECHAT_SEND_FAILED',
pngPath: '/tmp/saved.png',
sendStatus: 'failed',
sendTarget: '研发群@chatroom'
})
})
it('retries an existing PNG without generating the report again', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-retry-'))
await enableNotifications(storageDir)
const generateReport = vi.fn(async () => ({
success: true,
pngPath: '/tmp/generated.png',
groupName: '研发群'
}))
let sendCount = 0
const send = vi.fn(async (request: { filePath: string }) => {
sendCount += 1
if (sendCount === 1) {
return { success: false, status: capability.senderStatus, error: '连接器暂时不可用' }
}
expect(request.filePath).toBe('/tmp/saved.png')
return { success: true, status: capability.senderStatus }
})
const service = new ScheduledReportService({
storageDir,
...makeDependencies({ generateReport, send })
})
const created = await service.createTask({
name: '可恢复日报',
group: '研发群',
target: '研发群@chatroom',
scheduleTime: '09:00'
})
const first = { data: await runScheduled(service, created.data!.id) }
const recovered = await service.retryScheduledReportSend(first.data!.id)
expect(first.data?.status).toBe('partial_success')
expect(recovered).toMatchObject({
success: true,
data: { status: 'success', sendStatus: 'success', retryCount: 1 }
})
expect(recovered.data).not.toHaveProperty('error')
expect(recovered.data).not.toHaveProperty('errorCode')
expect(recovered.data).not.toHaveProperty('failedStage')
expect(recovered.data).not.toHaveProperty('technicalMessage')
expect(recovered.data).not.toHaveProperty('sendError')
expect(generateReport).toHaveBeenCalledOnce()
expect(send).toHaveBeenCalledTimes(2)
expect((await service.listNotifications()).map((item) => item.type)).toContain('recovery')
})
it('deduplicates repeated failure notifications for one execution', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-dedupe-'))
await enableNotifications(storageDir)
const send = vi.fn(async () => ({
success: false,
status: capability.senderStatus,
error: '连接器暂时不可用'
}))
const sendNotification = vi.fn(async () => ({ success: true, status: 'sent' as const }))
const service = new ScheduledReportService({
storageDir,
...makeDependencies({ send, sendNotification })
})
const created = await service.createTask({
name: '重复失败日报',
group: '研发群',
target: '研发群@chatroom',
scheduleTime: '09:00'
})
const first = { data: await runScheduled(service, created.data!.id) }
const retried = await service.retryScheduledReportSend(first.data!.id)
expect(retried.data?.status).toBe('partial_success')
expect(await service.listNotifications()).toHaveLength(1)
expect(sendNotification).toHaveBeenCalledOnce()
})
it('normalizes AI and unknown generation failures without a fake WeChat error', async () => {
const contextService = new ScheduledReportService({
storageDir: await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-context-')),
...makeDependencies({
generateReport: async () => ({
success: false,
error: 'maximum context length exceeded',
errorStage: 'ai' as const
})
})
})
const contextTask = await contextService.createTask({
name: '上下文日报',
group: '研发群',
target: '研发群@chatroom',
scheduleTime: '09:00'
})
const contextResult = await contextService.runScheduledReportNow(contextTask.data!.id)
expect(contextResult.data).toMatchObject({ errorCode: 'AI_CONTEXT_LIMIT', failedStage: 'ai' })
expect(contextResult.data?.error).not.toContain('wechat_not_ready')
const unknownService = new ScheduledReportService({
storageDir: await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-unknown-')),
...makeDependencies({
generateReport: async () => {
throw new Error('unexpected generation failure')
}
})
})
const unknownTask = await unknownService.createTask({
name: '未知错误日报',
group: '研发群',
target: '研发群@chatroom',
scheduleTime: '09:00'
})
const unknownResult = await unknownService.runScheduledReportNow(unknownTask.data!.id)
expect(unknownResult.data).toMatchObject({ errorCode: 'UNKNOWN', failedStage: 'report' })
expect(unknownResult.data?.error).not.toContain('wechat_not_ready')
})
it('keeps old execution records readable and claims one scheduled slot once', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-compat-'))
const oldExecution: ScheduledReportExecution = {
id: 'old-execution',
taskId: 'old-task',
startedAt: '2026-08-27T01:00:00.000Z',
finishedAt: '2026-08-27T01:01:00.000Z',
status: 'failed',
error: 'report_generation_failed:旧错误',
message: '旧错误'
}
await writeFile(join(storageDir, 'executions.json'), JSON.stringify([oldExecution]))
const generated = vi.fn(async () => ({ success: true, pngPath: '/tmp/generated.png' }))
const service = new ScheduledReportService({
storageDir,
now: () => new Date('2026-08-27T01:00:00.000Z'),
...makeDependencies({ generateReport: generated })
})
expect(await service.listExecutions()).toEqual([
expect.objectContaining({ triggerType: 'scheduled', retryCount: 0 })
])
const created = await service.createTask({
name: '幂等日报',
group: '研发群',
target: '研发群@chatroom',
scheduleTime: '09:00'
})
const dueAt = new Date(created.data!.nextRunAt)
await service.tick(new Date(dueAt.getTime() + 1_000))
await new Promise((resolve) => setTimeout(resolve, 50))
await service.tick(new Date(dueAt.getTime() + 2_000))
expect(generated).toHaveBeenCalledOnce()
expect(
(await service.listExecutions()).filter((item) => item.taskId === created.data!.id)
).toHaveLength(1)
})
})