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