From e1ab460637ef34b6b5f22f0f0e2ff4f27529ad51 Mon Sep 17 00:00:00 2001
From: "coderabbitai[bot]"
<136622811+coderabbitai[bot]@users.noreply.github.com>
Date: Wed, 16 Jul 2025 03:39:17 +0000
Subject: [PATCH] CodeRabbit Generated Unit Tests: Add comprehensive Jest unit
tests for AI proxy route endpoints and behaviors
---
.../routes/__tests__/aiProxyRoutes.test.ts | 769 ++++++++++++++++++
1 file changed, 769 insertions(+)
create mode 100644 src/backend/routes/__tests__/aiProxyRoutes.test.ts
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