126 lines
4.3 KiB
TypeScript
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();
|
|
});
|
|
});
|