diff --git a/test/directory.test.ts b/test/directory.test.ts new file mode 100644 index 0000000..cc96560 --- /dev/null +++ b/test/directory.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { buildApp } from '../src/app.js'; +import type { FastifyInstance } from 'fastify'; +import { loadConfig } from '../src/config.js'; +import { pool } from '../src/db/pool.js'; +import { sql } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { agents, auditEvents } from '../src/db/schema.js'; +import { recordAuditEvent } from '../src/lib/audit.js'; + +describe('Directory API', () => { + let app: FastifyInstance; + let testAgentId: string; + let testAgent2Id: string; + let testAgent3Id: string; + + beforeAll(async () => { + const config = loadConfig(); + app = await buildApp({ config }); + + const db = drizzle(pool); + + // Clean up test data + await db.execute(sql`TRUNCATE agents CASCADE`); + + // Create test agents with different roles + const [agent1] = await db + .insert(agents) + .values({ + name: 'test-engineer', + displayName: 'Test Engineer', + role: 'agent', + urlKey: 'test-engineer', + description: 'A test engineer agent', + specialties: ['typescript', 'testing'], + }) + .returning(); + + const [agent2] = await db + .insert(agents) + .values({ + name: 'test-admin', + displayName: 'Test Admin', + role: 'admin', + urlKey: 'test-admin', + description: 'An admin agent', + specialties: ['management', 'operations'], + }) + .returning(); + + const [agent3] = await db + .insert(agents) + .values({ + name: 'test-agent-idle', + displayName: 'Idle Agent', + role: 'agent', + urlKey: 'idle-agent', + description: null, + specialties: null, + }) + .returning(); + + testAgentId = agent1!.id; + testAgent2Id = agent2!.id; + testAgent3Id = agent3!.id; + + // Create recent activity for agent1 (should be "active") + await recordAuditEvent(pool, 'agent-created', testAgentId, { test: true }); + + // Create old activity for agent3 (should be "offline") + await db + .insert(auditEvents) + .values({ + type: 'agent-created', + agentId: testAgent3Id, + payloadHash: Buffer.from('test'), + ts: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago + }); + }); + + afterAll(async () => { + await app.close(); + }); + + it('should return empty list when no agents exist', async () => { + // Clean up + const db = drizzle(pool); + await db.execute(sql`TRUNCATE agents CASCADE`); + + const response = await app.inject({ + method: 'GET', + url: '/api/companies/test-company/agents/directory', + headers: { + 'x-agent-id': 'dummy-agent-id', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.agents).toEqual([]); + expect(body.total).toBe(0); + + // Recreate test data for other tests + await db.insert(agents).values({ + name: 'test-engineer', + displayName: 'Test Engineer', + role: 'agent', + urlKey: 'test-engineer', + description: 'A test engineer agent', + specialties: ['typescript', 'testing'], + }); + }); + + it('should return all agents with enriched data', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/companies/test-company/agents/directory', + headers: { + 'x-agent-id': testAgentId, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + + expect(body.agents).toBeDefined(); + expect(body.agents.length).toBeGreaterThan(0); + expect(body.total).toBeGreaterThan(0); + + // Check first agent structure + const firstAgent = body.agents[0]; + expect(firstAgent).toHaveProperty('id'); + expect(firstAgent).toHaveProperty('name'); + expect(firstAgent).toHaveProperty('urlKey'); + expect(firstAgent).toHaveProperty('role'); + expect(firstAgent).toHaveProperty('description'); + expect(firstAgent).toHaveProperty('specialties'); + expect(firstAgent).toHaveProperty('lastActivityAt'); + expect(firstAgent).toHaveProperty('status'); + expect(firstAgent).toHaveProperty('chainOfCommand'); + expect(firstAgent).toHaveProperty('socialChannels'); + expect(firstAgent).toHaveProperty('profileUrl'); + + // Validate types + expect(['active', 'idle', 'offline']).toContain(firstAgent.status); + expect(Array.isArray(firstAgent.specialties)).toBe(true); + expect(Array.isArray(firstAgent.socialChannels)).toBe(true); + expect(firstAgent.profileUrl).toMatch(/^\/BARAAA\/agents\//); + }); + + it('should filter agents by role', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/companies/test-company/agents/directory?role=admin', + headers: { + 'x-agent-id': testAgentId, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + + expect(body.agents.length).toBeGreaterThan(0); + body.agents.forEach((agent: any) => { + expect(agent.role).toBe('admin'); + }); + }); + + it('should respect limit parameter', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/companies/test-company/agents/directory?limit=1', + headers: { + 'x-agent-id': testAgentId, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + + expect(body.agents.length).toBeLessThanOrEqual(1); + if (body.agents.length === 1) { + expect(body.hasMore).toBe(true); + } + }); + + it('should calculate status correctly based on lastActivityAt', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/companies/test-company/agents/directory', + headers: { + 'x-agent-id': testAgentId, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + + // Find agent1 (recent activity) and agent3 (old activity) + const activeAgent = body.agents.find((a: any) => a.id === testAgentId); + const offlineAgent = body.agents.find((a: any) => a.id === testAgent3Id); + + // Agent with recent activity should be active or idle + if (activeAgent) { + expect(['active', 'idle']).toContain(activeAgent.status); + } + + // Agent with 2-hour-old activity should be offline + if (offlineAgent) { + expect(offlineAgent.status).toBe('offline'); + } + }); + + it('should return 401 without x-agent-id header', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/companies/test-company/agents/directory', + }); + + expect(response.statusCode).toBe(401); + const body = JSON.parse(response.body); + expect(body.error).toBe('Missing x-agent-id header'); + }); + + it('should handle null description and specialties gracefully', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/companies/test-company/agents/directory', + headers: { + 'x-agent-id': testAgentId, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + + const agentWithNulls = body.agents.find((a: any) => a.id === testAgent3Id); + if (agentWithNulls) { + expect(agentWithNulls.description).toBeNull(); + expect(Array.isArray(agentWithNulls.specialties)).toBe(true); + } + }); +});