From 7eff25129f438bea6d754f739820b9f4bae2b189 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Thu, 25 Jun 2026 12:40:11 +0200 Subject: [PATCH] fix(server): 404/400 error handling and consistent delegate behavior; add server route tests --- src/cli/remoteClient.ts | 21 ++++++++---- src/server/routes.ts | 71 ++++++++++++++++++++++++++++++++--------- tests/server.test.ts | 63 ++++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 21 deletions(-) create mode 100644 tests/server.test.ts diff --git a/src/cli/remoteClient.ts b/src/cli/remoteClient.ts index e39720c..90f3434 100644 --- a/src/cli/remoteClient.ts +++ b/src/cli/remoteClient.ts @@ -9,17 +9,26 @@ export class RemoteError extends Error { async function request(baseUrl: string, method: string, path: string, body?: unknown): Promise { const url = `${baseUrl}${path}`; - const res = await fetch(url, { - method, - headers: { 'Content-Type': 'application/json' }, - body: body ? JSON.stringify(body) : undefined, - }); + let res: Response; + try { + res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new RemoteError(0, `Cannot reach AgentHub server at ${baseUrl}: ${message}`); + } const text = await res.text(); if (!res.ok) { throw new RemoteError(res.status, text || `HTTP ${res.status}`); } - return text ? (JSON.parse(text) as T) : (undefined as T); + if (!text) { + throw new RemoteError(res.status, 'Empty response from server'); + } + return JSON.parse(text) as T; } export const remoteClient = { diff --git a/src/server/routes.ts b/src/server/routes.ts index 8acdec0..8fad2fa 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -1,12 +1,21 @@ -import { FastifyInstance } from 'fastify'; +import { FastifyInstance, FastifyReply } from 'fastify'; import { createTask, listTasks, getTask, claimTask, doneTask } from '../core/services/taskService.js'; import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js'; import { createDecision, listDecisions } from '../core/services/decisionService.js'; import { addMemory, searchMemory, listMemory } from '../core/services/memoryService.js'; import { getStatus, updateStatus } from '../core/services/statusService.js'; import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js'; +import { loadConfig } from '../core/config.js'; import type { Task, Handoff, Decision, Memory } from '../core/schema.js'; +function notFound(reply: FastifyReply, resource: string) { + return reply.status(404).send({ error: `${resource} not found` }); +} + +function badRequest(reply: FastifyReply, message: string) { + return reply.status(400).send({ error: message }); +} + export async function registerRoutes(app: FastifyInstance, cwd: string): Promise { app.get('/status', async () => ({ body: getStatus(cwd) })); app.post('/status/update', async () => ({ body: updateStatus(cwd) })); @@ -16,17 +25,25 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise return listTasks(cwd, { status, role }); }); - app.post('/tasks', async (request) => { - return createTask(cwd, request.body as Partial); + app.post('/tasks', async (request, reply) => { + try { + return createTask(cwd, request.body as Partial); + } catch (err) { + return badRequest(reply, err instanceof Error ? err.message : 'Invalid task'); + } }); - app.get('/tasks/:id', async (request) => { + app.get('/tasks/:id', async (request, reply) => { const { id } = request.params as { id: string }; - const { task, body } = getTask(cwd, id); - return { task, body }; + try { + const { task, body } = getTask(cwd, id); + return { task, body }; + } catch { + return notFound(reply, 'Task'); + } }); - app.patch('/tasks/:id', async (request) => { + app.patch('/tasks/:id', async (request, reply) => { const { id } = request.params as { id: string }; const patch = request.body as Partial; if (patch.status === 'in_progress' && patch.assignedTo) { @@ -35,22 +52,44 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise if (patch.status === 'done') { return doneTask(cwd, id); } - return { error: 'Unsupported patch' }; + return badRequest(reply, 'Unsupported patch'); }); app.get('/handoffs', async () => listHandoffs(cwd)); - app.post('/handoffs', async (request) => createHandoff(cwd, request.body as Partial)); - app.get('/handoffs/:id', async (request) => { + app.post('/handoffs', async (request, reply) => { + try { + return createHandoff(cwd, request.body as Partial); + } catch (err) { + return badRequest(reply, err instanceof Error ? err.message : 'Invalid handoff'); + } + }); + app.get('/handoffs/:id', async (request, reply) => { const { id } = request.params as { id: string }; - const { handoff, body } = getHandoff(cwd, id); - return { handoff, body }; + try { + const { handoff, body } = getHandoff(cwd, id); + return { handoff, body }; + } catch { + return notFound(reply, 'Handoff'); + } }); app.get('/decisions', async () => listDecisions(cwd)); - app.post('/decisions', async (request) => createDecision(cwd, request.body as Partial)); + app.post('/decisions', async (request, reply) => { + try { + return createDecision(cwd, request.body as Partial); + } catch (err) { + return badRequest(reply, err instanceof Error ? err.message : 'Invalid decision'); + } + }); app.get('/memory', async () => listMemory(cwd)); - app.post('/memory', async (request) => addMemory(cwd, request.body as Partial)); + app.post('/memory', async (request, reply) => { + try { + return addMemory(cwd, request.body as Partial); + } catch (err) { + return badRequest(reply, err instanceof Error ? err.message : 'Invalid memory'); + } + }); app.get('/memory/search', async (request) => { const { q } = request.query as { q: string }; return searchMemory(cwd, q ?? ''); @@ -58,9 +97,11 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise app.post('/delegate', async (request) => { const { auto } = request.query as { auto?: string }; + const config = loadConfig(cwd); const suggestion = suggestDelegation(cwd); if (!suggestion) return { suggestion: null }; - if (auto === 'true') { + const shouldAuto = auto === 'true' || config.delegationMode === 'auto'; + if (shouldAuto) { const handoff = autoDelegate(cwd); return { suggestion, handoff }; } diff --git a/tests/server.test.ts b/tests/server.test.ts new file mode 100644 index 0000000..6a71c35 --- /dev/null +++ b/tests/server.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { buildApp } from '../src/server/index.js'; +import { init } from '../src/cli/commands/init.js'; + +describe('server routes', () => { + let cwd: string; + let app: ReturnType; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'ah-server-')); + init(cwd, { projectName: 'server-test', yes: true }); + app = buildApp(cwd); + }); + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + it('creates a task via POST /tasks', async () => { + const res = await app.inject({ + method: 'POST', + url: '/tasks', + payload: { title: 'API task', role: 'implementer' }, + }); + expect(res.statusCode).toBe(200); + const task = JSON.parse(res.payload); + expect(task.id).toBe('TSK-0001'); + }); + + it('lists tasks via GET /tasks', async () => { + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); + const res = await app.inject({ method: 'GET', url: '/tasks' }); + expect(JSON.parse(res.payload)).toHaveLength(1); + }); + + it('returns 404 for unknown task', async () => { + const res = await app.inject({ method: 'GET', url: '/tasks/TSK-9999' }); + expect(res.statusCode).toBe(404); + }); + + it('returns 400 for unsupported patch', async () => { + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); + const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'unknown' } }); + expect(res.statusCode).toBe(400); + }); + + it('updates status via POST /status/update', async () => { + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); + const res = await app.inject({ method: 'POST', url: '/status/update' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload).body).toContain('Active tasks: 1'); + }); + + it('searches memory via GET /memory/search', async () => { + await app.inject({ method: 'POST', url: '/memory', payload: { title: 'DNS cache', category: 'technical', content: 'Use TTL' } }); + const res = await app.inject({ method: 'GET', url: '/memory/search?q=TTL' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload)).toHaveLength(1); + }); +});