feat: rebuild warm operations workbench
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user