diff --git a/src/backend/routes/__tests__/aiProxyRoutes.test.ts b/src/backend/routes/__tests__/aiProxyRoutes.test.ts new file mode 100644 index 0000000..6440934 --- /dev/null +++ b/src/backend/routes/__tests__/aiProxyRoutes.test.ts @@ -0,0 +1,769 @@ +import { FastifyInstance } from 'fastify'; +import { build } from '../../../app'; +import { GoogleGenerativeAI } from '@google/generative-ai'; + +// Mock Google Generative AI +jest.mock('@google/generative-ai'); + +describe('AI Proxy Routes', () => { + let app: FastifyInstance; + + beforeAll(async () => { + // Set up environment variables for testing + process.env.GEMINI_API_KEY = 'test-gemini-key'; + process.env.DEV_AUTH_TOKEN = 'test-dev-token'; + + app = build({ logger: false }); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('POST /api/ai/analyze', () => { + const validPayload = { + sections: ['Test screenplay content'], + analysisType: 'screenplay' as const, + options: { + filename: 'test.fountain', + userId: 'user123', + priority: 'normal' as const + } + }; + + const validHeaders = { + authorization: 'Bearer test-dev-token' + }; + + it('should successfully start analysis with valid input', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + headers: validHeaders, + payload: validPayload + }); + + expect(response.statusCode).toBe(200); + const responseBody = JSON.parse(response.body); + expect(responseBody).toHaveProperty('analysisId'); + expect(responseBody).toHaveProperty('status', 'queued'); + expect(responseBody).toHaveProperty('estimatedCompletion'); + expect(typeof responseBody.analysisId).toBe('string'); + expect(responseBody.analysisId).toMatch(/^analysis_\d+_[a-z0-9]+$/); + }); + + it('should reject request without authorization header', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + payload: validPayload + }); + + expect(response.statusCode).toBe(401); + const responseBody = JSON.parse(response.body); + expect(responseBody).toHaveProperty('error', 'Missing or invalid authorization header'); + }); + + it('should reject request with invalid authorization token', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + headers: { authorization: 'Bearer invalid-token' }, + payload: validPayload + }); + + expect(response.statusCode).toBe(401); + const responseBody = JSON.parse(response.body); + expect(responseBody).toHaveProperty('error', 'Invalid authorization token - please use valid JWT or development token'); + }); + + it('should reject request with missing required fields', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + headers: validHeaders, + payload: { + sections: ['Test content'] + // Missing analysisType + } + }); + + expect(response.statusCode).toBe(400); + }); + + it('should reject request with invalid analysisType', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + headers: validHeaders, + payload: { + ...validPayload, + analysisType: 'invalid-type' + } + }); + + expect(response.statusCode).toBe(400); + }); + + it('should reject request with empty sections array', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + headers: validHeaders, + payload: { + ...validPayload, + sections: [] + } + }); + + expect(response.statusCode).toBe(400); + }); + + it('should reject request with too many sections', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + headers: validHeaders, + payload: { + ...validPayload, + sections: new Array(51).fill('content') + } + }); + + expect(response.statusCode).toBe(400); + }); + + it('should reject request with content too large', async () => { + const largeContent = 'x'.repeat(100001); // Exceed individual section limit + const response = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + headers: validHeaders, + payload: { + ...validPayload, + sections: [largeContent] + } + }); + + expect(response.statusCode).toBe(400); + }); + + it('should reject request when total content exceeds 500KB', async () => { + const largeContent = 'x'.repeat(50000); + const sections = new Array(11).fill(largeContent); // 11 * 50KB > 500KB + + const response = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + headers: validHeaders, + payload: { + ...validPayload, + sections + } + }); + + expect(response.statusCode).toBe(400); + const responseBody = JSON.parse(response.body); + expect(responseBody).toHaveProperty('error', 'Content too large. Maximum size: 500KB'); + }); + + it('should handle different analysis types', async () => { + const analysisTypes = ['screenplay', 'character', 'scene'] as const; + + for (const analysisType of analysisTypes) { + const response = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + headers: validHeaders, + payload: { + ...validPayload, + analysisType + } + }); + + expect(response.statusCode).toBe(200); + const responseBody = JSON.parse(response.body); + expect(responseBody.status).toBe('queued'); + } + }); + + it('should handle optional parameters correctly', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + headers: validHeaders, + payload: { + sections: ['Test content'], + analysisType: 'screenplay' + // No options provided + } + }); + + expect(response.statusCode).toBe(200); + }); + + it('should sanitize input content', async () => { + const maliciousContent = 'javascript:void(0)'; + const response = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + headers: validHeaders, + payload: { + ...validPayload, + sections: [maliciousContent] + } + }); + + expect(response.statusCode).toBe(200); + // The sanitization happens internally, but we can verify the request was processed + }); + }); + + describe('GET /api/ai/analysis/:id', () => { + const validHeaders = { + authorization: 'Bearer test-dev-token' + }; + + it('should return 404 for non-existent analysis', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/ai/analysis/non-existent-id', + headers: validHeaders + }); + + expect(response.statusCode).toBe(404); + const responseBody = JSON.parse(response.body); + expect(responseBody).toHaveProperty('error', 'Analysis not found'); + }); + + it('should reject request without authorization', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/ai/analysis/some-id' + }); + + expect(response.statusCode).toBe(401); + }); + + it('should return analysis status for existing analysis', async () => { + // First create an analysis + const createResponse = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + headers: validHeaders, + payload: { + sections: ['Test content'], + analysisType: 'screenplay' + } + }); + + const { analysisId } = JSON.parse(createResponse.body); + + // Then check its status + const response = await app.inject({ + method: 'GET', + url: `/api/ai/analysis/${analysisId}`, + headers: validHeaders + }); + + expect(response.statusCode).toBe(200); + const responseBody = JSON.parse(response.body); + expect(responseBody).toHaveProperty('analysisId', analysisId); + expect(responseBody).toHaveProperty('status'); + expect(responseBody).toHaveProperty('timestamp'); + expect(['queued', 'processing', 'completed', 'failed']).toContain(responseBody.status); + }); + + it('should validate analysis ID format', async () => { + const invalidIds = ['../invalid', 'id with spaces', 'id@with@symbols']; + + for (const invalidId of invalidIds) { + const response = await app.inject({ + method: 'GET', + url: `/api/ai/analysis/${encodeURIComponent(invalidId)}`, + headers: validHeaders + }); + + expect(response.statusCode).toBe(400); + } + }); + }); + + describe('DELETE /api/ai/analysis/:id', () => { + const validHeaders = { + authorization: 'Bearer test-dev-token' + }; + + it('should return 404 for non-existent analysis', async () => { + const response = await app.inject({ + method: 'DELETE', + url: '/api/ai/analysis/non-existent-id', + headers: validHeaders + }); + + expect(response.statusCode).toBe(404); + const responseBody = JSON.parse(response.body); + expect(responseBody).toHaveProperty('error', 'Analysis not found'); + }); + + it('should reject request without authorization', async () => { + const response = await app.inject({ + method: 'DELETE', + url: '/api/ai/analysis/some-id' + }); + + expect(response.statusCode).toBe(401); + }); + + it('should cancel queued analysis', async () => { + // First create an analysis + const createResponse = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + headers: validHeaders, + payload: { + sections: ['Test content'], + analysisType: 'screenplay' + } + }); + + const { analysisId } = JSON.parse(createResponse.body); + + // Then cancel it + const response = await app.inject({ + method: 'DELETE', + url: `/api/ai/analysis/${analysisId}`, + headers: validHeaders + }); + + expect(response.statusCode).toBe(200); + const responseBody = JSON.parse(response.body); + expect(responseBody).toHaveProperty('analysisId', analysisId); + expect(responseBody).toHaveProperty('status', 'cancelled'); + }); + }); + + describe('GET /api/ai/health', () => { + it('should return health status when Gemini API is accessible', async () => { + // Mock successful Gemini API call + const mockModel = { + generateContent: jest.fn().mockResolvedValue({ + response: { + text: () => 'Test response' + } + }) + }; + + const mockGenAI = GoogleGenerativeAI as jest.MockedClass; + mockGenAI.prototype.getGenerativeModel = jest.fn().mockReturnValue(mockModel); + + const response = await app.inject({ + method: 'GET', + url: '/api/ai/health' + }); + + expect(response.statusCode).toBe(200); + const responseBody = JSON.parse(response.body); + expect(responseBody).toHaveProperty('status', 'healthy'); + expect(responseBody).toHaveProperty('service', 'ai-proxy'); + expect(responseBody).toHaveProperty('timestamp'); + expect(responseBody).toHaveProperty('geminiConnected', true); + }); + + it('should return unhealthy status when Gemini API fails', async () => { + // Mock failed Gemini API call + const mockModel = { + generateContent: jest.fn().mockRejectedValue(new Error('API connection failed')) + }; + + const mockGenAI = GoogleGenerativeAI as jest.MockedClass; + mockGenAI.prototype.getGenerativeModel = jest.fn().mockReturnValue(mockModel); + + const response = await app.inject({ + method: 'GET', + url: '/api/ai/health' + }); + + expect(response.statusCode).toBe(503); + const responseBody = JSON.parse(response.body); + expect(responseBody).toHaveProperty('status', 'unhealthy'); + expect(responseBody).toHaveProperty('service', 'ai-proxy'); + expect(responseBody).toHaveProperty('geminiConnected', false); + expect(responseBody).toHaveProperty('error', 'Gemini API connection failed'); + }); + }); + + describe('Authentication Middleware', () => { + it('should accept valid Bearer token', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/ai/analysis/test-id', + headers: { + authorization: 'Bearer test-dev-token' + } + }); + + // Should not get authentication error (though will get 404 for non-existent analysis) + expect(response.statusCode).not.toBe(401); + }); + + it('should reject missing authorization header', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/ai/analysis/test-id' + }); + + expect(response.statusCode).toBe(401); + }); + + it('should reject malformed authorization header', async () => { + const malformedHeaders = [ + 'Basic token', + 'Bearer', + 'token-without-bearer', + 'Bearer ' + ]; + + for (const header of malformedHeaders) { + const response = await app.inject({ + method: 'GET', + url: '/api/ai/analysis/test-id', + headers: { + authorization: header + } + }); + + expect(response.statusCode).toBe(401); + } + }); + }); + + describe('Rate Limiting Middleware', () => { + it('should allow requests within rate limit', async () => { + const headers = { authorization: 'Bearer test-dev-token' }; + const payload = { + sections: ['Test content'], + analysisType: 'screenplay' as const + }; + + // Make multiple requests (should all succeed for testing) + for (let i = 0; i < 3; i++) { + const response = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + headers, + payload + }); + + expect(response.statusCode).toBe(200); + } + }); + }); + + describe('Utility Functions', () => { + describe('generateAnalysisId', () => { + it('should generate unique analysis IDs', () => { + // We can't directly test the function since it's not exported, + // but we can verify unique IDs through the API + const responses: string[] = []; + + const createAnalysis = async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + headers: { authorization: 'Bearer test-dev-token' }, + payload: { + sections: ['Test content'], + analysisType: 'screenplay' + } + }); + return JSON.parse(response.body).analysisId; + }; + + // Create multiple analyses and verify IDs are unique + return Promise.all([ + createAnalysis(), + createAnalysis(), + createAnalysis() + ]).then(ids => { + expect(new Set(ids).size).toBe(3); // All IDs should be unique + ids.forEach(id => { + expect(id).toMatch(/^analysis_\d+_[a-z0-9]+$/); + }); + }); + }); + }); + + describe('Input Sanitization', () => { + it('should sanitize malicious input through API', async () => { + const maliciousInputs = [ + '', + 'javascript:void(0)', + 'data:text/html,', + 'vbscript:msgbox(1)', + 'onclick=alert(1)', + 'onload=alert(1)' + ]; + + for (const maliciousInput of maliciousInputs) { + const response = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + headers: { authorization: 'Bearer test-dev-token' }, + payload: { + sections: [maliciousInput], + analysisType: 'screenplay' + } + }); + + // Should successfully process (sanitization happens internally) + expect(response.statusCode).toBe(200); + } + }); + }); + }); + + describe('Async Analysis Processing', () => { + it('should handle analysis processing workflow', async () => { + // Mock Gemini API for processing + const mockModel = { + generateContent: jest.fn().mockResolvedValue({ + response: { + text: () => 'Analysis result content' + } + }) + }; + + const mockGenAI = GoogleGenerativeAI as jest.MockedClass; + mockGenAI.prototype.getGenerativeModel = jest.fn().mockReturnValue(mockModel); + + // Start analysis + const createResponse = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + headers: { authorization: 'Bearer test-dev-token' }, + payload: { + sections: ['Test screenplay content'], + analysisType: 'screenplay' + } + }); + + expect(createResponse.statusCode).toBe(200); + const { analysisId } = JSON.parse(createResponse.body); + + // Wait a bit for async processing to potentially complete + await new Promise(resolve => setTimeout(resolve, 100)); + + // Check status + const statusResponse = await app.inject({ + method: 'GET', + url: `/api/ai/analysis/${analysisId}`, + headers: { authorization: 'Bearer test-dev-token' } + }); + + expect(statusResponse.statusCode).toBe(200); + const statusBody = JSON.parse(statusResponse.body); + expect(['queued', 'processing', 'completed']).toContain(statusBody.status); + }); + + it('should handle analysis processing errors', async () => { + // Mock Gemini API to fail + const mockModel = { + generateContent: jest.fn().mockRejectedValue(new Error('AI processing failed')) + }; + + const mockGenAI = GoogleGenerativeAI as jest.MockedClass; + mockGenAI.prototype.getGenerativeModel = jest.fn().mockReturnValue(mockModel); + + // Start analysis + const createResponse = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + headers: { authorization: 'Bearer test-dev-token' }, + payload: { + sections: ['Test content'], + analysisType: 'screenplay' + } + }); + + expect(createResponse.statusCode).toBe(200); + const { analysisId } = JSON.parse(createResponse.body); + + // Wait for async processing to complete + await new Promise(resolve => setTimeout(resolve, 200)); + + // Check status should show failed + const statusResponse = await app.inject({ + method: 'GET', + url: `/api/ai/analysis/${analysisId}`, + headers: { authorization: 'Bearer test-dev-token' } + }); + + expect(statusResponse.statusCode).toBe(200); + const statusBody = JSON.parse(statusResponse.body); + expect(['processing', 'failed']).toContain(statusBody.status); + }); + }); + + describe('Analysis Prompt Generation', () => { + it('should generate appropriate prompts for different analysis types', async () => { + const mockModel = { + generateContent: jest.fn().mockResolvedValue({ + response: { + text: () => 'Analysis result' + } + }) + }; + + const mockGenAI = GoogleGenerativeAI as jest.MockedClass; + mockGenAI.prototype.getGenerativeModel = jest.fn().mockReturnValue(mockModel); + + const analysisTypes = ['screenplay', 'character', 'scene'] as const; + + for (const analysisType of analysisTypes) { + const response = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + headers: { authorization: 'Bearer test-dev-token' }, + payload: { + sections: ['Test content'], + analysisType + } + }); + + expect(response.statusCode).toBe(200); + + // Wait for processing + await new Promise(resolve => setTimeout(resolve, 100)); + + // Verify the model was called with content + expect(mockModel.generateContent).toHaveBeenCalled(); + } + }); + }); + + describe('Error Handling', () => { + it('should handle server errors gracefully', async () => { + // This test would be more meaningful with actual error injection + // For now, we verify that malformed requests are handled properly + const response = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + headers: { + authorization: 'Bearer test-dev-token', + 'content-type': 'application/json' + }, + payload: 'invalid-json' + }); + + expect(response.statusCode).toBe(400); + }); + + it('should handle missing environment variables', async () => { + // Temporarily unset environment variable + const originalKey = process.env.GEMINI_API_KEY; + delete process.env.GEMINI_API_KEY; + + try { + const response = await app.inject({ + method: 'GET', + url: '/api/ai/health' + }); + + // Should handle missing API key gracefully + expect([200, 503]).toContain(response.statusCode); + } finally { + // Restore environment variable + if (originalKey) { + process.env.GEMINI_API_KEY = originalKey; + } + } + }); + }); + + describe('Schema Validation', () => { + it('should validate request schemas strictly', async () => { + const invalidPayloads = [ + { + // Missing required fields + }, + { + sections: 'not-an-array', + analysisType: 'screenplay' + }, + { + sections: [123], // Wrong type + analysisType: 'screenplay' + }, + { + sections: [''], // Empty string + analysisType: 'screenplay' + }, + { + sections: ['valid content'], + analysisType: 'invalid-type' + }, + { + sections: ['valid content'], + analysisType: 'screenplay', + options: { + priority: 'invalid-priority' + } + } + ]; + + for (const payload of invalidPayloads) { + const response = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + headers: { authorization: 'Bearer test-dev-token' }, + payload + }); + + expect(response.statusCode).toBe(400); + } + }); + }); + + describe('Memory Management', () => { + it('should handle analysis cleanup on cancellation', async () => { + // Create analysis + const createResponse = await app.inject({ + method: 'POST', + url: '/api/ai/analyze', + headers: { authorization: 'Bearer test-dev-token' }, + payload: { + sections: ['Test content'], + analysisType: 'screenplay' + } + }); + + const { analysisId } = JSON.parse(createResponse.body); + + // Cancel analysis + const cancelResponse = await app.inject({ + method: 'DELETE', + url: `/api/ai/analysis/${analysisId}`, + headers: { authorization: 'Bearer test-dev-token' } + }); + + expect(cancelResponse.statusCode).toBe(200); + + // Verify analysis is cleaned up (status should reflect cancellation) + const statusResponse = await app.inject({ + method: 'GET', + url: `/api/ai/analysis/${analysisId}`, + headers: { authorization: 'Bearer test-dev-token' } + }); + + expect(statusResponse.statusCode).toBe(200); + const statusBody = JSON.parse(statusResponse.body); + expect(statusBody.status).toBe('cancelled'); + }); + }); +}); \ No newline at end of file