feat: rebuild warm operations workbench
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildApp } from '../../app.js';
|
||||
import { ScheduledTaskRepository } from '../../application/automation/scheduled-task-repository.js';
|
||||
import { ScheduledTaskService } from '../../application/automation/scheduled-task-service.js';
|
||||
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
|
||||
import type { SecretKey, SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||
import { registerAutomationRoutes } from './automation-routes.js';
|
||||
|
||||
class MemorySecrets implements SecretStore {
|
||||
readonly values = new Map<string, string>();
|
||||
async set(key: SecretKey, value: string): Promise<string> {
|
||||
const reference = `memory://${key.instanceId}/${key.purpose}`;
|
||||
this.values.set(reference, value);
|
||||
return reference;
|
||||
}
|
||||
async get(reference: string) {
|
||||
return this.values.get(reference);
|
||||
}
|
||||
async delete(reference: string) {
|
||||
return this.values.delete(reference);
|
||||
}
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
migrateDatabase(db);
|
||||
const repository = new ScheduledTaskRepository(db);
|
||||
let id = 0;
|
||||
const service = new ScheduledTaskService({
|
||||
repository,
|
||||
store: new MemorySecrets(),
|
||||
now: () => new Date('2026-07-30T00:00:00.000Z'),
|
||||
id: () => `task-${++id}`,
|
||||
});
|
||||
const app = buildApp({
|
||||
registerRoutes: (scope) => registerAutomationRoutes(scope, { service, repository }),
|
||||
});
|
||||
return { app, db };
|
||||
}
|
||||
|
||||
const restart = {
|
||||
name: 'Morning restart',
|
||||
operationType: 'restart-service',
|
||||
cronExpression: '0 9 * * *',
|
||||
targetSelector: { mode: 'fixed', instanceIds: ['instance-a'] },
|
||||
};
|
||||
|
||||
describe('automation HTTP routes', () => {
|
||||
it('previews Beijing-time Cron and creates a redacted schedule', async () => {
|
||||
const { app, db } = fixture();
|
||||
const preview = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/automation/cron/preview',
|
||||
payload: { cronExpression: '0 9 * * *', count: 2 },
|
||||
});
|
||||
expect(preview.statusCode).toBe(200);
|
||||
expect(preview.json()).toEqual({
|
||||
timezone: 'Asia/Shanghai',
|
||||
occurrences: ['2026-07-30T01:00:00.000Z', '2026-07-31T01:00:00.000Z'],
|
||||
});
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/automation/schedules',
|
||||
payload: restart,
|
||||
});
|
||||
expect(created.statusCode).toBe(201);
|
||||
expect(created.headers.etag).toBe('"version-1"');
|
||||
expect(created.json()).toMatchObject({ id: 'task-1', timezone: 'Asia/Shanghai' });
|
||||
expect(created.body).not.toMatch(/confirmationToken|smsSecretReference/i);
|
||||
await app.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('requires optimistic version headers for state changes and preserves run history on delete', async () => {
|
||||
const { app, db } = fixture();
|
||||
await app.inject({ method: 'POST', url: '/api/v1/automation/schedules', payload: restart });
|
||||
const missing = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/automation/schedules/task-1/state',
|
||||
payload: { enabled: false },
|
||||
});
|
||||
expect(missing.statusCode).toBe(428);
|
||||
const paused = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/automation/schedules/task-1/state',
|
||||
headers: { 'if-match': '"version-1"' },
|
||||
payload: { enabled: false },
|
||||
});
|
||||
expect(paused.statusCode).toBe(200);
|
||||
expect(paused.json()).toMatchObject({ enabled: false, version: 2 });
|
||||
const removed = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/automation/schedules/task-1',
|
||||
headers: { 'if-match': '"version-2"' },
|
||||
});
|
||||
expect(removed.statusCode).toBe(204);
|
||||
expect(
|
||||
(await app.inject({ method: 'GET', url: '/api/v1/automation/schedules' })).json(),
|
||||
).toMatchObject({ items: [] });
|
||||
await app.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('rejects alternate timezone and unknown preview keys with sanitized problems', async () => {
|
||||
const { app, db } = fixture();
|
||||
const invalid = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/automation/schedules',
|
||||
payload: { ...restart, timezone: 'UTC' },
|
||||
});
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
expect(invalid.json()).toMatchObject({ code: 'AUTOMATION_VALIDATION_FAILED' });
|
||||
expect(invalid.body).not.toContain('stack');
|
||||
await app.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('updates and duplicates schedules with optimistic version checks', async () => {
|
||||
const { app, db } = fixture();
|
||||
await app.inject({ method: 'POST', url: '/api/v1/automation/schedules', payload: restart });
|
||||
|
||||
const missingVersion = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/automation/schedules/task-1',
|
||||
payload: { name: 'Updated restart' },
|
||||
});
|
||||
expect(missingVersion.statusCode).toBe(428);
|
||||
|
||||
const updated = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/automation/schedules/task-1',
|
||||
headers: { 'if-match': '"version-1"' },
|
||||
payload: { name: 'Updated restart', effectiveEndAt: '2026-08-30T00:00:00.000Z' },
|
||||
});
|
||||
expect(updated.statusCode).toBe(200);
|
||||
expect(updated.headers.etag).toBe('"version-2"');
|
||||
expect(updated.json()).toMatchObject({ name: 'Updated restart', version: 2 });
|
||||
|
||||
const duplicated = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/automation/schedules/task-1/duplicate',
|
||||
headers: { 'if-match': '"version-2"' },
|
||||
});
|
||||
expect(duplicated.statusCode).toBe(201);
|
||||
expect(duplicated.json()).toMatchObject({
|
||||
id: 'task-2',
|
||||
name: 'Updated restart copy',
|
||||
enabled: false,
|
||||
});
|
||||
await app.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('bounds and validates schedule pagination', async () => {
|
||||
const { app, db } = fixture();
|
||||
for (const name of ['One', 'Two', 'Three'])
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/automation/schedules',
|
||||
payload: { ...restart, name },
|
||||
});
|
||||
|
||||
const page = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/automation/schedules?page=2&pageSize=1',
|
||||
});
|
||||
expect(page.statusCode).toBe(200);
|
||||
expect(page.json()).toMatchObject({
|
||||
items: [{ id: 'task-2' }],
|
||||
page: { page: 2, pageSize: 1, total: 3 },
|
||||
});
|
||||
const invalid = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/automation/schedules?pageSize=101',
|
||||
});
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
expect(invalid.json()).toMatchObject({ code: 'AUTOMATION_VALIDATION_FAILED' });
|
||||
await app.close();
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { AUTOMATION_TIMEZONE } from '@multi-simadmin/contracts';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import { ScheduledTaskRepository } from '../../application/automation/scheduled-task-repository.js';
|
||||
import { ScheduledTaskService } from '../../application/automation/scheduled-task-service.js';
|
||||
|
||||
export interface AutomationRoutesOptions {
|
||||
readonly service: ScheduledTaskService;
|
||||
readonly repository: ScheduledTaskRepository;
|
||||
readonly runNow?: (taskId: string, actor: string, requestId: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
function problem(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
status: number,
|
||||
code: string,
|
||||
detail: string,
|
||||
) {
|
||||
return reply
|
||||
.code(status)
|
||||
.type('application/problem+json')
|
||||
.send({
|
||||
type: 'about:blank',
|
||||
title: status === 404 ? 'Not Found' : status === 409 ? 'Conflict' : 'Bad Request',
|
||||
status,
|
||||
code,
|
||||
detail,
|
||||
requestId: request.id,
|
||||
});
|
||||
}
|
||||
|
||||
function version(request: FastifyRequest, reply: FastifyReply): number | undefined {
|
||||
const value = request.headers['if-match'];
|
||||
const match = typeof value === 'string' ? /^"version-([1-9]\d*)"$/.exec(value) : null;
|
||||
if (!match) {
|
||||
problem(request, reply, 428, 'VERSION_REQUIRED', 'A current schedule version is required.');
|
||||
return undefined;
|
||||
}
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
||||
throw new TypeError('Request body must be an object');
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function pagination(
|
||||
value: unknown,
|
||||
extraKeys: readonly string[] = [],
|
||||
): { readonly page: number; readonly pageSize: number; readonly query: Record<string, unknown> } {
|
||||
const query = object(value);
|
||||
const allowed = new Set(['page', 'pageSize', ...extraKeys]);
|
||||
if (Object.keys(query).some((key) => !allowed.has(key)))
|
||||
throw new TypeError('Unknown automation list query');
|
||||
const integer = (key: 'page' | 'pageSize', fallback: number, maximum: number): number => {
|
||||
const raw = query[key];
|
||||
if (raw === undefined) return fallback;
|
||||
if (typeof raw !== 'string' || !/^[1-9]\d*$/.test(raw))
|
||||
throw new TypeError(`${key} is invalid`);
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isSafeInteger(parsed) || parsed > maximum) throw new TypeError(`${key} is invalid`);
|
||||
return parsed;
|
||||
};
|
||||
const pageSize = integer('pageSize', 25, 100);
|
||||
const page = integer('page', 1, Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1);
|
||||
return { page, pageSize, query };
|
||||
}
|
||||
|
||||
function pageItems<T>(items: readonly T[], page: number, pageSize: number) {
|
||||
const offset = (page - 1) * pageSize;
|
||||
return {
|
||||
items: items.slice(offset, offset + pageSize),
|
||||
page: { page, pageSize, total: items.length },
|
||||
};
|
||||
}
|
||||
|
||||
async function action(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
operation: () => unknown | Promise<unknown>,
|
||||
) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError)
|
||||
return problem(
|
||||
request,
|
||||
reply,
|
||||
400,
|
||||
'AUTOMATION_VALIDATION_FAILED',
|
||||
'The automation request is invalid.',
|
||||
);
|
||||
if (error instanceof Error && /not found|version changed/i.test(error.message))
|
||||
return problem(
|
||||
request,
|
||||
reply,
|
||||
409,
|
||||
'SCHEDULE_VERSION_CONFLICT',
|
||||
'The schedule changed or no longer exists.',
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const etag = (version: number) => `"version-${version}"`;
|
||||
|
||||
export function registerAutomationRoutes(
|
||||
app: FastifyInstance,
|
||||
options: AutomationRoutesOptions,
|
||||
): void {
|
||||
app.post('/api/v1/automation/cron/preview', async (request, reply) =>
|
||||
action(request, reply, () => {
|
||||
const body = object(request.body);
|
||||
if (Object.keys(body).some((key) => key !== 'cronExpression' && key !== 'count'))
|
||||
throw new TypeError('Unknown preview field');
|
||||
if (typeof body.cronExpression !== 'string')
|
||||
throw new TypeError('cronExpression is required');
|
||||
const count = body.count === undefined ? 5 : body.count;
|
||||
if (!Number.isSafeInteger(count)) throw new TypeError('count is invalid');
|
||||
return {
|
||||
timezone: AUTOMATION_TIMEZONE,
|
||||
occurrences: options.service.preview(body.cronExpression, count as number),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
app.get('/api/v1/automation/schedules', async (request, reply) =>
|
||||
action(request, reply, () => {
|
||||
const { page, pageSize } = pagination(request.query);
|
||||
return pageItems(options.service.list(), page, pageSize);
|
||||
}),
|
||||
);
|
||||
|
||||
app.get('/api/v1/automation/schedules/:taskId', async (request, reply) => {
|
||||
const task = options.service.get((request.params as { taskId: string }).taskId);
|
||||
if (!task)
|
||||
return problem(request, reply, 404, 'SCHEDULE_NOT_FOUND', 'The schedule does not exist.');
|
||||
return reply.header('ETag', etag(task.version)).send(task);
|
||||
});
|
||||
|
||||
app.post('/api/v1/automation/schedules', async (request, reply) =>
|
||||
action(request, reply, async () => {
|
||||
const task = await options.service.create('console-operator', request.body);
|
||||
return reply.code(201).header('ETag', etag(task.version)).send(task);
|
||||
}),
|
||||
);
|
||||
|
||||
app.patch('/api/v1/automation/schedules/:taskId', async (request, reply) => {
|
||||
const currentVersion = version(request, reply);
|
||||
if (currentVersion === undefined) return reply;
|
||||
return action(request, reply, async () => {
|
||||
const task = await options.service.update(
|
||||
'console-operator',
|
||||
(request.params as { taskId: string }).taskId,
|
||||
currentVersion,
|
||||
request.body,
|
||||
);
|
||||
return reply.header('ETag', etag(task.version)).send(task);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/v1/automation/schedules/:taskId/duplicate', async (request, reply) => {
|
||||
const currentVersion = version(request, reply);
|
||||
if (currentVersion === undefined) return reply;
|
||||
return action(request, reply, async () => {
|
||||
const task = await options.service.duplicate(
|
||||
'console-operator',
|
||||
(request.params as { taskId: string }).taskId,
|
||||
currentVersion,
|
||||
);
|
||||
return reply.code(201).header('ETag', etag(task.version)).send(task);
|
||||
});
|
||||
});
|
||||
|
||||
app.patch('/api/v1/automation/schedules/:taskId/state', async (request, reply) => {
|
||||
const currentVersion = version(request, reply);
|
||||
if (currentVersion === undefined) return reply;
|
||||
return action(request, reply, () => {
|
||||
const body = object(request.body);
|
||||
if (Object.keys(body).length !== 1 || typeof body.enabled !== 'boolean')
|
||||
throw new TypeError('enabled is required');
|
||||
const task = options.service.setEnabled(
|
||||
'console-operator',
|
||||
(request.params as { taskId: string }).taskId,
|
||||
currentVersion,
|
||||
body.enabled,
|
||||
);
|
||||
return reply.header('ETag', etag(task.version)).send(task);
|
||||
});
|
||||
});
|
||||
|
||||
app.delete('/api/v1/automation/schedules/:taskId', async (request, reply) => {
|
||||
const currentVersion = version(request, reply);
|
||||
if (currentVersion === undefined) return reply;
|
||||
return action(request, reply, async () => {
|
||||
await options.service.remove(
|
||||
'console-operator',
|
||||
(request.params as { taskId: string }).taskId,
|
||||
currentVersion,
|
||||
);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/v1/automation/runs', async (request, reply) =>
|
||||
action(request, reply, () => {
|
||||
const { page, pageSize, query } = pagination(request.query, ['scheduledTaskId']);
|
||||
const scheduledTaskId =
|
||||
typeof query.scheduledTaskId === 'string' && query.scheduledTaskId
|
||||
? query.scheduledTaskId
|
||||
: undefined;
|
||||
if (query.scheduledTaskId !== undefined && !scheduledTaskId)
|
||||
throw new TypeError('scheduledTaskId is invalid');
|
||||
return pageItems(options.repository.listRuns(scheduledTaskId), page, pageSize);
|
||||
}),
|
||||
);
|
||||
|
||||
app.post('/api/v1/automation/schedules/:taskId/run', async (request, reply) => {
|
||||
const runNow = options.runNow;
|
||||
if (!runNow)
|
||||
return problem(
|
||||
request,
|
||||
reply,
|
||||
409,
|
||||
'SCHEDULER_UNAVAILABLE',
|
||||
'The scheduler is not available.',
|
||||
);
|
||||
return action(request, reply, async () => {
|
||||
const run = await runNow(
|
||||
(request.params as { taskId: string }).taskId,
|
||||
'console-operator',
|
||||
request.id,
|
||||
);
|
||||
return reply.code(202).send(run);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -400,7 +400,7 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
|
||||
required: ['phoneNumber', 'content'],
|
||||
properties: {
|
||||
phoneNumber: { type: 'string', minLength: 3, maxLength: 32 },
|
||||
content: { type: 'string', minLength: 1, maxLength: 1600 },
|
||||
content: { type: 'string', minLength: 1, maxLength: 2000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user