Extract dashboard inbox action route
This commit is contained in:
253
src/web-dashboard-inbox-routes.test.ts
Normal file
253
src/web-dashboard-inbox-routes.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { NewMessage, PairedTask } from './types.js';
|
||||
import {
|
||||
createInboxDismissTracker,
|
||||
handleInboxActionRoute,
|
||||
type InboxActionRouteDependencies,
|
||||
} from './web-dashboard-inbox-routes.js';
|
||||
|
||||
function makePairedTask(overrides: Partial<PairedTask>): PairedTask {
|
||||
return {
|
||||
id: 'paired-1',
|
||||
chat_jid: 'dc:general',
|
||||
group_folder: 'general',
|
||||
owner_service_id: 'codex-main',
|
||||
reviewer_service_id: 'claude-reviewer',
|
||||
owner_agent_type: 'codex',
|
||||
reviewer_agent_type: 'claude-code',
|
||||
arbiter_agent_type: 'codex',
|
||||
title: 'Dashboard PR',
|
||||
source_ref: null,
|
||||
plan_notes: null,
|
||||
review_requested_at: null,
|
||||
round_trip_count: 0,
|
||||
owner_failure_count: 0,
|
||||
owner_step_done_streak: 0,
|
||||
finalize_step_done_count: 0,
|
||||
task_done_then_user_reopen_count: 0,
|
||||
empty_step_done_streak: 0,
|
||||
status: 'review_ready',
|
||||
arbiter_verdict: null,
|
||||
arbiter_requested_at: null,
|
||||
completion_reason: null,
|
||||
created_at: '2026-04-26T04:00:00.000Z',
|
||||
updated_at: '2026-04-26T04:30:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
...init,
|
||||
headers: { 'content-type': 'application/json', ...init?.headers },
|
||||
});
|
||||
}
|
||||
|
||||
function inboxRequest(
|
||||
inboxId: string,
|
||||
body: unknown,
|
||||
method = 'POST',
|
||||
): Request {
|
||||
return new Request(
|
||||
`http://localhost/api/inbox/${encodeURIComponent(inboxId)}/actions`,
|
||||
{
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: method === 'POST' ? JSON.stringify(body) : undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
task: PairedTask | undefined,
|
||||
overrides: Partial<InboxActionRouteDependencies> = {},
|
||||
): InboxActionRouteDependencies {
|
||||
return {
|
||||
dismissTracker: createInboxDismissTracker(),
|
||||
enqueueMessageCheck: () => undefined,
|
||||
loadPairedTaskById: (id) => (id === task?.id ? task : undefined),
|
||||
messageExists: () => false,
|
||||
mutatePairedTaskIfUnchanged: () => true,
|
||||
schedulePairedFollowUp: () => true,
|
||||
writeChatMetadata: () => undefined,
|
||||
writeMessage: () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('web dashboard inbox action routes', () => {
|
||||
it('dismisses inbox items and queues paired follow-up intents', async () => {
|
||||
const dismissTracker = createInboxDismissTracker();
|
||||
const task = makePairedTask({ id: 'merge-1', status: 'merge_ready' });
|
||||
const scheduled: Array<{ taskId: string; intentKind: string }> = [];
|
||||
const queued: Array<{ chatJid: string; groupFolder: string }> = [];
|
||||
const deps = makeDeps(task, {
|
||||
dismissTracker,
|
||||
enqueueMessageCheck: (chatJid, groupFolder) => {
|
||||
queued.push({ chatJid, groupFolder });
|
||||
},
|
||||
schedulePairedFollowUp: (args) => {
|
||||
scheduled.push({
|
||||
taskId: args.task.id,
|
||||
intentKind: args.intentKind,
|
||||
});
|
||||
args.enqueue();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const fallThrough = await handleInboxActionRoute({
|
||||
...deps,
|
||||
jsonResponse,
|
||||
request: new Request('http://localhost/api/overview'),
|
||||
url: new URL('http://localhost/api/overview'),
|
||||
});
|
||||
expect(fallThrough).toBeNull();
|
||||
|
||||
const dismiss = await handleInboxActionRoute({
|
||||
...deps,
|
||||
jsonResponse,
|
||||
request: inboxRequest('ci:task-1', {
|
||||
action: 'dismiss',
|
||||
lastOccurredAt: '2026-04-26T05:00:00.000Z',
|
||||
}),
|
||||
url: new URL('http://localhost/api/inbox/ci%3Atask-1/actions'),
|
||||
});
|
||||
expect(dismiss?.status).toBe(200);
|
||||
expect(
|
||||
dismissTracker.isDismissed({
|
||||
id: 'ci:task-1',
|
||||
lastOccurredAt: '2026-04-26T05:00:00.000Z',
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
const run = await handleInboxActionRoute({
|
||||
...deps,
|
||||
jsonResponse,
|
||||
request: inboxRequest('paired:merge-1:merge_ready', { action: 'run' }),
|
||||
url: new URL(
|
||||
'http://localhost/api/inbox/paired%3Amerge-1%3Amerge_ready/actions',
|
||||
),
|
||||
});
|
||||
expect(run?.status).toBe(200);
|
||||
await expect(run?.json()).resolves.toMatchObject({
|
||||
intentKind: 'finalize-owner-turn',
|
||||
ok: true,
|
||||
queued: true,
|
||||
taskId: 'merge-1',
|
||||
});
|
||||
expect(scheduled).toEqual([
|
||||
{ taskId: 'merge-1', intentKind: 'finalize-owner-turn' },
|
||||
]);
|
||||
expect(queued).toEqual([{ chatJid: 'dc:general', groupFolder: 'general' }]);
|
||||
});
|
||||
|
||||
it('declines paired tasks and rejects invalid inbox actions', async () => {
|
||||
const task = makePairedTask({
|
||||
id: 'arbiter-1',
|
||||
status: 'arbiter_requested',
|
||||
arbiter_requested_at: '2026-04-26T05:00:00.000Z',
|
||||
});
|
||||
const messages: NewMessage[] = [];
|
||||
const updates: unknown[] = [];
|
||||
const deps = makeDeps(task, {
|
||||
enqueueMessageCheck: () => undefined,
|
||||
messageExists: (chatJid, id) =>
|
||||
messages.some(
|
||||
(message) => message.chat_jid === chatJid && message.id === id,
|
||||
),
|
||||
mutatePairedTaskIfUnchanged: (_id, _expectedUpdatedAt, update) => {
|
||||
updates.push(update);
|
||||
return true;
|
||||
},
|
||||
writeMessage: (message) => {
|
||||
messages.push(message);
|
||||
},
|
||||
});
|
||||
|
||||
const decline = () =>
|
||||
handleInboxActionRoute({
|
||||
...deps,
|
||||
jsonResponse,
|
||||
now: () => '2026-04-26T05:15:00.000Z',
|
||||
request: inboxRequest('paired:arbiter-1:arbiter_requested', {
|
||||
action: 'decline',
|
||||
requestId: 'decline 1',
|
||||
}),
|
||||
url: new URL(
|
||||
'http://localhost/api/inbox/paired%3Aarbiter-1%3Aarbiter_requested/actions',
|
||||
),
|
||||
});
|
||||
|
||||
const response = await decline();
|
||||
expect(response?.status).toBe(200);
|
||||
await expect(response?.json()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
queued: true,
|
||||
status: 'active',
|
||||
taskId: 'arbiter-1',
|
||||
});
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0]).toMatchObject({
|
||||
arbiter_requested_at: null,
|
||||
status: 'active',
|
||||
updated_at: '2026-04-26T05:15:00.000Z',
|
||||
});
|
||||
expect(messages[0]).toMatchObject({
|
||||
id: 'web-inbox-decline-1',
|
||||
content:
|
||||
'Dashboard declined arbiter escalation. Continue with the owner turn.',
|
||||
message_source_kind: 'ipc_injected_human',
|
||||
});
|
||||
|
||||
const duplicate = await decline();
|
||||
expect(duplicate?.status).toBe(200);
|
||||
await expect(duplicate?.json()).resolves.toMatchObject({
|
||||
duplicate: true,
|
||||
queued: false,
|
||||
});
|
||||
expect(messages).toHaveLength(1);
|
||||
|
||||
const invalidBody = await handleInboxActionRoute({
|
||||
...deps,
|
||||
jsonResponse,
|
||||
request: inboxRequest('paired:arbiter-1:active', { action: 'approve' }),
|
||||
url: new URL(
|
||||
'http://localhost/api/inbox/paired%3Aarbiter-1%3Aactive/actions',
|
||||
),
|
||||
});
|
||||
expect(invalidBody?.status).toBe(400);
|
||||
|
||||
const stale = await handleInboxActionRoute({
|
||||
...deps,
|
||||
jsonResponse,
|
||||
request: inboxRequest('paired:arbiter-1:merge_ready', { action: 'run' }),
|
||||
url: new URL(
|
||||
'http://localhost/api/inbox/paired%3Aarbiter-1%3Amerge_ready/actions',
|
||||
),
|
||||
});
|
||||
expect(stale?.status).toBe(409);
|
||||
|
||||
const notWired = await handleInboxActionRoute({
|
||||
...deps,
|
||||
enqueueMessageCheck: undefined,
|
||||
jsonResponse,
|
||||
request: inboxRequest('paired:arbiter-1:active', { action: 'run' }),
|
||||
url: new URL(
|
||||
'http://localhost/api/inbox/paired%3Aarbiter-1%3Aactive/actions',
|
||||
),
|
||||
});
|
||||
expect(notWired?.status).toBe(503);
|
||||
|
||||
const wrongMethod = await handleInboxActionRoute({
|
||||
...deps,
|
||||
jsonResponse,
|
||||
request: inboxRequest('paired:arbiter-1:active', null, 'GET'),
|
||||
url: new URL(
|
||||
'http://localhost/api/inbox/paired%3Aarbiter-1%3Aactive/actions',
|
||||
),
|
||||
});
|
||||
expect(wrongMethod?.status).toBe(405);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user