import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import type { FastifyRequest, FastifyReply } from 'fastify'; import { buildServer } from '../../src/index.js'; import { users, teams, teamMembers, teamResources, agents, scoutFindings } from '../../src/db/schema.js'; import { sql, eq } from 'drizzle-orm'; import { ScoutAgent } from '../../src/daemons/scout.js'; describe('Scout Agent (Task 3.18)', () => { let server: Awaited>; let userId: string; let secondUserId: string; let teamId: string; let scout: ScoutAgent; beforeAll(async () => { server = await buildServer(); // Clean up leftover test data await server.db.execute(sql`DELETE FROM scout_findings WHERE user_id IN (SELECT id FROM users WHERE clerk_id LIKE 'scout_%')`); await server.db.execute(sql`DELETE FROM agents WHERE user_id IN (SELECT id FROM users WHERE clerk_id LIKE 'scout_%')`); await server.db.execute(sql`DELETE FROM team_resources WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'scouttest-%')`); await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'scouttest-%')`); await server.db.execute(sql`DELETE FROM team_capability_requests WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'scouttest-%')`); await server.db.execute(sql`DELETE FROM team_capability_overrides WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'scouttest-%')`); await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'scouttest-%')`); await server.db.execute(sql`DELETE FROM teams WHERE slug LIKE 'scouttest-%'`); await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'scout_%'`); // Create test user const [user] = await server.db.insert(users).values({ clerkId: 'scout_user1', displayName: 'Scout User', email: 'scout_user1@test.com', }).returning(); userId = user.id; const [secondUser] = await server.db.insert(users).values({ clerkId: 'scout_user2', displayName: 'Scout User 2', email: 'scout_user2@test.com', }).returning(); secondUserId = secondUser.id; // Create team const [team] = await server.db.insert(teams).values({ name: 'Scout Test Team', slug: 'scouttest-team', ownerId: userId, }).returning(); teamId = team.id; // Add membership with interests await server.db.insert(teamMembers).values({ teamId, userId, role: 'owner', interests: ['python', 'data-science'], }); // Create an agent with tools for the user await server.db.insert(agents).values({ userId, teamId, name: 'TestAgent', tools: ['web_search', 'code_review'], }); // Create team resources await server.db.insert(teamResources).values([ { teamId, resourceType: 'skill', name: 'Python Data Analyzer', description: 'A skill for analyzing data with Python', config: {}, sharedBy: userId, }, { teamId, resourceType: 'prompt_template', name: 'Code Review Template', description: 'Template for code reviews', config: {}, sharedBy: userId, }, ]); scout = new ScoutAgent(server.db); // Override auth handler server._authHandler.fn = async function (request: FastifyRequest, reply: FastifyReply) { const testUserId = request.headers['x-test-user-id'] as string; if (!testUserId) { return reply.code(401).send({ error: 'Missing x-test-user-id header' }); } request.userId = testUserId; request.clerkId = 'test'; }; }); afterAll(async () => { await server.db.execute(sql`DELETE FROM scout_findings WHERE user_id IN (SELECT id FROM users WHERE clerk_id LIKE 'scout_%')`); await server.db.execute(sql`DELETE FROM agents WHERE user_id IN (SELECT id FROM users WHERE clerk_id LIKE 'scout_%')`); await server.db.execute(sql`DELETE FROM team_resources WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'scouttest-%')`); await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'scouttest-%')`); await server.db.execute(sql`DELETE FROM team_capability_requests WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'scouttest-%')`); await server.db.execute(sql`DELETE FROM team_capability_overrides WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'scouttest-%')`); await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'scouttest-%')`); await server.db.execute(sql`DELETE FROM teams WHERE slug LIKE 'scouttest-%'`); await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'scout_%'`); await server.close(); }); it('scan creates findings from team resources', async () => { const findings = await scout.scan(userId, teamId); expect(findings.length).toBeGreaterThanOrEqual(2); expect(findings.every((f) => f.status === 'new')).toBe(true); expect(findings.some((f) => f.title.includes('Python Data Analyzer'))).toBe(true); expect(findings.some((f) => f.title.includes('Code Review Template'))).toBe(true); }); it('relevance scoring boosts based on user interests', async () => { // The Python Data Analyzer should be boosted because user has 'python' interest const findings = await scout.listFindings(userId); const pythonFinding = findings.find((f) => f.title.includes('Python')); const templateFinding = findings.find((f) => f.title.includes('Code Review')); expect(pythonFinding).toBeTruthy(); expect(templateFinding).toBeTruthy(); // Python finding should have higher score (base 0.5 + 0.3 interest boost) expect(pythonFinding!.relevanceScore).toBeGreaterThan(templateFinding!.relevanceScore); }); it('adopt updates status', async () => { const findings = await scout.listFindings(userId); const finding = findings[0]; const updated = await scout.adopt(finding.id, userId); expect(updated).toBeTruthy(); expect(updated!.status).toBe('adopted'); }); it('dismiss updates status', async () => { const findings = await scout.listFindings(userId); const newFinding = findings.find((f) => f.status === 'new'); expect(newFinding).toBeTruthy(); const updated = await scout.dismiss(newFinding!.id, userId); expect(updated).toBeTruthy(); expect(updated!.status).toBe('dismissed'); }); it('dismissed finding not resurfaced in future scans', async () => { // Clear all non-dismissed findings const allFindings = await scout.listFindings(userId); const dismissed = allFindings.filter((f) => f.status === 'dismissed'); expect(dismissed.length).toBeGreaterThan(0); // Run scan again — dismissed titles should not reappear as new const newFindings = await scout.scan(userId, teamId); const dismissedTitles = dismissed.map((f) => f.title); const resurfaced = newFindings.filter((f) => dismissedTitles.includes(f.title)); expect(resurfaced.length).toBe(0); }); it('GET /api/scout/findings returns findings for user', async () => { const response = await server.inject({ method: 'GET', url: '/api/scout/findings', headers: { 'x-test-user-id': userId }, }); expect(response.statusCode).toBe(200); const body = JSON.parse(response.body); expect(Array.isArray(body)).toBe(true); expect(body.length).toBeGreaterThan(0); }); it.each(['adopted', 'dismissed'] as const)( 'does not let one user mark another user\'s finding as %s', async (status) => { const [victimFinding] = await server.db.insert(scoutFindings).values({ userId: secondUserId, teamId, source: 'team', category: 'practice', title: `Victim finding ${status}`, summary: 'belongs to another user', relevanceScore: 0.8, url: null, status: 'new', }).returning(); const response = await server.inject({ method: 'PATCH', url: `/api/scout/findings/${victimFinding.id}`, headers: { 'x-test-user-id': userId }, payload: { status }, }); expect(response.statusCode).toBe(404); expect(response.json()).toEqual({ error: 'Finding not found' }); const [unchanged] = await server.db.select().from(scoutFindings) .where(eq(scoutFindings.id, victimFinding.id)); expect(unchanged.status).toBe('new'); expect(unchanged.userId).toBe(secondUserId); }, ); it('PATCH /api/scout/findings/:id rejects invalid status', async () => { const findings = await scout.listFindings(userId); const finding = findings[0]; const response = await server.inject({ method: 'PATCH', url: `/api/scout/findings/${finding.id}`, headers: { 'x-test-user-id': userId }, payload: { status: 'invalid' }, }); expect(response.statusCode).toBe(400); }); });