diff --git a/src/trpc/index.ts b/src/trpc/index.ts index 740af30..a611903 100644 --- a/src/trpc/index.ts +++ b/src/trpc/index.ts @@ -6,11 +6,26 @@ */ // Re-export router components -export { router, publicProcedure, middleware, createCallerFactory } from './router.js'; +export { + router, + publicProcedure, + middleware, + createCallerFactory, + appRouter, + healthResponseSchema, + statusResponseSchema, + processInfoSchema, +} from './router.js'; + +// Export types +export type { + AppRouter, + HealthResponse, + StatusResponse, + ProcessInfo, +} from './router.js'; // Re-export context export { createContext } from './context.js'; export type { TRPCContext, CreateContextOptions } from './context.js'; export type { EventBus, DomainEvent } from './context.js'; - -// AppRouter type will be exported after procedures are defined in Task 2 diff --git a/src/trpc/router.test.ts b/src/trpc/router.test.ts new file mode 100644 index 0000000..c585ce5 --- /dev/null +++ b/src/trpc/router.test.ts @@ -0,0 +1,222 @@ +/** + * tRPC Router Tests + * + * Tests for the tRPC procedures using createCallerFactory. + * Tests verify correct response shapes and Zod validation. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + appRouter, + createCallerFactory, + healthResponseSchema, + statusResponseSchema, +} from './index.js'; +import type { TRPCContext } from './context.js'; +import type { EventBus } from '../events/types.js'; + +// Create caller factory for the app router +const createCaller = createCallerFactory(appRouter); + +/** + * Create a mock EventBus for testing. + */ +function createMockEventBus(): EventBus { + return { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + }; +} + +/** + * Create a test context with configurable options. + */ +function createTestContext(overrides: Partial = {}): TRPCContext { + return { + eventBus: createMockEventBus(), + serverStartedAt: new Date('2026-01-30T12:00:00Z'), + processCount: 0, + ...overrides, + }; +} + +describe('tRPC Router', () => { + let caller: ReturnType; + let ctx: TRPCContext; + + beforeEach(() => { + ctx = createTestContext(); + caller = createCaller(ctx); + }); + + describe('health procedure', () => { + it('should return correct shape', async () => { + const result = await caller.health(); + + expect(result).toEqual({ + status: 'ok', + uptime: expect.any(Number), + processCount: 0, + }); + }); + + it('should validate against Zod schema', async () => { + const result = await caller.health(); + + const parsed = healthResponseSchema.safeParse(result); + expect(parsed.success).toBe(true); + }); + + it('should calculate uptime from serverStartedAt', async () => { + // Set serverStartedAt to 60 seconds ago + const sixtySecondsAgo = new Date(Date.now() - 60000); + ctx = createTestContext({ serverStartedAt: sixtySecondsAgo }); + caller = createCaller(ctx); + + const result = await caller.health(); + + // Uptime should be approximately 60 seconds (allow 1 second tolerance) + expect(result.uptime).toBeGreaterThanOrEqual(59); + expect(result.uptime).toBeLessThanOrEqual(61); + }); + + it('should return uptime 0 when serverStartedAt is null', async () => { + ctx = createTestContext({ serverStartedAt: null }); + caller = createCaller(ctx); + + const result = await caller.health(); + + expect(result.uptime).toBe(0); + }); + + it('should reflect processCount from context', async () => { + ctx = createTestContext({ processCount: 5 }); + caller = createCaller(ctx); + + const result = await caller.health(); + + expect(result.processCount).toBe(5); + }); + }); + + describe('status procedure', () => { + it('should return correct shape', async () => { + const result = await caller.status(); + + expect(result).toEqual({ + server: { + startedAt: expect.any(String), + uptime: expect.any(Number), + pid: expect.any(Number), + }, + processes: [], + }); + }); + + it('should validate against Zod schema', async () => { + const result = await caller.status(); + + const parsed = statusResponseSchema.safeParse(result); + expect(parsed.success).toBe(true); + }); + + it('should include server startedAt as ISO string', async () => { + const result = await caller.status(); + + expect(result.server.startedAt).toBe('2026-01-30T12:00:00.000Z'); + }); + + it('should return empty startedAt when serverStartedAt is null', async () => { + ctx = createTestContext({ serverStartedAt: null }); + caller = createCaller(ctx); + + const result = await caller.status(); + + expect(result.server.startedAt).toBe(''); + }); + + it('should include actual process.pid', async () => { + const result = await caller.status(); + + expect(result.server.pid).toBe(process.pid); + }); + + it('should calculate uptime correctly', async () => { + const thirtySecondsAgo = new Date(Date.now() - 30000); + ctx = createTestContext({ serverStartedAt: thirtySecondsAgo }); + caller = createCaller(ctx); + + const result = await caller.status(); + + expect(result.server.uptime).toBeGreaterThanOrEqual(29); + expect(result.server.uptime).toBeLessThanOrEqual(31); + }); + + it('should return empty processes array', async () => { + const result = await caller.status(); + + expect(result.processes).toEqual([]); + }); + }); + + describe('Zod schema validation', () => { + it('healthResponseSchema should reject invalid status', () => { + const invalid = { + status: 'not-ok', + uptime: 100, + processCount: 0, + }; + + const parsed = healthResponseSchema.safeParse(invalid); + expect(parsed.success).toBe(false); + }); + + it('healthResponseSchema should reject negative uptime', () => { + const invalid = { + status: 'ok', + uptime: -1, + processCount: 0, + }; + + const parsed = healthResponseSchema.safeParse(invalid); + expect(parsed.success).toBe(false); + }); + + it('statusResponseSchema should reject missing server fields', () => { + const invalid = { + server: { + startedAt: '2026-01-30T12:00:00Z', + // missing uptime and pid + }, + processes: [], + }; + + const parsed = statusResponseSchema.safeParse(invalid); + expect(parsed.success).toBe(false); + }); + + it('statusResponseSchema should accept valid process info', () => { + const valid = { + server: { + startedAt: '2026-01-30T12:00:00Z', + uptime: 100, + pid: 12345, + }, + processes: [ + { + id: 'proc-1', + pid: 54321, + command: 'node server.js', + status: 'running', + startedAt: '2026-01-30T12:00:00Z', + }, + ], + }; + + const parsed = statusResponseSchema.safeParse(valid); + expect(parsed.success).toBe(true); + }); + }); +}); diff --git a/src/trpc/router.ts b/src/trpc/router.ts index 29cd1f3..da55246 100644 --- a/src/trpc/router.ts +++ b/src/trpc/router.ts @@ -6,6 +6,7 @@ */ import { initTRPC } from '@trpc/server'; +import { z } from 'zod'; import type { TRPCContext } from './context.js'; /** @@ -35,3 +36,108 @@ export const middleware = t.middleware; * Allows calling procedures directly without HTTP transport. */ export const createCallerFactory = t.createCallerFactory; + +// ============================================================================= +// Zod Schemas for procedure outputs +// ============================================================================= + +/** + * Schema for health check response. + */ +export const healthResponseSchema = z.object({ + status: z.literal('ok'), + uptime: z.number().int().nonnegative(), + processCount: z.number().int().nonnegative(), +}); + +export type HealthResponse = z.infer; + +/** + * Schema for process info in status response. + */ +export const processInfoSchema = z.object({ + id: z.string(), + pid: z.number().int().positive(), + command: z.string(), + status: z.string(), + startedAt: z.string(), +}); + +export type ProcessInfo = z.infer; + +/** + * Schema for status response. + */ +export const statusResponseSchema = z.object({ + server: z.object({ + startedAt: z.string(), + uptime: z.number().int().nonnegative(), + pid: z.number().int().positive(), + }), + processes: z.array(processInfoSchema), +}); + +export type StatusResponse = z.infer; + +// ============================================================================= +// Application Router with Procedures +// ============================================================================= + +/** + * Application router with all procedures. + * + * Procedures: + * - health: Quick health check with uptime and process count + * - status: Full server status with process list + */ +export const appRouter = router({ + /** + * Health check procedure. + * + * Returns a lightweight response suitable for health monitoring. + * Calculates uptime from serverStartedAt in context. + */ + health: publicProcedure + .output(healthResponseSchema) + .query(({ ctx }): HealthResponse => { + const uptime = ctx.serverStartedAt + ? Math.floor((Date.now() - ctx.serverStartedAt.getTime()) / 1000) + : 0; + + return { + status: 'ok', + uptime, + processCount: ctx.processCount, + }; + }), + + /** + * Full status procedure. + * + * Returns detailed server state including process list. + * More comprehensive than health for admin/debugging purposes. + */ + status: publicProcedure + .output(statusResponseSchema) + .query(({ ctx }): StatusResponse => { + const uptime = ctx.serverStartedAt + ? Math.floor((Date.now() - ctx.serverStartedAt.getTime()) / 1000) + : 0; + + return { + server: { + startedAt: ctx.serverStartedAt?.toISOString() ?? '', + uptime, + pid: process.pid, + }, + // Process list will be populated when ProcessManager integration is complete + processes: [], + }; + }), +}); + +/** + * Type of the application router. + * Used by clients for type-safe procedure calls. + */ +export type AppRouter = typeof appRouter;