mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 12:52:09 +01:00
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
This commit is contained in:
@ -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)
|
||||
|
||||
382
tests/integration/csv-import-workflow.test.ts
Normal file
382
tests/integration/csv-import-workflow.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
550
tests/integration/session-processing-workflow.test.ts
Normal file
550
tests/integration/session-processing-workflow.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user