Files
multi-simadmin/apps/api/src/application/automation/scheduled-operation-dispatcher.test.ts
T

126 lines
4.3 KiB
TypeScript

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<string, number>();
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();
});
});