diff --git a/__tests__/api/agents/lineage.test.ts b/__tests__/api/agents/lineage.test.ts new file mode 100644 index 0000000..e3564b0 --- /dev/null +++ b/__tests__/api/agents/lineage.test.ts @@ -0,0 +1,259 @@ +/** + * PlantLineageAgent API Tests + * Tests for lineage agent API endpoints + */ + +import { PlantLineageAgent, getPlantLineageAgent } from '../../../lib/agents'; +import { getBlockchain, initializeBlockchain } from '../../../lib/blockchain/manager'; + +describe('PlantLineageAgent API', () => { + let agent: PlantLineageAgent; + + beforeEach(() => { + // Get fresh agent instance + agent = getPlantLineageAgent(); + }); + + afterEach(async () => { + // Ensure agent is stopped after each test + if (agent.status === 'running') { + await agent.stop(); + } + }); + + describe('GET /api/agents/lineage', () => { + it('should return agent status and configuration', () => { + expect(agent.config.id).toBe('plant-lineage-agent'); + expect(agent.config.name).toBe('Plant Lineage Agent'); + expect(agent.config.description).toBeDefined(); + expect(agent.config.priority).toBe('high'); + expect(agent.config.intervalMs).toBe(60000); + }); + + it('should return current metrics', () => { + const metrics = agent.getMetrics(); + + expect(metrics.agentId).toBe('plant-lineage-agent'); + expect(metrics.tasksCompleted).toBeGreaterThanOrEqual(0); + expect(metrics.tasksFailed).toBeGreaterThanOrEqual(0); + expect(metrics.averageExecutionMs).toBeGreaterThanOrEqual(0); + }); + + it('should return network statistics', () => { + const networkStats = agent.getNetworkStats(); + + expect(networkStats.totalPlants).toBeGreaterThanOrEqual(0); + expect(networkStats.totalLineages).toBeGreaterThanOrEqual(0); + expect(networkStats.avgGenerationDepth).toBeGreaterThanOrEqual(0); + expect(networkStats.avgLineageSize).toBeGreaterThanOrEqual(0); + expect(networkStats.geographicSpread).toBeGreaterThanOrEqual(0); + }); + + it('should return anomaly summary', () => { + const anomalies = agent.getAnomalies(); + + expect(Array.isArray(anomalies)).toBe(true); + }); + }); + + describe('POST /api/agents/lineage', () => { + it('should start agent successfully', async () => { + expect(agent.status).toBe('idle'); + + await agent.start(); + + expect(agent.status).toBe('running'); + }); + + it('should stop agent successfully', async () => { + await agent.start(); + expect(agent.status).toBe('running'); + + await agent.stop(); + + expect(agent.status).toBe('idle'); + }); + + it('should pause and resume agent', async () => { + await agent.start(); + expect(agent.status).toBe('running'); + + agent.pause(); + expect(agent.status).toBe('paused'); + + agent.resume(); + expect(agent.status).toBe('running'); + }); + + it('should handle start when already running', async () => { + await agent.start(); + const firstStatus = agent.status; + + await agent.start(); // Should not throw + + expect(agent.status).toBe(firstStatus); + }); + }); + + describe('GET /api/agents/lineage/[plantId]', () => { + it('should return null for non-existent plant analysis', () => { + const analysis = agent.getLineageAnalysis('non-existent-plant-id'); + + expect(analysis).toBeNull(); + }); + + it('should return analysis structure when available', () => { + // Analysis would be populated after agent runs + // For now, test the structure expectations + const analysis = agent.getLineageAnalysis('test-plant-id'); + + // Should return null for non-cached plant + expect(analysis).toBeNull(); + }); + }); + + describe('GET /api/agents/lineage/anomalies', () => { + it('should return empty array when no anomalies', () => { + const anomalies = agent.getAnomalies(); + + expect(Array.isArray(anomalies)).toBe(true); + }); + + it('should support filtering by severity', () => { + const allAnomalies = agent.getAnomalies(); + + const highSeverity = allAnomalies.filter(a => a.severity === 'high'); + const mediumSeverity = allAnomalies.filter(a => a.severity === 'medium'); + const lowSeverity = allAnomalies.filter(a => a.severity === 'low'); + + expect(highSeverity.length + mediumSeverity.length + lowSeverity.length).toBe(allAnomalies.length); + }); + + it('should support filtering by type', () => { + const allAnomalies = agent.getAnomalies(); + const validTypes = ['orphan', 'circular', 'invalid_generation', 'missing_parent', 'suspicious_location']; + + for (const anomaly of allAnomalies) { + expect(validTypes).toContain(anomaly.type); + } + }); + }); + + describe('Agent Lifecycle', () => { + it('should track uptime correctly', async () => { + const initialMetrics = agent.getMetrics(); + const initialUptime = initialMetrics.uptime; + + await agent.start(); + + // Wait a bit + await new Promise(resolve => setTimeout(resolve, 100)); + + const runningMetrics = agent.getMetrics(); + expect(runningMetrics.uptime).toBeGreaterThan(initialUptime); + }); + + it('should maintain metrics across start/stop cycles', async () => { + await agent.start(); + await agent.stop(); + + const metrics = agent.getMetrics(); + expect(metrics.uptime).toBeGreaterThan(0); + }); + }); + + describe('Alert Management', () => { + it('should return alerts array', () => { + const alerts = agent.getAlerts(); + + expect(Array.isArray(alerts)).toBe(true); + }); + + it('should have proper alert structure', () => { + const alerts = agent.getAlerts(); + + for (const alert of alerts) { + expect(alert.id).toBeDefined(); + expect(alert.agentId).toBe('plant-lineage-agent'); + expect(alert.severity).toBeDefined(); + expect(alert.title).toBeDefined(); + expect(alert.message).toBeDefined(); + expect(alert.timestamp).toBeDefined(); + expect(typeof alert.acknowledged).toBe('boolean'); + } + }); + }); + + describe('Error Handling', () => { + it('should handle agent operations gracefully', async () => { + // Test that agent doesn't throw on basic operations + expect(() => agent.getMetrics()).not.toThrow(); + expect(() => agent.getAnomalies()).not.toThrow(); + expect(() => agent.getNetworkStats()).not.toThrow(); + expect(() => agent.getAlerts()).not.toThrow(); + }); + + it('should handle pause on non-running agent', () => { + expect(agent.status).toBe('idle'); + + agent.pause(); // Should not throw + + // Status should remain idle (only pauses if running) + expect(agent.status).toBe('idle'); + }); + + it('should handle resume on non-paused agent', () => { + expect(agent.status).toBe('idle'); + + agent.resume(); // Should not throw + + // Status should remain idle (only resumes if paused) + expect(agent.status).toBe('idle'); + }); + }); + + describe('Response Format', () => { + it('should return consistent metrics format', () => { + const metrics = agent.getMetrics(); + + expect(typeof metrics.agentId).toBe('string'); + expect(typeof metrics.tasksCompleted).toBe('number'); + expect(typeof metrics.tasksFailed).toBe('number'); + expect(typeof metrics.averageExecutionMs).toBe('number'); + expect(typeof metrics.uptime).toBe('number'); + expect(Array.isArray(metrics.errors)).toBe(true); + }); + + it('should return consistent network stats format', () => { + const stats = agent.getNetworkStats(); + + expect(typeof stats.totalPlants).toBe('number'); + expect(typeof stats.totalLineages).toBe('number'); + expect(typeof stats.avgGenerationDepth).toBe('number'); + expect(typeof stats.avgLineageSize).toBe('number'); + expect(typeof stats.geographicSpread).toBe('number'); + }); + }); +}); + +describe('PlantLineageAgent Integration', () => { + it('should be accessible via singleton', () => { + const agent1 = getPlantLineageAgent(); + const agent2 = getPlantLineageAgent(); + + expect(agent1).toBe(agent2); + }); + + it('should have correct priority', () => { + const agent = getPlantLineageAgent(); + + expect(agent.config.priority).toBe('high'); + }); + + it('should have correct interval', () => { + const agent = getPlantLineageAgent(); + + // Should run every minute + expect(agent.config.intervalMs).toBe(60000); + }); +}); diff --git a/pages/api/agents/lineage/[plantId].ts b/pages/api/agents/lineage/[plantId].ts new file mode 100644 index 0000000..ca302ef --- /dev/null +++ b/pages/api/agents/lineage/[plantId].ts @@ -0,0 +1,85 @@ +/** + * API Route: Get lineage analysis for a specific plant + * GET /api/agents/lineage/[plantId] + */ + +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getPlantLineageAgent } from '../../../../lib/agents'; +import { getBlockchain } from '../../../../lib/blockchain/manager'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'GET') { + return res.status(405).json({ success: false, error: 'Method not allowed' }); + } + + try { + const { plantId } = req.query; + + if (!plantId || typeof plantId !== 'string') { + return res.status(400).json({ success: false, error: 'Invalid plant ID' }); + } + + // Check if plant exists in blockchain + const blockchain = getBlockchain(); + const plant = blockchain.findPlant(plantId); + + if (!plant) { + return res.status(404).json({ success: false, error: 'Plant not found' }); + } + + const agent = getPlantLineageAgent(); + + // Get cached lineage analysis from agent + let analysis = agent.getLineageAnalysis(plantId); + + // If not cached, the agent hasn't scanned this plant yet + // Return basic info with a note that full analysis is pending + if (!analysis) { + return res.status(200).json({ + success: true, + plantId, + cached: false, + message: 'Lineage analysis pending. Agent will analyze on next scan cycle.', + plant: { + id: plant.id, + species: plant.species, + generation: plant.generation, + status: plant.status, + parentPlantId: plant.parentPlantId, + childPlants: plant.childPlants || [], + }, + }); + } + + res.status(200).json({ + success: true, + plantId, + cached: true, + analysis: { + generation: analysis.generation, + ancestors: analysis.ancestors, + descendants: analysis.descendants, + totalLineageSize: analysis.totalLineageSize, + propagationChain: analysis.propagationChain, + geographicSpread: analysis.geographicSpread, + oldestAncestorDate: analysis.oldestAncestorDate, + healthScore: analysis.healthScore, + }, + plant: { + id: plant.id, + species: plant.species, + generation: plant.generation, + status: plant.status, + propagationType: plant.propagationType, + location: plant.location, + dateAcquired: plant.dateAcquired, + }, + }); + } catch (error: any) { + console.error('Error getting lineage analysis:', error); + res.status(500).json({ success: false, error: error.message || 'Internal server error' }); + } +} diff --git a/pages/api/agents/lineage/anomalies.ts b/pages/api/agents/lineage/anomalies.ts new file mode 100644 index 0000000..8f1ef75 --- /dev/null +++ b/pages/api/agents/lineage/anomalies.ts @@ -0,0 +1,79 @@ +/** + * API Route: Get lineage anomalies detected by PlantLineageAgent + * GET /api/agents/lineage/anomalies + */ + +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getPlantLineageAgent } from '../../../../lib/agents'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'GET') { + return res.status(405).json({ success: false, error: 'Method not allowed' }); + } + + try { + const { severity, type, limit } = req.query; + const agent = getPlantLineageAgent(); + + let anomalies = agent.getAnomalies(); + + // Filter by severity if specified + if (severity && typeof severity === 'string') { + const validSeverities = ['low', 'medium', 'high']; + if (!validSeverities.includes(severity)) { + return res.status(400).json({ + success: false, + error: `Invalid severity. Must be one of: ${validSeverities.join(', ')}` + }); + } + anomalies = anomalies.filter(a => a.severity === severity); + } + + // Filter by type if specified + if (type && typeof type === 'string') { + const validTypes = ['orphan', 'circular', 'invalid_generation', 'missing_parent', 'suspicious_location']; + if (!validTypes.includes(type)) { + return res.status(400).json({ + success: false, + error: `Invalid type. Must be one of: ${validTypes.join(', ')}` + }); + } + anomalies = anomalies.filter(a => a.type === type); + } + + // Apply limit if specified + const maxResults = limit ? Math.min(parseInt(limit as string, 10), 100) : 50; + anomalies = anomalies.slice(0, maxResults); + + // Group by type for summary + const byType: Record = {}; + const bySeverity: Record = {}; + const allAnomalies = agent.getAnomalies(); + + for (const anomaly of allAnomalies) { + byType[anomaly.type] = (byType[anomaly.type] || 0) + 1; + bySeverity[anomaly.severity] = (bySeverity[anomaly.severity] || 0) + 1; + } + + res.status(200).json({ + success: true, + summary: { + total: allAnomalies.length, + byType, + bySeverity, + }, + filters: { + severity: severity || null, + type: type || null, + limit: maxResults, + }, + anomalies, + }); + } catch (error: any) { + console.error('Error getting lineage anomalies:', error); + res.status(500).json({ success: false, error: error.message || 'Internal server error' }); + } +} diff --git a/pages/api/agents/lineage/index.ts b/pages/api/agents/lineage/index.ts new file mode 100644 index 0000000..b927603 --- /dev/null +++ b/pages/api/agents/lineage/index.ts @@ -0,0 +1,92 @@ +/** + * API Route: PlantLineageAgent Status and Network Stats + * GET /api/agents/lineage - Get agent status and network statistics + * POST /api/agents/lineage - Start/stop/restart the agent + */ + +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getPlantLineageAgent } from '../../../../lib/agents'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const agent = getPlantLineageAgent(); + + if (req.method === 'GET') { + try { + const metrics = agent.getMetrics(); + const networkStats = agent.getNetworkStats(); + const anomalies = agent.getAnomalies(); + + res.status(200).json({ + success: true, + agent: { + id: agent.config.id, + name: agent.config.name, + description: agent.config.description, + status: agent.status, + priority: agent.config.priority, + intervalMs: agent.config.intervalMs, + }, + metrics: { + tasksCompleted: metrics.tasksCompleted, + tasksFailed: metrics.tasksFailed, + averageExecutionMs: Math.round(metrics.averageExecutionMs), + lastRunAt: metrics.lastRunAt, + lastSuccessAt: metrics.lastSuccessAt, + uptime: metrics.uptime, + }, + networkStats, + anomalySummary: { + total: anomalies.length, + bySeverity: { + high: anomalies.filter(a => a.severity === 'high').length, + medium: anomalies.filter(a => a.severity === 'medium').length, + low: anomalies.filter(a => a.severity === 'low').length, + }, + }, + }); + } catch (error: any) { + console.error('Error getting PlantLineageAgent status:', error); + res.status(500).json({ success: false, error: error.message || 'Internal server error' }); + } + } else if (req.method === 'POST') { + try { + const { action } = req.body; + + if (!action || !['start', 'stop', 'pause', 'resume'].includes(action)) { + return res.status(400).json({ + success: false, + error: 'Invalid action. Must be one of: start, stop, pause, resume' + }); + } + + switch (action) { + case 'start': + await agent.start(); + break; + case 'stop': + await agent.stop(); + break; + case 'pause': + agent.pause(); + break; + case 'resume': + agent.resume(); + break; + } + + res.status(200).json({ + success: true, + action, + newStatus: agent.status, + }); + } catch (error: any) { + console.error('Error controlling PlantLineageAgent:', error); + res.status(500).json({ success: false, error: error.message || 'Internal server error' }); + } + } else { + res.status(405).json({ success: false, error: 'Method not allowed' }); + } +}