import Database from 'better-sqlite3'; import { describe, expect, it, vi } from 'vitest'; import { migrateDatabase } from '../../infrastructure/database/migrations.js'; import { ScheduledTaskRepository } from './scheduled-task-repository.js'; import { ScheduledOperationDispatcher } from './scheduled-operation-dispatcher.js'; describe('ScheduledOperationDispatcher', () => { it('executes restart targets independently and aggregates partial success', async () => { const db = new Database(':memory:'); migrateDatabase(db); const execute = vi .fn() .mockResolvedValueOnce({ id: 'job-a', status: 'succeeded' }) .mockResolvedValueOnce({ id: 'job-b', status: 'failed' }); const dispatcher = new ScheduledOperationDispatcher({ db, operations: { prepare: vi.fn(async (input) => ({ id: `prep-${input.targets[0]?.instanceId}`, confirmationToken: 'token', })), execute, }, messages: { send: vi.fn() }, store: { set: vi.fn(), get: vi.fn(), delete: vi.fn() }, repository: new ScheduledTaskRepository(db), }); const result = await dispatcher.dispatch( { id: 'task-1', name: 'Restart', operationType: 'restart-service', cronExpression: '0 9 * * *', timezone: 'Asia/Shanghai', targetSelector: { mode: 'fixed', instanceIds: ['a', 'b'] }, misfirePolicy: 'skip', overlapPolicy: 'skip', retryPolicy: { maxRetries: 0, intervalSeconds: 60 }, enabled: true, version: 1, createdBy: 'operator', updatedBy: 'operator', createdAt: '2026-07-30T00:00:00.000Z', updatedAt: '2026-07-30T00:00:00.000Z', }, [ { id: 'a', revision: 1 }, { id: 'b', revision: 2 }, ], { actor: 'operator', requestId: 'request-1' }, ); expect(result).toEqual({ outcome: 'partially-succeeded', jobIds: ['job-a', 'job-b'] }); expect(execute).toHaveBeenCalledTimes(2); db.close(); }); it('retries only failed SMS recipients using the task retry interval', async () => { const db = new Database(':memory:'); migrateDatabase(db); db.prepare( 'INSERT INTO instances (id,name,base_url,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?)', ).run( 'a', 'Alpha', 'http://10.0.0.1', 1, 1, '2026-07-30T00:00:00.000Z', '2026-07-30T00:00:00.000Z', ); const repository = new ScheduledTaskRepository(db); const task = repository.create({ id: 'task-sms', task: { name: 'Notify lab', operationType: 'send-sms', cronExpression: '0 9 * * *', timezone: 'Asia/Shanghai', targetSelector: { mode: 'fixed', instanceIds: ['a'] }, sms: { recipients: ['13800138000', '13900139000'], content: 'Maintenance' }, misfirePolicy: 'skip', overlapPolicy: 'skip', retryPolicy: { maxRetries: 1, intervalSeconds: 30 }, enabled: true, }, smsSecretReference: 'memory://sms', createdBy: 'operator', now: '2026-07-30T00:00:00.000Z', }); const attempts = new Map(); const send = vi.fn(async (_instanceId: string, input: { phoneNumber: string }) => { const count = (attempts.get(input.phoneNumber) ?? 0) + 1; attempts.set(input.phoneNumber, count); if (input.phoneNumber === '13800138000' && count === 1) throw new Error('temporary'); return { sent: true as const }; }); const sleep = vi.fn(async () => undefined); const dispatcher = new ScheduledOperationDispatcher({ db, operations: { prepare: vi.fn(), execute: vi.fn() }, messages: { send }, store: { set: vi.fn(), get: vi.fn(async () => JSON.stringify({ recipients: ['13800138000', '13900139000'], content: 'Maintenance' }), ), delete: vi.fn(), }, repository, sleep, }); const result = await dispatcher.dispatch(task, [{ id: 'a', revision: 1 }], { actor: 'operator', requestId: 'request-1', }); expect(result.outcome).toBe('succeeded'); expect(send).toHaveBeenCalledTimes(3); expect(sleep).toHaveBeenCalledTimes(1); expect(sleep).toHaveBeenCalledWith(30_000); db.close(); }); it('dispatches a baseband task against the baseband restart operation', async () => { const db = new Database(':memory:'); migrateDatabase(db); const prepare = vi.fn(async (input: { operationId: string; parameters: unknown }) => ({ id: 'prep-a', confirmationToken: 'token', ...input, })); const dispatcher = new ScheduledOperationDispatcher({ db, operations: { prepare, execute: vi.fn().mockResolvedValue({ id: 'job-a', status: 'succeeded' }), }, messages: { send: vi.fn() }, store: { set: vi.fn(), get: vi.fn(), delete: vi.fn() }, repository: new ScheduledTaskRepository(db), }); const result = await dispatcher.dispatch( { id: 'task-baseband', name: 'Baseband', operationType: 'restart-baseband', cronExpression: '0 4 * * *', timezone: 'Asia/Shanghai', targetSelector: { mode: 'fixed', instanceIds: ['a'] }, misfirePolicy: 'skip', overlapPolicy: 'skip', retryPolicy: { maxRetries: 0, intervalSeconds: 60 }, enabled: true, version: 1, createdBy: 'operator', updatedBy: 'operator', createdAt: '2026-07-30T00:00:00.000Z', updatedAt: '2026-07-30T00:00:00.000Z', }, [{ id: 'a', revision: 1 }], { actor: 'operator', requestId: 'request-1' }, ); expect(result.outcome).toBe('succeeded'); expect(prepare.mock.calls[0]?.[0]).toMatchObject({ operationId: 'postBasebandRestart', parameters: { parameterSchemaId: 'simadmin.58e2204.postBasebandRestart.parameters.v1' }, }); db.close(); }); });