mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
799 lines
29 KiB
TypeScript
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)
|
|
})
|
|
})
|