From e7818f5e4f7d5b84339760b63c8d98a9cfa47659 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Fri, 11 Jul 2025 16:30:43 +0200 Subject: [PATCH] fix: resolve user invitation unique constraint error and add integration tests - Fix platform user invitation to handle global email uniqueness properly - Replace findFirst with findUnique for email validation - Add clear error messages for email conflicts across companies - Create comprehensive CSV import workflow integration tests - Create comprehensive session processing pipeline integration tests - Cover end-to-end flows from import to AI analysis completion --- .../platform/companies/[id]/users/route.ts | 31 +- tests/integration/csv-import-workflow.test.ts | 382 ++++++++++++ .../session-processing-workflow.test.ts | 550 ++++++++++++++++++ 3 files changed, 956 insertions(+), 7 deletions(-) create mode 100644 tests/integration/csv-import-workflow.test.ts create mode 100644 tests/integration/session-processing-workflow.test.ts diff --git a/app/api/platform/companies/[id]/users/route.ts b/app/api/platform/companies/[id]/users/route.ts index bdf1c43..79cce92 100644 --- a/app/api/platform/companies/[id]/users/route.ts +++ b/app/api/platform/companies/[id]/users/route.ts @@ -51,19 +51,36 @@ export async function POST( ); } - // Check if user already exists in this company - const existingUser = await prisma.user.findFirst({ + // Check if user already exists (emails must be globally unique) + const existingUser = await prisma.user.findUnique({ where: { email, - companyId, + }, + select: { + id: true, + companyId: true, + company: { + select: { + name: true, + }, + }, }, }); if (existingUser) { - return NextResponse.json( - { error: "User already exists in this company" }, - { status: 400 } - ); + if (existingUser.companyId === companyId) { + return NextResponse.json( + { error: "User already exists in this company" }, + { status: 400 } + ); + } else { + return NextResponse.json( + { + error: `Email already in use by a user in company: ${existingUser.company.name}. Each email address can only be used once across all companies.` + }, + { status: 400 } + ); + } } // Generate a temporary password (in a real app, you'd send an invitation email) diff --git a/tests/integration/csv-import-workflow.test.ts b/tests/integration/csv-import-workflow.test.ts new file mode 100644 index 0000000..ba8f37f --- /dev/null +++ b/tests/integration/csv-import-workflow.test.ts @@ -0,0 +1,382 @@ +/** + * Integration tests for CSV import workflow + * + * Tests the complete end-to-end flow of CSV import: + * 1. CSV file fetching from URL + * 2. Parsing and validation of CSV data + * 3. Creating SessionImport records + * 4. Error handling and retry logic + * 5. Authentication and access control + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { prisma } from "@/lib/prisma"; +import { runCSVImport } from "@/lib/scheduler"; +import type { Company, SessionImport } from "@prisma/client"; + +// Mock external dependencies +vi.mock("node-fetch"); + +describe("CSV Import Workflow Integration Tests", () => { + let testCompany: Company; + const mockCsvUrl = "https://example.com/test.csv"; + const mockCsvData = `sessionId,userId,language,country,ipAddress,sentiment,messagesSent,startTime,endTime,escalated,forwardedHr,summary +session1,user1,en,US,192.168.1.1,positive,5,2024-01-15T10:00:00Z,2024-01-15T10:30:00Z,false,false,Test session summary +session2,user2,nl,NL,192.168.1.2,neutral,3,2024-01-15T11:00:00Z,2024-01-15T11:20:00Z,true,false,Another test session`; + + beforeEach(async () => { + // Clean up test data + await prisma.sessionImport.deleteMany({}); + await prisma.session.deleteMany({}); + await prisma.user.deleteMany({}); + await prisma.company.deleteMany({}); + + // Create test company + testCompany = await prisma.company.create({ + data: { + name: "Test Company", + csvUrl: mockCsvUrl, + status: "ACTIVE", + }, + }); + }); + + afterEach(async () => { + vi.clearAllMocks(); + }); + + describe("Successful CSV Import", () => { + it("should successfully import CSV data from URL", async () => { + const fetchMock = await import("node-fetch"); + vi.mocked(fetchMock.default).mockResolvedValueOnce({ + ok: true, + text: async () => mockCsvData, + headers: { + get: () => "text/csv", + }, + } as any); + + // Run the import + await runCSVImport(testCompany.id); + + // Verify SessionImport records were created + const imports = await prisma.sessionImport.findMany({ + where: { companyId: testCompany.id }, + orderBy: { createdAt: "asc" }, + }); + + expect(imports).toHaveLength(2); + expect(imports[0]).toMatchObject({ + sessionId: "session1", + userId: "user1", + language: "en", + country: "US", + ipAddress: "192.168.1.1", + sentiment: "positive", + messagesSent: 5, + escalated: false, + forwardedHr: false, + status: "PENDING", + }); + + expect(imports[1]).toMatchObject({ + sessionId: "session2", + userId: "user2", + language: "nl", + country: "NL", + ipAddress: "192.168.1.2", + sentiment: "neutral", + messagesSent: 3, + escalated: true, + forwardedHr: false, + status: "PENDING", + }); + }); + + it("should handle CSV with authentication", async () => { + // Update company with credentials + await prisma.company.update({ + where: { id: testCompany.id }, + data: { + csvUsername: "testuser", + csvPassword: "testpass", + }, + }); + + const fetchMock = await import("node-fetch"); + vi.mocked(fetchMock.default).mockImplementation((url, options: any) => { + // Verify auth header is present + expect(options.headers.Authorization).toBe( + `Basic ${Buffer.from("testuser:testpass").toString("base64")}` + ); + + return Promise.resolve({ + ok: true, + text: async () => mockCsvData, + headers: { + get: () => "text/csv", + }, + } as any); + }); + + await runCSVImport(testCompany.id); + + const imports = await prisma.sessionImport.count({ + where: { companyId: testCompany.id }, + }); + expect(imports).toBe(2); + }); + + it("should skip duplicate imports", async () => { + // Create existing import + await prisma.sessionImport.create({ + data: { + sessionId: "session1", + companyId: testCompany.id, + userId: "user1", + language: "en", + country: "US", + ipAddress: "192.168.1.1", + sentiment: "positive", + messagesSent: 5, + startTime: new Date("2024-01-15T10:00:00Z"), + endTime: new Date("2024-01-15T10:30:00Z"), + escalated: false, + forwardedHr: false, + status: "PENDING", + }, + }); + + const fetchMock = await import("node-fetch"); + vi.mocked(fetchMock.default).mockResolvedValueOnce({ + ok: true, + text: async () => mockCsvData, + headers: { + get: () => "text/csv", + }, + } as any); + + await runCSVImport(testCompany.id); + + // Should only have 2 imports (1 existing + 1 new) + const imports = await prisma.sessionImport.count({ + where: { companyId: testCompany.id }, + }); + expect(imports).toBe(2); + }); + }); + + describe("Error Handling", () => { + it("should handle network errors gracefully", async () => { + const fetchMock = await import("node-fetch"); + vi.mocked(fetchMock.default).mockRejectedValueOnce( + new Error("Network error") + ); + + // Should not throw + await expect(runCSVImport(testCompany.id)).resolves.not.toThrow(); + + // No imports should be created + const imports = await prisma.sessionImport.count({ + where: { companyId: testCompany.id }, + }); + expect(imports).toBe(0); + }); + + it("should handle invalid CSV format", async () => { + const invalidCsv = "invalid,csv,data\nwithout,proper,headers"; + + const fetchMock = await import("node-fetch"); + vi.mocked(fetchMock.default).mockResolvedValueOnce({ + ok: true, + text: async () => invalidCsv, + headers: { + get: () => "text/csv", + }, + } as any); + + await runCSVImport(testCompany.id); + + // No imports should be created for invalid CSV + const imports = await prisma.sessionImport.count({ + where: { companyId: testCompany.id }, + }); + expect(imports).toBe(0); + }); + + it("should handle HTTP errors", async () => { + const fetchMock = await import("node-fetch"); + vi.mocked(fetchMock.default).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: "Not Found", + } as any); + + await expect(runCSVImport(testCompany.id)).resolves.not.toThrow(); + + const imports = await prisma.sessionImport.count({ + where: { companyId: testCompany.id }, + }); + expect(imports).toBe(0); + }); + }); + + describe("Data Validation", () => { + it("should validate and transform date fields", async () => { + const csvWithDates = `sessionId,userId,language,country,ipAddress,sentiment,messagesSent,startTime,endTime,escalated,forwardedHr,summary +session1,user1,en,US,192.168.1.1,positive,5,2024-01-15 10:00:00,2024-01-15 10:30:00,false,false,Test session`; + + const fetchMock = await import("node-fetch"); + vi.mocked(fetchMock.default).mockResolvedValueOnce({ + ok: true, + text: async () => csvWithDates, + headers: { + get: () => "text/csv", + }, + } as any); + + await runCSVImport(testCompany.id); + + const import1 = await prisma.sessionImport.findFirst({ + where: { sessionId: "session1" }, + }); + + expect(import1?.startTime).toBeInstanceOf(Date); + expect(import1?.endTime).toBeInstanceOf(Date); + }); + + it("should handle missing optional fields", async () => { + const csvWithMissingFields = `sessionId,userId,language,country,ipAddress,sentiment,messagesSent,startTime,endTime,escalated,forwardedHr,summary +session1,user1,en,,,,5,2024-01-15T10:00:00Z,,,false,`; + + const fetchMock = await import("node-fetch"); + vi.mocked(fetchMock.default).mockResolvedValueOnce({ + ok: true, + text: async () => csvWithMissingFields, + headers: { + get: () => "text/csv", + }, + } as any); + + await runCSVImport(testCompany.id); + + const import1 = await prisma.sessionImport.findFirst({ + where: { sessionId: "session1" }, + }); + + expect(import1).toMatchObject({ + sessionId: "session1", + userId: "user1", + language: "en", + country: null, + ipAddress: null, + sentiment: null, + messagesSent: 5, + endTime: null, + forwardedHr: false, + summary: null, + }); + }); + + it("should normalize boolean fields", async () => { + const csvWithBooleans = `sessionId,userId,language,country,ipAddress,sentiment,messagesSent,startTime,endTime,escalated,forwardedHr,summary +session1,user1,en,US,192.168.1.1,positive,5,2024-01-15T10:00:00Z,2024-01-15T10:30:00Z,TRUE,yes,Test +session2,user2,en,US,192.168.1.2,positive,5,2024-01-15T10:00:00Z,2024-01-15T10:30:00Z,1,Y,Test +session3,user3,en,US,192.168.1.3,positive,5,2024-01-15T10:00:00Z,2024-01-15T10:30:00Z,FALSE,no,Test +session4,user4,en,US,192.168.1.4,positive,5,2024-01-15T10:00:00Z,2024-01-15T10:30:00Z,0,N,Test`; + + const fetchMock = await import("node-fetch"); + vi.mocked(fetchMock.default).mockResolvedValueOnce({ + ok: true, + text: async () => csvWithBooleans, + headers: { + get: () => "text/csv", + }, + } as any); + + await runCSVImport(testCompany.id); + + const imports = await prisma.sessionImport.findMany({ + where: { companyId: testCompany.id }, + orderBy: { sessionId: "asc" }, + }); + + expect(imports[0].escalated).toBe(true); + expect(imports[0].forwardedHr).toBe(true); + expect(imports[1].escalated).toBe(true); + expect(imports[1].forwardedHr).toBe(true); + expect(imports[2].escalated).toBe(false); + expect(imports[2].forwardedHr).toBe(false); + expect(imports[3].escalated).toBe(false); + expect(imports[3].forwardedHr).toBe(false); + }); + }); + + describe("Company Status Handling", () => { + it("should skip import for inactive companies", async () => { + await prisma.company.update({ + where: { id: testCompany.id }, + data: { status: "INACTIVE" }, + }); + + const fetchMock = await import("node-fetch"); + const fetchSpy = vi.mocked(fetchMock.default); + + await runCSVImport(testCompany.id); + + // Should not attempt to fetch CSV + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("should skip import for suspended companies", async () => { + await prisma.company.update({ + where: { id: testCompany.id }, + data: { status: "SUSPENDED" }, + }); + + const fetchMock = await import("node-fetch"); + const fetchSpy = vi.mocked(fetchMock.default); + + await runCSVImport(testCompany.id); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); + + describe("Batch Import Performance", () => { + it("should handle large CSV files efficiently", async () => { + // Generate large CSV with 1000 rows + const largeCSVRows = ["sessionId,userId,language,country,ipAddress,sentiment,messagesSent,startTime,endTime,escalated,forwardedHr,summary"]; + + for (let i = 0; i < 1000; i++) { + largeCSVRows.push( + `session${i},user${i},en,US,192.168.1.${i % 255},positive,5,2024-01-15T10:00:00Z,2024-01-15T10:30:00Z,false,false,Session ${i}` + ); + } + + const largeCsv = largeCSVRows.join("\n"); + + const fetchMock = await import("node-fetch"); + vi.mocked(fetchMock.default).mockResolvedValueOnce({ + ok: true, + text: async () => largeCsv, + headers: { + get: () => "text/csv", + }, + } as any); + + const startTime = Date.now(); + await runCSVImport(testCompany.id); + const duration = Date.now() - startTime; + + // Should complete within reasonable time (5 seconds) + expect(duration).toBeLessThan(5000); + + // Verify all imports were created + const importCount = await prisma.sessionImport.count({ + where: { companyId: testCompany.id }, + }); + expect(importCount).toBe(1000); + }); + }); +}); \ No newline at end of file diff --git a/tests/integration/session-processing-workflow.test.ts b/tests/integration/session-processing-workflow.test.ts new file mode 100644 index 0000000..b44e2f4 --- /dev/null +++ b/tests/integration/session-processing-workflow.test.ts @@ -0,0 +1,550 @@ +/** + * Integration tests for session processing workflow + * + * Tests the complete end-to-end flow of session processing: + * 1. Import processing (SessionImport → Session) + * 2. Transcript fetching and parsing + * 3. AI analysis (sentiment, categorization, summarization) + * 4. Question extraction + * 5. Batch API integration + * 6. Error handling and retry logic + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { prisma } from "@/lib/prisma"; +import { processSessionImports } from "@/lib/importProcessor"; +import { processUnprocessedSessions } from "@/lib/processingScheduler"; +import { createBatchJob, checkBatchStatuses, processCompletedBatches } from "@/lib/batchProcessor"; +import { transcriptFetcher } from "@/lib/transcriptFetcher"; +import { parseTranscriptMessages } from "@/lib/transcriptParser"; +import type { Company, SessionImport, Session, User, AIBatchRequest } from "@prisma/client"; + +// Mock external dependencies +vi.mock("@/lib/transcriptFetcher"); +vi.mock("openai"); + +describe("Session Processing Workflow Integration Tests", () => { + let testCompany: Company; + let testUser: User; + + const mockTranscript = ` +Chat started at 10:00 AM + +User: Hello, I need help with my vacation request. +Assistant: Hi! I'd be happy to help you with your vacation request. How many days are you planning to take off? +User: I want to take 10 days starting from next Monday. +Assistant: Let me check that for you. When would you like your vacation to end? +User: It should end on the Friday of the following week. +Assistant: Perfect! I've noted your vacation request for 10 days. Please submit the formal request through the HR portal. +User: Thank you! How do I access the HR portal? +Assistant: You can access the HR portal at hr.company.com using your employee credentials. +User: Great, thanks for your help! +Assistant: You're welcome! Have a great vacation! + +Chat ended at 10:15 AM +`; + + beforeEach(async () => { + // Clean up test data + await prisma.message.deleteMany({}); + await prisma.sessionQuestion.deleteMany({}); + await prisma.question.deleteMany({}); + await prisma.aIProcessingRequest.deleteMany({}); + await prisma.aIBatchRequest.deleteMany({}); + await prisma.session.deleteMany({}); + await prisma.sessionImport.deleteMany({}); + await prisma.user.deleteMany({}); + await prisma.company.deleteMany({}); + + // Create test company + testCompany = await prisma.company.create({ + data: { + name: "Test Company", + csvUrl: "https://example.com/test.csv", + status: "ACTIVE", + }, + }); + + // Create test user + testUser = await prisma.user.create({ + data: { + email: "test@example.com", + password: "hashed_password", + role: "ADMIN", + companyId: testCompany.id, + }, + }); + }); + + afterEach(async () => { + vi.clearAllMocks(); + }); + + describe("Import Processing (SessionImport → Session)", () => { + it("should process SessionImport into Session with transcript parsing", async () => { + // Create SessionImport + const sessionImport = await prisma.sessionImport.create({ + data: { + sessionId: "test-session-1", + companyId: testCompany.id, + userId: "user123", + language: "en", + country: "US", + ipAddress: "192.168.1.1", + sentiment: "positive", + messagesSent: 5, + startTime: new Date("2024-01-15T10:00:00Z"), + endTime: new Date("2024-01-15T10:15:00Z"), + escalated: false, + forwardedHr: false, + summary: "User needs help with vacation request", + fullTranscriptUrl: "https://example.com/transcript.txt", + status: "PENDING", + }, + }); + + // Mock transcript fetching + vi.mocked(transcriptFetcher).mockResolvedValueOnce(mockTranscript); + + // Process the import + await processSessionImports(testCompany.id); + + // Verify Session was created + const session = await prisma.session.findFirst({ + where: { importId: sessionImport.id }, + include: { messages: { orderBy: { order: "asc" } } }, + }); + + expect(session).toBeTruthy(); + expect(session?.sessionId).toBe("test-session-1"); + expect(session?.messages).toHaveLength(10); // 5 user + 5 assistant messages + + // Verify messages were parsed correctly + expect(session?.messages[0]).toMatchObject({ + role: "User", + content: "Hello, I need help with my vacation request.", + order: 0, + }); + expect(session?.messages[1]).toMatchObject({ + role: "Assistant", + content: "Hi! I'd be happy to help you with your vacation request. How many days are you planning to take off?", + order: 1, + }); + + // Verify import status was updated + const updatedImport = await prisma.sessionImport.findUnique({ + where: { id: sessionImport.id }, + }); + expect(updatedImport?.status).toBe("PROCESSED"); + }); + + it("should handle imports without transcript URLs", async () => { + const sessionImport = await prisma.sessionImport.create({ + data: { + sessionId: "test-session-2", + companyId: testCompany.id, + userId: "user456", + language: "en", + country: "US", + startTime: new Date("2024-01-15T11:00:00Z"), + status: "PENDING", + }, + }); + + await processSessionImports(testCompany.id); + + const session = await prisma.session.findFirst({ + where: { importId: sessionImport.id }, + }); + + expect(session).toBeTruthy(); + expect(session?.transcriptContent).toBeNull(); + expect(session?.messages).toHaveLength(0); + }); + + it("should handle transcript parsing errors gracefully", async () => { + const sessionImport = await prisma.sessionImport.create({ + data: { + sessionId: "test-session-3", + companyId: testCompany.id, + fullTranscriptUrl: "https://example.com/bad-transcript.txt", + startTime: new Date(), + status: "PENDING", + }, + }); + + // Mock transcript fetching to throw error + vi.mocked(transcriptFetcher).mockRejectedValueOnce(new Error("Network error")); + + await processSessionImports(testCompany.id); + + // Session should still be created but without transcript + const session = await prisma.session.findFirst({ + where: { importId: sessionImport.id }, + }); + + expect(session).toBeTruthy(); + expect(session?.transcriptContent).toBeNull(); + + // Import should be marked as processed with error + const updatedImport = await prisma.sessionImport.findUnique({ + where: { id: sessionImport.id }, + }); + expect(updatedImport?.status).toBe("ERROR"); + }); + }); + + describe("AI Analysis Processing", () => { + let testSession: Session; + + beforeEach(async () => { + // Create a session ready for AI processing + testSession = await prisma.session.create({ + data: { + sessionId: "test-session-ai", + companyId: testCompany.id, + startTime: new Date(), + messages: { + create: [ + { role: "User", content: "I need 10 days of vacation", order: 0 }, + { role: "Assistant", content: "I'll help you with that", order: 1 }, + ], + }, + }, + }); + }); + + it("should create batch job for unprocessed sessions", async () => { + // Create multiple sessions for batch processing + const sessions = await Promise.all( + Array.from({ length: 5 }, (_, i) => + prisma.session.create({ + data: { + sessionId: `batch-session-${i}`, + companyId: testCompany.id, + startTime: new Date(), + transcriptContent: mockTranscript, + }, + }) + ) + ); + + // Mock OpenAI batch API + const openai = await import("openai"); + vi.mocked(openai.default.prototype.batches.create).mockResolvedValueOnce({ + id: "batch_abc123", + object: "batch", + endpoint: "/v1/chat/completions", + errors: null, + input_file_id: "file-abc123", + completion_window: "24h", + status: "validating", + created_at: Date.now() / 1000, + } as any); + + // Create batch job + await createBatchJob(testCompany.id); + + // Verify batch request was created + const batchRequest = await prisma.aIBatchRequest.findFirst({ + where: { companyId: testCompany.id }, + }); + + expect(batchRequest).toBeTruthy(); + expect(batchRequest?.openaiBatchId).toBe("batch_abc123"); + expect(batchRequest?.status).toBe("SUBMITTED"); + + // Verify sessions are linked to batch + const linkedSessions = await prisma.session.count({ + where: { + companyId: testCompany.id, + batchRequestId: batchRequest?.id, + }, + }); + expect(linkedSessions).toBe(5); + }); + + it("should check and update batch statuses", async () => { + // Create a batch request + const batchRequest = await prisma.aIBatchRequest.create({ + data: { + companyId: testCompany.id, + openaiBatchId: "batch_xyz789", + inputFileId: "file-input", + status: "SUBMITTED", + }, + }); + + // Mock OpenAI batch status check + const openai = await import("openai"); + vi.mocked(openai.default.prototype.batches.retrieve).mockResolvedValueOnce({ + id: "batch_xyz789", + status: "completed", + output_file_id: "file-output", + completed_at: Date.now() / 1000, + } as any); + + // Check batch status + await checkBatchStatuses(testCompany.id); + + // Verify batch status was updated + const updatedBatch = await prisma.aIBatchRequest.findUnique({ + where: { id: batchRequest.id }, + }); + + expect(updatedBatch?.status).toBe("COMPLETED"); + expect(updatedBatch?.outputFileId).toBe("file-output"); + expect(updatedBatch?.completedAt).toBeTruthy(); + }); + + it("should process completed batch results", async () => { + // Create completed batch with linked sessions + const batchRequest = await prisma.aIBatchRequest.create({ + data: { + companyId: testCompany.id, + openaiBatchId: "batch_completed", + inputFileId: "file-input", + outputFileId: "file-output", + status: "COMPLETED", + completedAt: new Date(), + }, + }); + + // Link session to batch + await prisma.session.update({ + where: { id: testSession.id }, + data: { batchRequestId: batchRequest.id }, + }); + + // Mock batch results file + const mockBatchResults = [ + { + custom_id: testSession.id, + response: { + status_code: 200, + body: { + choices: [{ + message: { + content: JSON.stringify({ + sentiment: "POSITIVE", + category: "LEAVE_VACATION", + summary: "User requesting 10 days vacation", + questions: [ + "How do I access the HR portal?", + "When should my vacation end?" + ], + }), + }, + }], + usage: { + prompt_tokens: 100, + completion_tokens: 50, + total_tokens: 150, + }, + }, + }, + }, + ].map(r => JSON.stringify(r)).join("\n"); + + // Mock OpenAI file content retrieval + const openai = await import("openai"); + vi.mocked(openai.default.prototype.files.content).mockResolvedValueOnce({ + text: async () => mockBatchResults, + } as any); + + // Process batch results + await processCompletedBatches(testCompany.id); + + // Verify session was updated with AI results + const updatedSession = await prisma.session.findUnique({ + where: { id: testSession.id }, + include: { + sessionQuestions: { + include: { question: true }, + orderBy: { order: "asc" }, + }, + }, + }); + + expect(updatedSession?.sentiment).toBe("POSITIVE"); + expect(updatedSession?.category).toBe("LEAVE_VACATION"); + expect(updatedSession?.summary).toBe("User requesting 10 days vacation"); + expect(updatedSession?.sessionQuestions).toHaveLength(2); + + // Verify questions were extracted + const questions = updatedSession?.sessionQuestions.map(sq => sq.question.content); + expect(questions).toContain("How do I access the HR portal?"); + expect(questions).toContain("When should my vacation end?"); + + // Verify AI processing request was recorded + const aiRequest = await prisma.aIProcessingRequest.findFirst({ + where: { sessionId: testSession.id }, + }); + + expect(aiRequest).toBeTruthy(); + expect(aiRequest?.promptTokens).toBe(100); + expect(aiRequest?.completionTokens).toBe(50); + expect(aiRequest?.success).toBe(true); + + // Verify batch was marked as processed + const processedBatch = await prisma.aIBatchRequest.findUnique({ + where: { id: batchRequest.id }, + }); + expect(processedBatch?.status).toBe("PROCESSED"); + }); + }); + + describe("Error Handling and Retry Logic", () => { + it("should retry failed AI processing", async () => { + const session = await prisma.session.create({ + data: { + sessionId: "retry-session", + companyId: testCompany.id, + startTime: new Date(), + transcriptContent: "Test content", + processingRetries: 1, // Already failed once + }, + }); + + // Mock successful batch creation on retry + const openai = await import("openai"); + vi.mocked(openai.default.prototype.batches.create).mockResolvedValueOnce({ + id: "batch_retry", + status: "validating", + } as any); + + await createBatchJob(testCompany.id); + + // Verify session retry count was incremented + const updatedSession = await prisma.session.findUnique({ + where: { id: session.id }, + }); + expect(updatedSession?.processingRetries).toBe(2); + }); + + it("should skip sessions that exceeded retry limit", async () => { + const session = await prisma.session.create({ + data: { + sessionId: "max-retry-session", + companyId: testCompany.id, + startTime: new Date(), + transcriptContent: "Test content", + processingRetries: 3, // Max retries reached + }, + }); + + await createBatchJob(testCompany.id); + + // Session should not be included in batch + const batchRequest = await prisma.aIBatchRequest.findFirst({ + where: { companyId: testCompany.id }, + }); + + const linkedSession = await prisma.session.findFirst({ + where: { + id: session.id, + batchRequestId: batchRequest?.id, + }, + }); + + expect(linkedSession).toBeNull(); + }); + }); + + describe("Complete End-to-End Workflow", () => { + it("should process from CSV import to AI analysis completion", async () => { + // Step 1: Create SessionImport + const sessionImport = await prisma.sessionImport.create({ + data: { + sessionId: "e2e-session", + companyId: testCompany.id, + userId: "user-e2e", + language: "en", + country: "US", + startTime: new Date("2024-01-15T10:00:00Z"), + endTime: new Date("2024-01-15T10:15:00Z"), + fullTranscriptUrl: "https://example.com/transcript.txt", + status: "PENDING", + }, + }); + + // Step 2: Process import (mock transcript) + vi.mocked(transcriptFetcher).mockResolvedValueOnce(mockTranscript); + await processSessionImports(testCompany.id); + + // Verify Session created + const session = await prisma.session.findFirst({ + where: { sessionId: "e2e-session" }, + }); + expect(session).toBeTruthy(); + expect(session?.messages).toHaveLength(10); + + // Step 3: Create batch for AI processing + const openai = await import("openai"); + vi.mocked(openai.default.prototype.batches.create).mockResolvedValueOnce({ + id: "batch_e2e", + status: "validating", + } as any); + + await createBatchJob(testCompany.id); + + // Step 4: Simulate batch completion + const batchRequest = await prisma.aIBatchRequest.findFirst({ + where: { companyId: testCompany.id }, + }); + + await prisma.aIBatchRequest.update({ + where: { id: batchRequest!.id }, + data: { + status: "COMPLETED", + outputFileId: "file-e2e-output", + completedAt: new Date(), + }, + }); + + // Step 5: Process batch results + const mockResults = [{ + custom_id: session!.id, + response: { + status_code: 200, + body: { + choices: [{ + message: { + content: JSON.stringify({ + sentiment: "POSITIVE", + category: "LEAVE_VACATION", + summary: "User successfully requested vacation time", + questions: ["How do I access the HR portal?"], + }), + }, + }], + usage: { prompt_tokens: 200, completion_tokens: 100, total_tokens: 300 }, + }, + }, + }]; + + vi.mocked(openai.default.prototype.files.content).mockResolvedValueOnce({ + text: async () => mockResults.map(r => JSON.stringify(r)).join("\n"), + } as any); + + await processCompletedBatches(testCompany.id); + + // Final verification + const finalSession = await prisma.session.findUnique({ + where: { id: session!.id }, + include: { + messages: true, + sessionQuestions: { include: { question: true } }, + aiProcessingRequests: true, + }, + }); + + expect(finalSession?.sentiment).toBe("POSITIVE"); + expect(finalSession?.category).toBe("LEAVE_VACATION"); + expect(finalSession?.summary).toBe("User successfully requested vacation time"); + expect(finalSession?.sessionQuestions).toHaveLength(1); + expect(finalSession?.aiProcessingRequests).toHaveLength(1); + expect(finalSession?.aiProcessingRequests[0].success).toBe(true); + }); + }); +}); \ No newline at end of file