/** * @fileoverview Tests for hook-event-routes route handlers. * * Uses app.inject() — no real HTTP ports needed. * Port: N/A (app.inject doesn't open ports) * * These tests assert the UNIFORM response envelope (stable HTTP contract): * success -> 2xx, { success: true, data: } * error -> 4xx/5xx, { success: false, error, errorCode } * The production server applies this via a preSerialization hook (server.ts). * The shared route harness doesn't install it, so we build a local harness here * that mirrors production: the same preSerialization envelope hook + the shared * route error handler, so assertions match the real wire format. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; import fastifyCookie from '@fastify/cookie'; import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; import { registerHookEventRoutes } from '../../src/web/routes/hook-event-routes.js'; interface LocalHarness { app: FastifyInstance; ctx: MockRouteContext; } /** * Build a Fastify instance that mirrors production's uniform-envelope behavior * (server.ts preSerialization hook) so the test wire format matches the contract: * bare payloads become { success: true, data }, and { success:false } error * envelopes get the conventional HTTP status from their errorCode. */ async function createEnvelopeHarness( registerFn: (app: FastifyInstance, ctx: MockRouteContext) => void ): Promise { const app = Fastify({ logger: false }); await app.register(fastifyCookie); const ctx = createMockRouteContext(); registerFn(app, ctx); // Mirror production uniform response envelope (server.ts). app.addHook('preSerialization', (req, reply, payload: unknown, done) => { if (!req.url.startsWith('/api')) return done(null, payload); if (payload === null || typeof payload !== 'object') return done(null, payload); if (Buffer.isBuffer(payload) || typeof (payload as { pipe?: unknown }).pipe === 'function') { return done(null, payload); } const p = payload as { success?: unknown; errorCode?: unknown }; if (p.success === false) { if (reply.statusCode === 200 && typeof p.errorCode === 'string') { reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode)); } return done(null, payload); } if (p.success === true) return done(null, payload); return done(null, { success: true, data: payload }); }); installRouteErrorHandler(app); await app.ready(); return { app, ctx }; } describe('hook-event-routes', () => { let harness: LocalHarness; beforeEach(async () => { harness = await createEnvelopeHarness(registerHookEventRoutes); }); afterEach(async () => { await harness.app.close(); }); // ========== POST /api/hook-event ========== describe('POST /api/hook-event', () => { it('accepts a valid hook event and broadcasts it', async () => { const res = await harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event: 'stop', sessionId: harness.ctx._sessionId, data: null, }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.success).toBe(true); expect(harness.ctx.broadcast).toHaveBeenCalledWith( 'hook:stop', expect.objectContaining({ sessionId: harness.ctx._sessionId }) ); }); it('sends push notifications for hook events', async () => { const res = await harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event: 'idle_prompt', sessionId: harness.ctx._sessionId, data: null, }, }); expect(res.statusCode).toBe(200); expect(harness.ctx.sendPushNotifications).toHaveBeenCalledWith( 'hook:idle_prompt', expect.objectContaining({ sessionId: harness.ctx._sessionId }) ); }); it('returns 404 for unknown session', async () => { const res = await harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event: 'stop', sessionId: 'nonexistent-session', data: null, }, }); expect(res.statusCode).toBe(404); const body = JSON.parse(res.body); expect(body.success).toBe(false); expect(body.error).toBeDefined(); }); it('rejects invalid event type', async () => { const res = await harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event: 'invalid_event_type', sessionId: harness.ctx._sessionId, data: null, }, }); expect(res.statusCode).toBe(400); const body = JSON.parse(res.body); expect(body.success).toBe(false); }); it('rejects missing sessionId', async () => { const res = await harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event: 'stop', }, }); expect(res.statusCode).toBe(400); const body = JSON.parse(res.body); expect(body.success).toBe(false); }); it('signals respawn controller on stop event', async () => { const mockController = { signalStopHook: vi.fn(), signalElicitation: vi.fn(), signalIdlePrompt: vi.fn(), }; harness.ctx.respawnControllers.set(harness.ctx._sessionId, mockController as never); const res = await harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event: 'stop', sessionId: harness.ctx._sessionId, data: null, }, }); expect(res.statusCode).toBe(200); expect(mockController.signalStopHook).toHaveBeenCalled(); }); it('signals respawn controller on elicitation_dialog event', async () => { const mockController = { signalStopHook: vi.fn(), signalElicitation: vi.fn(), signalIdlePrompt: vi.fn(), }; harness.ctx.respawnControllers.set(harness.ctx._sessionId, mockController as never); const res = await harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event: 'elicitation_dialog', sessionId: harness.ctx._sessionId, data: null, }, }); expect(res.statusCode).toBe(200); expect(mockController.signalElicitation).toHaveBeenCalled(); }); it('signals respawn controller on idle_prompt event', async () => { const mockController = { signalStopHook: vi.fn(), signalElicitation: vi.fn(), signalIdlePrompt: vi.fn(), }; harness.ctx.respawnControllers.set(harness.ctx._sessionId, mockController as never); const res = await harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event: 'idle_prompt', sessionId: harness.ctx._sessionId, data: null, }, }); expect(res.statusCode).toBe(200); expect(mockController.signalIdlePrompt).toHaveBeenCalled(); }); it('records hook event in run summary tracker', async () => { const mockTracker = { recordHookEvent: vi.fn() }; harness.ctx.runSummaryTrackers.set(harness.ctx._sessionId, mockTracker as never); const res = await harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event: 'stop', sessionId: harness.ctx._sessionId, data: { tool_name: 'bash' }, }, }); expect(res.statusCode).toBe(200); expect(mockTracker.recordHookEvent).toHaveBeenCalledWith('stop', expect.any(Object)); }); it('starts transcript watcher when transcript_path is provided', async () => { const res = await harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event: 'stop', sessionId: harness.ctx._sessionId, data: { transcript_path: '/home/user/.claude/transcript.jsonl' }, }, }); expect(res.statusCode).toBe(200); expect(harness.ctx.startTranscriptWatcher).toHaveBeenCalledWith( harness.ctx._sessionId, '/home/user/.claude/transcript.jsonl' ); }); it('accepts valid data payload with extra fields', async () => { const res = await harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event: 'permission_prompt', sessionId: harness.ctx._sessionId, data: { tool_name: 'bash', command: 'ls -la' }, }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.success).toBe(true); }); }); });