/** * 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'; import type { AccountRepository } from '../db/repositories/account-repository.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('addAccountByToken procedure', () => { let mockRepo: AccountRepository; beforeEach(() => { mockRepo = { findByEmail: vi.fn(), updateAccountAuth: vi.fn(), create: vi.fn(), } as unknown as AccountRepository; vi.clearAllMocks(); }); it('creates a new account and returns { upserted: false, account }', async () => { const stubAccount = { id: 'new-id', email: 'user@example.com', provider: 'claude' }; (mockRepo.findByEmail as ReturnType).mockResolvedValue(null); (mockRepo.create as ReturnType).mockResolvedValue(stubAccount); const testCtx = createTestContext({ accountRepository: mockRepo }); const testCaller = createCaller(testCtx); const result = await testCaller.addAccountByToken({ email: 'user@example.com', token: 'my-token' }); expect(result).toEqual({ upserted: false, account: stubAccount }); expect(mockRepo.create).toHaveBeenCalledWith({ email: 'user@example.com', provider: 'claude', configJson: '{"hasCompletedOnboarding":true}', credentials: '{"claudeAiOauth":{"accessToken":"my-token"}}', }); expect(mockRepo.updateAccountAuth).not.toHaveBeenCalled(); }); it('updates existing account and returns { upserted: true, account }', async () => { const existingAccount = { id: 'existing-id', email: 'user@example.com', provider: 'claude' }; const updatedAccount = { id: 'existing-id', email: 'user@example.com', provider: 'claude', updated: true }; (mockRepo.findByEmail as ReturnType).mockResolvedValue(existingAccount); (mockRepo.updateAccountAuth as ReturnType).mockResolvedValue(updatedAccount); const testCtx = createTestContext({ accountRepository: mockRepo }); const testCaller = createCaller(testCtx); const result = await testCaller.addAccountByToken({ email: 'user@example.com', token: 'my-token' }); expect(result).toEqual({ upserted: true, account: updatedAccount }); expect(mockRepo.updateAccountAuth).toHaveBeenCalledWith( 'existing-id', '{"hasCompletedOnboarding":true}', '{"claudeAiOauth":{"accessToken":"my-token"}}', ); expect(mockRepo.create).not.toHaveBeenCalled(); }); it('throws BAD_REQUEST when email is empty', async () => { const testCtx = createTestContext({ accountRepository: mockRepo }); const testCaller = createCaller(testCtx); await expect(testCaller.addAccountByToken({ email: '', provider: 'claude', token: 'tok' })) .rejects.toMatchObject({ code: 'BAD_REQUEST' }); expect(mockRepo.findByEmail).not.toHaveBeenCalled(); expect(mockRepo.create).not.toHaveBeenCalled(); expect(mockRepo.updateAccountAuth).not.toHaveBeenCalled(); }); it('throws BAD_REQUEST when token is empty', async () => { const testCtx = createTestContext({ accountRepository: mockRepo }); const testCaller = createCaller(testCtx); await expect(testCaller.addAccountByToken({ email: 'user@example.com', provider: 'claude', token: '' })) .rejects.toMatchObject({ code: 'BAD_REQUEST' }); expect(mockRepo.findByEmail).not.toHaveBeenCalled(); expect(mockRepo.create).not.toHaveBeenCalled(); expect(mockRepo.updateAccountAuth).not.toHaveBeenCalled(); }); }); 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); }); }); });