Extract dashboard task routes (#74)
* review: harden readonly git checks and lint verification * test: satisfy readonly reviewer sandbox typing * test: fix readonly reviewer sandbox assertions * test: remove impossible readonly sandbox guards * test: relax readonly sandbox assertions * test: cast readonly sandbox expectation shape * test: cast readonly sandbox expectation through unknown * Phase 0 — STEP_DONE/TASK_DONE split + owner-follow-up integration smoke * Add STEP_DONE guards, verdict storage, and stale delivery suppression * style: format paired stepdone telemetry files * Route STEP_DONE through reviewer * Add structured Discord attachments * style: format structured attachment test * Fix room thread bot output parity * Remove duplicate work item attachment migration from owner branch * Remove legacy dashboard rooms renderer * Extract dashboard parsed body renderer * Extract dashboard redaction helpers * Extract dashboard RoomCardV2 component * Include RoomCardV2 test in vitest suite * Extract dashboard RoomBoardV2 component * Extract dashboard EmptyState component * Extract dashboard InboxPanel component * Split dashboard InboxPanel card renderer * Extract dashboard TaskPanel component * Extract dashboard UsagePanel component * Fix dashboard room thread chunk rendering * Extract dashboard ServicePanel component * Render dashboard live progress markdown * Extract dashboard SettingsPanel component * Fix structured attachment rendering * Extract dashboard simple route table * Extract dashboard settings routes * Extract dashboard service routes * Extract dashboard room timeline routes * Extract dashboard task routes
This commit is contained in:
254
src/web-dashboard-task-routes.test.ts
Normal file
254
src/web-dashboard-task-routes.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { RegisteredGroup, ScheduledTask } from './types.js';
|
||||
import {
|
||||
handleScheduledTaskRoute,
|
||||
type ScheduledTaskRouteDependencies,
|
||||
} from './web-dashboard-task-routes.js';
|
||||
|
||||
function jsonResponse(value: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
...init,
|
||||
headers: {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
...(init?.headers as Record<string, string> | undefined),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function request(
|
||||
pathname: string,
|
||||
method: string,
|
||||
body?: Record<string, unknown>,
|
||||
): Request {
|
||||
return new Request(`http://localhost${pathname}`, {
|
||||
method,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
headers:
|
||||
body === undefined ? undefined : { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function makeTask(overrides: Partial<ScheduledTask>): ScheduledTask {
|
||||
return {
|
||||
id: 'task-1',
|
||||
group_folder: 'ops-room',
|
||||
chat_jid: 'dc:ops',
|
||||
agent_type: null,
|
||||
status_message_id: null,
|
||||
status_started_at: null,
|
||||
prompt: 'regular scheduled task',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2026-04-26T05:20:00.000Z',
|
||||
context_mode: 'isolated',
|
||||
next_run: '2026-04-26T05:20:00.000Z',
|
||||
last_run: null,
|
||||
last_result: null,
|
||||
status: 'active',
|
||||
suspended_until: null,
|
||||
created_at: '2026-04-26T05:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
tasks: Map<string, ScheduledTask>,
|
||||
overrides: Partial<ScheduledTaskRouteDependencies> = {},
|
||||
): ScheduledTaskRouteDependencies {
|
||||
return {
|
||||
createScheduledTask: (task) => {
|
||||
tasks.set(task.id, {
|
||||
...task,
|
||||
agent_type: task.agent_type ?? null,
|
||||
ci_provider: null,
|
||||
ci_metadata: null,
|
||||
max_duration_ms: null,
|
||||
status_message_id: null,
|
||||
status_started_at: null,
|
||||
last_run: null,
|
||||
last_result: null,
|
||||
suspended_until: null,
|
||||
});
|
||||
},
|
||||
loadTaskById: (id) => tasks.get(id),
|
||||
mutateTask: (id, updates) => {
|
||||
const task = tasks.get(id);
|
||||
if (task) tasks.set(id, { ...task, ...updates });
|
||||
},
|
||||
removeTask: (id) => {
|
||||
tasks.delete(id);
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function route({
|
||||
body,
|
||||
deps,
|
||||
method,
|
||||
now,
|
||||
pathname,
|
||||
}: {
|
||||
body?: Record<string, unknown>;
|
||||
deps: ScheduledTaskRouteDependencies;
|
||||
method: string;
|
||||
now?: () => string;
|
||||
pathname: string;
|
||||
}): Promise<Response | null> {
|
||||
return handleScheduledTaskRoute({
|
||||
url: new URL(`http://localhost${pathname}`),
|
||||
request: request(pathname, method, body),
|
||||
jsonResponse,
|
||||
now,
|
||||
...deps,
|
||||
});
|
||||
}
|
||||
|
||||
describe('web dashboard scheduled task routes', () => {
|
||||
it('creates scheduled tasks with room resolution and duplicate request ids', async () => {
|
||||
const tasks = new Map<string, ScheduledTask>();
|
||||
const rooms: Record<string, RegisteredGroup> = {
|
||||
'dc:ops': {
|
||||
name: '#ops',
|
||||
folder: 'ops-room',
|
||||
added_at: '2026-04-26T05:00:00.000Z',
|
||||
agentType: 'codex',
|
||||
},
|
||||
};
|
||||
let nudges = 0;
|
||||
const deps = makeDeps(tasks, {
|
||||
loadRoomBindings: () => rooms,
|
||||
nudgeScheduler: () => {
|
||||
nudges += 1;
|
||||
},
|
||||
});
|
||||
|
||||
const create = await route({
|
||||
pathname: '/api/tasks',
|
||||
method: 'POST',
|
||||
body: {
|
||||
roomJid: 'dc:ops',
|
||||
prompt: ' run deployment audit ',
|
||||
requestId: 'task-create-1',
|
||||
scheduleType: 'once',
|
||||
scheduleValue: '2026-04-26T05:10:00.000Z',
|
||||
contextMode: 'group',
|
||||
},
|
||||
deps,
|
||||
now: () => '2026-04-26T05:10:00.000Z',
|
||||
});
|
||||
|
||||
expect(create?.status).toBe(200);
|
||||
await expect(create?.json()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
task: {
|
||||
id: 'web-task-task-create-1',
|
||||
groupFolder: 'ops-room',
|
||||
chatJid: 'dc:ops',
|
||||
agentType: 'codex',
|
||||
promptLength: 20,
|
||||
scheduleType: 'once',
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
expect(tasks.get('web-task-task-create-1')).toMatchObject({
|
||||
prompt: 'run deployment audit',
|
||||
next_run: '2026-04-26T05:10:00.000Z',
|
||||
});
|
||||
expect(nudges).toBe(1);
|
||||
|
||||
const duplicate = await route({
|
||||
pathname: '/api/tasks',
|
||||
method: 'POST',
|
||||
body: {
|
||||
roomJid: 'dc:ops',
|
||||
prompt: 'run deployment audit',
|
||||
requestId: 'task-create-1',
|
||||
scheduleType: 'once',
|
||||
scheduleValue: '2026-04-26T05:10:00.000Z',
|
||||
},
|
||||
deps,
|
||||
now: () => '2026-04-26T05:10:00.000Z',
|
||||
});
|
||||
expect(duplicate?.status).toBe(200);
|
||||
await expect(duplicate?.json()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
duplicate: true,
|
||||
task: { id: 'web-task-task-create-1' },
|
||||
});
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(nudges).toBe(1);
|
||||
});
|
||||
|
||||
it('handles task actions, edits, invalid watchers, and fall-through', async () => {
|
||||
const tasks = new Map<string, ScheduledTask>([
|
||||
['active-task', makeTask({ id: 'active-task' })],
|
||||
[
|
||||
'watch-task',
|
||||
makeTask({
|
||||
id: 'watch-task',
|
||||
prompt: '[BACKGROUND CI WATCH]\nwatch',
|
||||
}),
|
||||
],
|
||||
]);
|
||||
const deps = makeDeps(tasks);
|
||||
|
||||
const pause = await route({
|
||||
pathname: '/api/tasks/active-task/actions',
|
||||
method: 'POST',
|
||||
body: { action: 'pause' },
|
||||
deps,
|
||||
});
|
||||
expect(pause?.status).toBe(200);
|
||||
await expect(pause?.json()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
task: { id: 'active-task', status: 'paused' },
|
||||
});
|
||||
expect(tasks.get('active-task')).toMatchObject({
|
||||
status: 'paused',
|
||||
suspended_until: null,
|
||||
});
|
||||
|
||||
const edit = await route({
|
||||
pathname: '/api/tasks/active-task',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
prompt: 'watch every minute',
|
||||
scheduleType: 'interval',
|
||||
scheduleValue: '60000',
|
||||
},
|
||||
deps,
|
||||
now: () => '2026-04-26T05:10:00.000Z',
|
||||
});
|
||||
expect(edit?.status).toBe(200);
|
||||
expect(tasks.get('active-task')).toMatchObject({
|
||||
next_run: '2026-04-26T05:11:00.000Z',
|
||||
prompt: 'watch every minute',
|
||||
schedule_type: 'interval',
|
||||
schedule_value: '60000',
|
||||
suspended_until: null,
|
||||
});
|
||||
|
||||
const watcherEdit = await route({
|
||||
pathname: '/api/tasks/watch-task',
|
||||
method: 'PATCH',
|
||||
body: { prompt: 'run again' },
|
||||
deps,
|
||||
});
|
||||
expect(watcherEdit?.status).toBe(409);
|
||||
|
||||
const wrongMethod = await route({
|
||||
pathname: '/api/tasks/active-task/actions',
|
||||
method: 'GET',
|
||||
deps,
|
||||
});
|
||||
expect(wrongMethod?.status).toBe(405);
|
||||
|
||||
const unmatched = await route({
|
||||
pathname: '/api/overview',
|
||||
method: 'GET',
|
||||
deps,
|
||||
});
|
||||
expect(unmatched).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user