Files
multi-simadmin/apps/api/src/application/automation/scheduled-operation-dispatcher.test.ts
T
chick f9186bd851 feat(api): absorb the Hub control plane into the local instance model
Add central notification channels, rules, queue and delivery logs, fleet
organization groups and tags, device discovery, the device action catalog,
instance module reads, the log centre, connection settings and system
maintenance as native /api/v1 routes backed by the existing secret store,
audit trail and pinned upstream transport.
2026-09-05 18:53:04 +08:00

173 lines
5.9 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();
});
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();
});
});