feat: implement comprehensive email system with rate limiting and extensive test suite

- Add robust email service with rate limiting and configuration management
- Implement shared rate limiter utility for consistent API protection
- Create comprehensive test suite for core processing pipeline
- Add API tests for dashboard metrics and authentication routes
- Fix date range picker infinite loop issue
- Improve session lookup in refresh sessions API
- Refactor session API routing with better code organization
- Update processing pipeline status monitoring
- Clean up leftover files and improve code formatting
This commit is contained in:
2025-07-05 13:42:47 +02:00
committed by Kaj Kowalski
parent 19628233ea
commit a0ac60cf04
36 changed files with 10714 additions and 5292 deletions

View File

@ -0,0 +1,458 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { POST as registerPOST } from "../../app/api/register/route";
import { POST as forgotPasswordPOST } from "../../app/api/forgot-password/route";
import { NextRequest } from "next/server";
// Mock bcrypt
vi.mock("bcryptjs", () => ({
default: {
hash: vi.fn().mockResolvedValue("hashed-password"),
compare: vi.fn().mockResolvedValue(true),
},
}));
// Mock crypto
vi.mock("node:crypto", () => ({
default: {
randomBytes: vi.fn().mockReturnValue({
toString: vi.fn().mockReturnValue("random-token"),
}),
},
}));
// Mock prisma
vi.mock("../../lib/prisma", () => ({
prisma: {
user: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
company: {
findUnique: vi.fn(),
},
},
}));
// Mock email service
vi.mock("../../lib/sendEmail", () => ({
sendEmail: vi.fn().mockResolvedValue({ success: true }),
}));
// Mock rate limiter
vi.mock("../../lib/rateLimiter", () => ({
InMemoryRateLimiter: vi.fn().mockImplementation(() => ({
checkRateLimit: vi.fn().mockReturnValue({ allowed: true }),
})),
extractClientIP: vi.fn().mockReturnValue("192.168.1.1"),
}));
describe("Authentication API Routes", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("POST /api/register", () => {
it("should register a new user successfully", async () => {
const { prisma } = await import("../../lib/prisma");
const mockCompany = {
id: "company1",
name: "Test Company",
status: "ACTIVE",
csvUrl: "http://example.com/data.csv",
createdAt: new Date(),
updatedAt: new Date(),
};
const mockUser = {
id: "user1",
email: "test@example.com",
name: "Test User",
companyId: "company1",
role: "USER",
password: "hashed-password",
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(prisma.company.findUnique).mockResolvedValue(mockCompany);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.create).mockResolvedValue(mockUser);
const request = new NextRequest("http://localhost:3000/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Test User",
email: "test@example.com",
password: "password123",
companyId: "company1",
}),
});
const response = await registerPOST(request);
expect(response.status).toBe(201);
const data = await response.json();
expect(data.message).toBe("User created successfully");
expect(data.user.email).toBe("test@example.com");
});
it("should return 400 for missing required fields", async () => {
const request = new NextRequest("http://localhost:3000/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "test@example.com",
// Missing name, password, companyId
}),
});
const response = await registerPOST(request);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toBe("Missing required fields");
});
it("should return 400 for invalid email format", async () => {
const request = new NextRequest("http://localhost:3000/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Test User",
email: "invalid-email",
password: "password123",
companyId: "company1",
}),
});
const response = await registerPOST(request);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toBe("Invalid email format");
});
it("should return 400 for weak password", async () => {
const request = new NextRequest("http://localhost:3000/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Test User",
email: "test@example.com",
password: "123", // Too short
companyId: "company1",
}),
});
const response = await registerPOST(request);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toBe("Password must be at least 8 characters long");
});
it("should return 404 for non-existent company", async () => {
const { prisma } = await import("../../lib/prisma");
vi.mocked(prisma.company.findUnique).mockResolvedValue(null);
const request = new NextRequest("http://localhost:3000/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Test User",
email: "test@example.com",
password: "password123",
companyId: "non-existent",
}),
});
const response = await registerPOST(request);
expect(response.status).toBe(404);
const data = await response.json();
expect(data.error).toBe("Company not found");
});
it("should return 409 for existing user email", async () => {
const { prisma } = await import("../../lib/prisma");
const mockCompany = {
id: "company1",
name: "Test Company",
status: "ACTIVE",
csvUrl: "http://example.com/data.csv",
createdAt: new Date(),
updatedAt: new Date(),
};
const existingUser = {
id: "existing-user",
email: "test@example.com",
name: "Existing User",
companyId: "company1",
role: "USER",
password: "hashed-password",
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(prisma.company.findUnique).mockResolvedValue(mockCompany);
vi.mocked(prisma.user.findUnique).mockResolvedValue(existingUser);
const request = new NextRequest("http://localhost:3000/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Test User",
email: "test@example.com",
password: "password123",
companyId: "company1",
}),
});
const response = await registerPOST(request);
expect(response.status).toBe(409);
const data = await response.json();
expect(data.error).toBe("User already exists");
});
it("should handle rate limiting", async () => {
const { InMemoryRateLimiter } = await import("../../lib/rateLimiter");
// Mock rate limiter to return not allowed
const mockRateLimiter = {
checkRateLimit: vi.fn().mockReturnValue({
allowed: false,
resetTime: Date.now() + 60000,
}),
};
vi.mocked(InMemoryRateLimiter).mockImplementation(() => mockRateLimiter);
const request = new NextRequest("http://localhost:3000/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Test User",
email: "test@example.com",
password: "password123",
companyId: "company1",
}),
});
const response = await registerPOST(request);
expect(response.status).toBe(429);
const data = await response.json();
expect(data.error).toBe(
"Too many registration attempts. Please try again later."
);
});
});
describe("POST /api/forgot-password", () => {
it("should send password reset email for existing user", async () => {
const { prisma } = await import("../../lib/prisma");
const { sendEmail } = await import("../../lib/sendEmail");
const existingUser = {
id: "user1",
email: "test@example.com",
name: "Test User",
companyId: "company1",
role: "USER",
password: "hashed-password",
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(prisma.user.findUnique).mockResolvedValue(existingUser);
vi.mocked(prisma.user.update).mockResolvedValue({
...existingUser,
resetToken: "random-token",
resetTokenExpiry: new Date(Date.now() + 3600000),
});
const request = new NextRequest(
"http://localhost:3000/api/forgot-password",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "test@example.com",
}),
}
);
const response = await forgotPasswordPOST(request);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.message).toBe("Password reset email sent");
expect(sendEmail).toHaveBeenCalled();
});
it("should return success even for non-existent users (security)", async () => {
const { prisma } = await import("../../lib/prisma");
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
const request = new NextRequest(
"http://localhost:3000/api/forgot-password",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "nonexistent@example.com",
}),
}
);
const response = await forgotPasswordPOST(request);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.message).toBe("Password reset email sent");
});
it("should return 400 for invalid email", async () => {
const request = new NextRequest(
"http://localhost:3000/api/forgot-password",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "invalid-email",
}),
}
);
const response = await forgotPasswordPOST(request);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toBe("Invalid email address");
});
it("should return 400 for missing email", async () => {
const request = new NextRequest(
"http://localhost:3000/api/forgot-password",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
}
);
const response = await forgotPasswordPOST(request);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toBe("Email is required");
});
it("should handle database errors gracefully", async () => {
const { prisma } = await import("../../lib/prisma");
vi.mocked(prisma.user.findUnique).mockRejectedValue(
new Error("Database connection failed")
);
const request = new NextRequest(
"http://localhost:3000/api/forgot-password",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "test@example.com",
}),
}
);
const response = await forgotPasswordPOST(request);
expect(response.status).toBe(500);
const data = await response.json();
expect(data.error).toBe("Internal server error");
});
it("should handle email sending failures gracefully", async () => {
const { prisma } = await import("../../lib/prisma");
const { sendEmail } = await import("../../lib/sendEmail");
const existingUser = {
id: "user1",
email: "test@example.com",
name: "Test User",
companyId: "company1",
role: "USER",
password: "hashed-password",
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(prisma.user.findUnique).mockResolvedValue(existingUser);
vi.mocked(prisma.user.update).mockResolvedValue({
...existingUser,
resetToken: "random-token",
resetTokenExpiry: new Date(Date.now() + 3600000),
});
vi.mocked(sendEmail).mockResolvedValue({
success: false,
error: "Email service unavailable",
});
const request = new NextRequest(
"http://localhost:3000/api/forgot-password",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "test@example.com",
}),
}
);
const response = await forgotPasswordPOST(request);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.message).toBe("Password reset email sent");
});
});
});

View File

@ -0,0 +1,409 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { GET } from "../../app/api/dashboard/metrics/route";
import { NextRequest } from "next/server";
// Mock NextAuth
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
// Mock prisma
vi.mock("../../lib/prisma", () => ({
prisma: {
session: {
count: vi.fn(),
findMany: vi.fn(),
aggregate: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
company: {
findUnique: vi.fn(),
},
},
}));
// Mock auth options
vi.mock("../../lib/auth", () => ({
authOptions: {},
}));
describe("/api/dashboard/metrics", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("GET /api/dashboard/metrics", () => {
it("should return 401 for unauthenticated users", async () => {
const { getServerSession } = await import("next-auth");
vi.mocked(getServerSession).mockResolvedValue(null);
const request = new NextRequest(
"http://localhost:3000/api/dashboard/metrics"
);
const response = await GET(request);
expect(response.status).toBe(401);
const data = await response.json();
expect(data.error).toBe("Unauthorized");
});
it("should return 404 when user not found", async () => {
const { getServerSession } = await import("next-auth");
const { prisma } = await import("../../lib/prisma");
vi.mocked(getServerSession).mockResolvedValue({
user: { email: "test@example.com" },
expires: "2024-12-31",
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
const request = new NextRequest(
"http://localhost:3000/api/dashboard/metrics"
);
const response = await GET(request);
expect(response.status).toBe(404);
const data = await response.json();
expect(data.error).toBe("User not found");
});
it("should return 404 when company not found", async () => {
const { getServerSession } = await import("next-auth");
const { prisma } = await import("../../lib/prisma");
vi.mocked(getServerSession).mockResolvedValue({
user: { email: "test@example.com" },
expires: "2024-12-31",
});
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user1",
email: "test@example.com",
companyId: "company1",
role: "ADMIN",
password: "hashed",
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(prisma.company.findUnique).mockResolvedValue(null);
const request = new NextRequest(
"http://localhost:3000/api/dashboard/metrics"
);
const response = await GET(request);
expect(response.status).toBe(404);
const data = await response.json();
expect(data.error).toBe("Company not found");
});
it("should return metrics data for valid requests", async () => {
const { getServerSession } = await import("next-auth");
const { prisma } = await import("../../lib/prisma");
const mockUser = {
id: "user1",
email: "test@example.com",
companyId: "company1",
role: "ADMIN",
password: "hashed",
createdAt: new Date(),
updatedAt: new Date(),
};
const mockCompany = {
id: "company1",
name: "Test Company",
csvUrl: "http://example.com/data.csv",
sentimentAlert: 0.5,
status: "ACTIVE" as const,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockSessions = [
{
id: "session1",
sessionId: "s1",
companyId: "company1",
startTime: new Date("2024-01-01T10:00:00Z"),
endTime: new Date("2024-01-01T10:30:00Z"),
sentiment: "POSITIVE",
messagesSent: 5,
avgResponseTime: 2.5,
tokens: 100,
tokensEur: 0.002,
language: "en",
country: "US",
category: "SUPPORT",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "session2",
sessionId: "s2",
companyId: "company1",
startTime: new Date("2024-01-02T14:00:00Z"),
endTime: new Date("2024-01-02T14:15:00Z"),
sentiment: "NEGATIVE",
messagesSent: 3,
avgResponseTime: 1.8,
tokens: 75,
tokensEur: 0.0015,
language: "es",
country: "ES",
category: "BILLING",
createdAt: new Date(),
updatedAt: new Date(),
},
];
vi.mocked(getServerSession).mockResolvedValue({
user: { email: "test@example.com" },
expires: "2024-12-31",
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
vi.mocked(prisma.company.findUnique).mockResolvedValue(mockCompany);
vi.mocked(prisma.session.findMany).mockResolvedValue(mockSessions);
vi.mocked(prisma.session.count).mockResolvedValue(2);
const request = new NextRequest(
"http://localhost:3000/api/dashboard/metrics"
);
const response = await GET(request);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.metrics).toBeDefined();
expect(data.company).toBeDefined();
expect(data.metrics.totalSessions).toBe(2);
expect(data.company.name).toBe("Test Company");
});
it("should handle date range filtering", async () => {
const { getServerSession } = await import("next-auth");
const { prisma } = await import("../../lib/prisma");
const mockUser = {
id: "user1",
email: "test@example.com",
companyId: "company1",
role: "ADMIN",
password: "hashed",
createdAt: new Date(),
updatedAt: new Date(),
};
const mockCompany = {
id: "company1",
name: "Test Company",
csvUrl: "http://example.com/data.csv",
sentimentAlert: 0.5,
status: "ACTIVE" as const,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(getServerSession).mockResolvedValue({
user: { email: "test@example.com" },
expires: "2024-12-31",
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
vi.mocked(prisma.company.findUnique).mockResolvedValue(mockCompany);
vi.mocked(prisma.session.findMany).mockResolvedValue([]);
vi.mocked(prisma.session.count).mockResolvedValue(0);
const request = new NextRequest(
"http://localhost:3000/api/dashboard/metrics?startDate=2024-01-01&endDate=2024-01-31"
);
const response = await GET(request);
expect(response.status).toBe(200);
expect(prisma.session.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
companyId: "company1",
startTime: expect.objectContaining({
gte: expect.any(Date),
lte: expect.any(Date),
}),
}),
})
);
});
it("should calculate metrics correctly", async () => {
const { getServerSession } = await import("next-auth");
const { prisma } = await import("../../lib/prisma");
const mockUser = {
id: "user1",
email: "test@example.com",
companyId: "company1",
role: "ADMIN",
password: "hashed",
createdAt: new Date(),
updatedAt: new Date(),
};
const mockCompany = {
id: "company1",
name: "Test Company",
csvUrl: "http://example.com/data.csv",
sentimentAlert: 0.5,
status: "ACTIVE" as const,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockSessions = [
{
id: "session1",
sessionId: "s1",
companyId: "company1",
startTime: new Date("2024-01-01T10:00:00Z"),
endTime: new Date("2024-01-01T10:30:00Z"),
sentiment: "POSITIVE",
messagesSent: 5,
avgResponseTime: 2.0,
tokens: 100,
tokensEur: 0.002,
language: "en",
country: "US",
category: "SUPPORT",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "session2",
sessionId: "s2",
companyId: "company1",
startTime: new Date("2024-01-01T14:00:00Z"),
endTime: new Date("2024-01-01T14:20:00Z"),
sentiment: "NEGATIVE",
messagesSent: 3,
avgResponseTime: 3.0,
tokens: 150,
tokensEur: 0.003,
language: "en",
country: "US",
category: "BILLING",
createdAt: new Date(),
updatedAt: new Date(),
},
];
vi.mocked(getServerSession).mockResolvedValue({
user: { email: "test@example.com" },
expires: "2024-12-31",
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
vi.mocked(prisma.company.findUnique).mockResolvedValue(mockCompany);
vi.mocked(prisma.session.findMany).mockResolvedValue(mockSessions);
vi.mocked(prisma.session.count).mockResolvedValue(2);
const request = new NextRequest(
"http://localhost:3000/api/dashboard/metrics"
);
const response = await GET(request);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.metrics.totalSessions).toBe(2);
expect(data.metrics.avgResponseTime).toBe(2.5); // (2.0 + 3.0) / 2
expect(data.metrics.totalTokens).toBe(250); // 100 + 150
expect(data.metrics.totalTokensEur).toBe(0.005); // 0.002 + 0.003
expect(data.metrics.sentimentPositiveCount).toBe(1);
expect(data.metrics.sentimentNegativeCount).toBe(1);
expect(data.metrics.languages).toEqual({ en: 2 });
expect(data.metrics.countries).toEqual({ US: 2 });
expect(data.metrics.categories).toEqual({ SUPPORT: 1, BILLING: 1 });
});
it("should handle errors gracefully", async () => {
const { getServerSession } = await import("next-auth");
const { prisma } = await import("../../lib/prisma");
vi.mocked(getServerSession).mockResolvedValue({
user: { email: "test@example.com" },
expires: "2024-12-31",
});
vi.mocked(prisma.user.findUnique).mockRejectedValue(
new Error("Database error")
);
const request = new NextRequest(
"http://localhost:3000/api/dashboard/metrics"
);
const response = await GET(request);
expect(response.status).toBe(500);
const data = await response.json();
expect(data.error).toBe("Database error");
});
it("should return empty metrics for companies with no sessions", async () => {
const { getServerSession } = await import("next-auth");
const { prisma } = await import("../../lib/prisma");
const mockUser = {
id: "user1",
email: "test@example.com",
companyId: "company1",
role: "ADMIN",
password: "hashed",
createdAt: new Date(),
updatedAt: new Date(),
};
const mockCompany = {
id: "company1",
name: "Test Company",
csvUrl: "http://example.com/data.csv",
sentimentAlert: 0.5,
status: "ACTIVE" as const,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(getServerSession).mockResolvedValue({
user: { email: "test@example.com" },
expires: "2024-12-31",
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
vi.mocked(prisma.company.findUnique).mockResolvedValue(mockCompany);
vi.mocked(prisma.session.findMany).mockResolvedValue([]);
vi.mocked(prisma.session.count).mockResolvedValue(0);
const request = new NextRequest(
"http://localhost:3000/api/dashboard/metrics"
);
const response = await GET(request);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.metrics.totalSessions).toBe(0);
expect(data.metrics.avgResponseTime).toBe(0);
expect(data.metrics.totalTokens).toBe(0);
expect(data.metrics.languages).toEqual({});
expect(data.metrics.countries).toEqual({});
expect(data.metrics.categories).toEqual({});
});
});
});

View File

@ -0,0 +1,229 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { PrismaClient } from "@prisma/client";
import { processQueuedImports } from "../../lib/importProcessor";
import { ProcessingStatusManager } from "../../lib/processingStatusManager";
vi.mock("../../lib/prisma", () => ({
prisma: new PrismaClient(),
}));
vi.mock("../../lib/processingStatusManager", () => ({
ProcessingStatusManager: {
initializeStage: vi.fn(),
startStage: vi.fn(),
completeStage: vi.fn(),
failStage: vi.fn(),
skipStage: vi.fn(),
},
}));
describe("Import Processor", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("processQueuedImports", () => {
it("should process imports within specified limit", async () => {
const mockSessionImports = [
{
id: "import1",
companyId: "company1",
externalSessionId: "session1",
startTimeRaw: "2024-01-01 10:00:00",
endTimeRaw: "2024-01-01 11:00:00",
ipAddress: "192.168.1.1",
countryCode: "US",
language: "en",
messagesSent: 5,
sentimentRaw: "positive",
escalatedRaw: "false",
forwardedHrRaw: "false",
fullTranscriptUrl: "http://example.com/transcript1",
avgResponseTimeSeconds: 2.5,
tokens: 100,
tokensEur: 0.002,
category: "SUPPORT",
initialMessage: "Hello, I need help",
},
];
// Mock the prisma queries
const prismaMock = {
sessionImport: {
findMany: vi.fn().mockResolvedValue(mockSessionImports),
},
session: {
create: vi.fn().mockResolvedValue({
id: "new-session-id",
companyId: "company1",
sessionId: "session1",
}),
},
};
// Replace the prisma import with our mock
vi.doMock("../../lib/prisma", () => ({
prisma: prismaMock,
}));
const result = await processQueuedImports(10);
expect(prismaMock.sessionImport.findMany).toHaveBeenCalledWith({
where: {
processingStatus: {
some: {
stage: "CSV_IMPORT",
status: "COMPLETED",
},
none: {
stage: "SESSION_CREATION",
status: "COMPLETED",
},
},
},
take: 10,
orderBy: { createdAt: "asc" },
});
expect(result.processed).toBe(1);
expect(result.total).toBe(1);
});
it("should handle processing errors gracefully", async () => {
const mockSessionImports = [
{
id: "import1",
companyId: "company1",
externalSessionId: "session1",
startTimeRaw: "invalid-date",
endTimeRaw: "2024-01-01 11:00:00",
},
];
const prismaMock = {
sessionImport: {
findMany: vi.fn().mockResolvedValue(mockSessionImports),
},
session: {
create: vi.fn().mockRejectedValue(new Error("Database error")),
},
};
vi.doMock("../../lib/prisma", () => ({
prisma: prismaMock,
}));
const result = await processQueuedImports(10);
expect(ProcessingStatusManager.failStage).toHaveBeenCalled();
expect(result.processed).toBe(0);
expect(result.errors).toBe(1);
});
it("should correctly parse sentiment values", async () => {
const testCases = [
{ sentimentRaw: "positive", expected: "POSITIVE" },
{ sentimentRaw: "negative", expected: "NEGATIVE" },
{ sentimentRaw: "neutral", expected: "NEUTRAL" },
{ sentimentRaw: "unknown", expected: "NEUTRAL" },
{ sentimentRaw: null, expected: "NEUTRAL" },
];
for (const testCase of testCases) {
const mockImport = {
id: "import1",
companyId: "company1",
externalSessionId: "session1",
sentimentRaw: testCase.sentimentRaw,
startTimeRaw: "2024-01-01 10:00:00",
endTimeRaw: "2024-01-01 11:00:00",
};
const prismaMock = {
sessionImport: {
findMany: vi.fn().mockResolvedValue([mockImport]),
},
session: {
create: vi.fn().mockImplementation((data) => {
expect(data.data.sentiment).toBe(testCase.expected);
return Promise.resolve({ id: "session-id" });
}),
},
};
vi.doMock("../../lib/prisma", () => ({
prisma: prismaMock,
}));
await processQueuedImports(1);
}
});
it("should handle boolean string conversions", async () => {
const mockImport = {
id: "import1",
companyId: "company1",
externalSessionId: "session1",
escalatedRaw: "true",
forwardedHrRaw: "false",
startTimeRaw: "2024-01-01 10:00:00",
endTimeRaw: "2024-01-01 11:00:00",
};
const prismaMock = {
sessionImport: {
findMany: vi.fn().mockResolvedValue([mockImport]),
},
session: {
create: vi.fn().mockImplementation((data) => {
expect(data.data.escalated).toBe(true);
expect(data.data.forwardedHr).toBe(false);
return Promise.resolve({ id: "session-id" });
}),
},
};
vi.doMock("../../lib/prisma", () => ({
prisma: prismaMock,
}));
await processQueuedImports(1);
});
it("should validate required fields", async () => {
const mockImport = {
id: "import1",
companyId: null, // Invalid - missing required field
externalSessionId: "session1",
startTimeRaw: "2024-01-01 10:00:00",
endTimeRaw: "2024-01-01 11:00:00",
};
const prismaMock = {
sessionImport: {
findMany: vi.fn().mockResolvedValue([mockImport]),
},
session: {
create: vi.fn(),
},
};
vi.doMock("../../lib/prisma", () => ({
prisma: prismaMock,
}));
const result = await processQueuedImports(1);
expect(ProcessingStatusManager.failStage).toHaveBeenCalledWith(
expect.any(String),
"SESSION_CREATION",
expect.stringContaining("Missing required field")
);
expect(result.errors).toBe(1);
});
});
});

View File

@ -0,0 +1,362 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { PrismaClient } from "@prisma/client";
import { ProcessingScheduler } from "../../lib/processingScheduler";
vi.mock("../../lib/prisma", () => ({
prisma: new PrismaClient(),
}));
vi.mock("../../lib/env", () => ({
env: {
OPENAI_API_KEY: "test-key",
PROCESSING_BATCH_SIZE: "10",
PROCESSING_INTERVAL_MS: "5000",
},
}));
describe("Processing Scheduler", () => {
let scheduler: ProcessingScheduler;
beforeEach(() => {
vi.clearAllMocks();
scheduler = new ProcessingScheduler();
});
afterEach(() => {
if (scheduler) {
scheduler.stop();
}
vi.restoreAllMocks();
});
describe("Scheduler lifecycle", () => {
it("should initialize with correct default settings", () => {
expect(scheduler).toBeDefined();
expect(scheduler.isRunning()).toBe(false);
});
it("should start and stop correctly", async () => {
scheduler.start();
expect(scheduler.isRunning()).toBe(true);
scheduler.stop();
expect(scheduler.isRunning()).toBe(false);
});
it("should not start multiple times", () => {
scheduler.start();
const firstStart = scheduler.isRunning();
scheduler.start(); // Should not start again
const secondStart = scheduler.isRunning();
expect(firstStart).toBe(true);
expect(secondStart).toBe(true);
scheduler.stop();
});
});
describe("Processing pipeline stages", () => {
it("should process transcript fetch stage", async () => {
const mockSessions = [
{
id: "session1",
import: {
fullTranscriptUrl: "http://example.com/transcript1",
rawTranscriptContent: null,
},
},
];
const prismaMock = {
session: {
findMany: vi.fn().mockResolvedValue(mockSessions),
update: vi.fn().mockResolvedValue({}),
},
};
vi.doMock("../../lib/prisma", () => ({
prisma: prismaMock,
}));
// Mock fetch for transcript content
global.fetch = vi.fn().mockResolvedValue({
ok: true,
text: () => Promise.resolve("Mock transcript content"),
});
await scheduler.processTranscriptFetch();
expect(prismaMock.session.findMany).toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalledWith(
"http://example.com/transcript1"
);
});
it("should process AI analysis stage", async () => {
const mockSessions = [
{
id: "session1",
transcriptContent: "User: Hello\nAssistant: Hi there!",
sentiment: null,
summary: null,
},
];
const prismaMock = {
session: {
findMany: vi.fn().mockResolvedValue(mockSessions),
update: vi.fn().mockResolvedValue({}),
},
aIProcessingRequest: {
create: vi.fn().mockResolvedValue({ id: "request1" }),
},
};
vi.doMock("../../lib/prisma", () => ({
prisma: prismaMock,
}));
// Mock OpenAI API
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [
{
message: {
content: JSON.stringify({
sentiment: "POSITIVE",
summary: "Friendly greeting exchange",
}),
},
},
],
usage: {
prompt_tokens: 50,
completion_tokens: 20,
total_tokens: 70,
},
}),
});
await scheduler.processAIAnalysis();
expect(prismaMock.session.findMany).toHaveBeenCalled();
expect(prismaMock.aIProcessingRequest.create).toHaveBeenCalled();
});
it("should handle OpenAI API errors gracefully", async () => {
const mockSessions = [
{
id: "session1",
transcriptContent: "User: Hello",
},
];
const prismaMock = {
session: {
findMany: vi.fn().mockResolvedValue(mockSessions),
},
aIProcessingRequest: {
create: vi.fn().mockResolvedValue({ id: "request1" }),
},
};
vi.doMock("../../lib/prisma", () => ({
prisma: prismaMock,
}));
// Mock failed OpenAI API call
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 429,
text: () => Promise.resolve("Rate limit exceeded"),
});
await expect(scheduler.processAIAnalysis()).rejects.toThrow();
});
it("should process question extraction stage", async () => {
const mockSessions = [
{
id: "session1",
transcriptContent:
"User: How do I reset my password?\nAssistant: You can reset it in settings.",
},
];
const prismaMock = {
session: {
findMany: vi.fn().mockResolvedValue(mockSessions),
update: vi.fn().mockResolvedValue({}),
},
question: {
upsert: vi.fn().mockResolvedValue({}),
},
aIProcessingRequest: {
create: vi.fn().mockResolvedValue({ id: "request1" }),
},
};
vi.doMock("../../lib/prisma", () => ({
prisma: prismaMock,
}));
// Mock OpenAI API for question extraction
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [
{
message: {
content: JSON.stringify({
questions: ["How do I reset my password?"],
}),
},
},
],
usage: {
prompt_tokens: 30,
completion_tokens: 15,
total_tokens: 45,
},
}),
});
await scheduler.processQuestionExtraction();
expect(prismaMock.session.findMany).toHaveBeenCalled();
expect(prismaMock.question.upsert).toHaveBeenCalled();
});
});
describe("Error handling", () => {
it("should handle database connection errors", async () => {
const prismaMock = {
session: {
findMany: vi
.fn()
.mockRejectedValue(new Error("Database connection failed")),
},
};
vi.doMock("../../lib/prisma", () => ({
prisma: prismaMock,
}));
await expect(scheduler.processTranscriptFetch()).rejects.toThrow(
"Database connection failed"
);
});
it("should handle invalid transcript URLs", async () => {
const mockSessions = [
{
id: "session1",
import: {
fullTranscriptUrl: "invalid-url",
rawTranscriptContent: null,
},
},
];
const prismaMock = {
session: {
findMany: vi.fn().mockResolvedValue(mockSessions),
},
};
vi.doMock("../../lib/prisma", () => ({
prisma: prismaMock,
}));
global.fetch = vi.fn().mockRejectedValue(new Error("Invalid URL"));
await expect(scheduler.processTranscriptFetch()).rejects.toThrow();
});
it("should handle malformed JSON responses from OpenAI", async () => {
const mockSessions = [
{
id: "session1",
transcriptContent: "User: Hello",
},
];
const prismaMock = {
session: {
findMany: vi.fn().mockResolvedValue(mockSessions),
},
aIProcessingRequest: {
create: vi.fn().mockResolvedValue({ id: "request1" }),
},
};
vi.doMock("../../lib/prisma", () => ({
prisma: prismaMock,
}));
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [
{
message: {
content: "Invalid JSON response",
},
},
],
usage: { total_tokens: 10 },
}),
});
await expect(scheduler.processAIAnalysis()).rejects.toThrow();
});
});
describe("Rate limiting and batching", () => {
it("should respect batch size limits", async () => {
const mockSessions = Array.from({ length: 25 }, (_, i) => ({
id: `session${i}`,
transcriptContent: `Content ${i}`,
}));
const prismaMock = {
session: {
findMany: vi.fn().mockResolvedValue(mockSessions),
},
};
vi.doMock("../../lib/prisma", () => ({
prisma: prismaMock,
}));
await scheduler.processAIAnalysis();
// Should only process up to batch size (10 by default)
expect(prismaMock.session.findMany).toHaveBeenCalledWith(
expect.objectContaining({
take: 10,
})
);
});
it("should handle rate limiting gracefully", async () => {
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 429,
text: () => Promise.resolve("Rate limit exceeded"),
});
await expect(scheduler.processAIAnalysis()).rejects.toThrow();
consoleSpy.mockRestore();
});
});
});

View File

@ -0,0 +1,193 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { parseTranscriptContent } from "../../lib/transcriptParser";
describe("Transcript Parser", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("parseTranscriptContent", () => {
it("should parse basic transcript with timestamps", () => {
const transcript = `
[10:00:00] User: Hello, I need help with my account
[10:00:15] Assistant: I'd be happy to help you with your account. What specific issue are you experiencing?
[10:00:45] User: I can't log in to my dashboard
[10:01:00] Assistant: Let me help you troubleshoot that login issue.
`.trim();
const messages = parseTranscriptContent(transcript);
expect(messages).toHaveLength(4);
expect(messages[0]).toEqual({
timestamp: new Date("1970-01-01T10:00:00.000Z"),
role: "User",
content: "Hello, I need help with my account",
order: 0,
});
expect(messages[1]).toEqual({
timestamp: new Date("1970-01-01T10:00:15.000Z"),
role: "Assistant",
content:
"I'd be happy to help you with your account. What specific issue are you experiencing?",
order: 1,
});
expect(messages[3].order).toBe(3);
});
it("should handle transcript without timestamps", () => {
const transcript = `
User: Hello there
Assistant: Hi! How can I help you today?
User: I need support
Assistant: I'm here to help.
`.trim();
const messages = parseTranscriptContent(transcript);
expect(messages).toHaveLength(4);
expect(messages[0].timestamp).toBeNull();
expect(messages[0].role).toBe("User");
expect(messages[0].content).toBe("Hello there");
expect(messages[0].order).toBe(0);
});
it("should handle mixed timestamp formats", () => {
const transcript = `
[2024-01-01 10:00:00] User: Hello
10:00:15 Assistant: Hi there
[10:00:30] User: How are you?
Assistant: I'm doing well, thanks!
`.trim();
const messages = parseTranscriptContent(transcript);
expect(messages).toHaveLength(4);
expect(messages[0].timestamp).toEqual(
new Date("2024-01-01T10:00:00.000Z")
);
expect(messages[1].timestamp).toEqual(
new Date("1970-01-01T10:00:15.000Z")
);
expect(messages[2].timestamp).toEqual(
new Date("1970-01-01T10:00:30.000Z")
);
expect(messages[3].timestamp).toBeNull();
});
it("should handle various role formats", () => {
const transcript = `
Customer: I have a problem
Support Agent: What can I help with?
USER: My account is locked
ASSISTANT: Let me check that for you
System: Connection established
`.trim();
const messages = parseTranscriptContent(transcript);
expect(messages).toHaveLength(5);
expect(messages[0].role).toBe("User"); // Customer -> User
expect(messages[1].role).toBe("Assistant"); // Support Agent -> Assistant
expect(messages[2].role).toBe("User"); // USER -> User
expect(messages[3].role).toBe("Assistant"); // ASSISTANT -> Assistant
expect(messages[4].role).toBe("System"); // System -> System
});
it("should handle malformed transcript gracefully", () => {
const transcript = `
This is not a proper transcript format
No colons here
: Empty role
User:
: Empty content
`.trim();
const messages = parseTranscriptContent(transcript);
// Should still try to parse what it can
expect(messages.length).toBeGreaterThanOrEqual(0);
// Check that all messages have required fields
messages.forEach((message, index) => {
expect(message).toHaveProperty("role");
expect(message).toHaveProperty("content");
expect(message).toHaveProperty("order", index);
expect(message).toHaveProperty("timestamp");
});
});
it("should preserve message order correctly", () => {
const transcript = `
User: First message
Assistant: Second message
User: Third message
Assistant: Fourth message
User: Fifth message
`.trim();
const messages = parseTranscriptContent(transcript);
expect(messages).toHaveLength(5);
messages.forEach((message, index) => {
expect(message.order).toBe(index);
});
});
it("should handle empty or whitespace-only transcript", () => {
expect(parseTranscriptContent("")).toEqual([]);
expect(parseTranscriptContent(" \n\n ")).toEqual([]);
expect(parseTranscriptContent("\t\r\n")).toEqual([]);
});
it("should handle special characters in content", () => {
const transcript = `
User: Hello! How are you? 😊
Assistant: I'm great! Thanks for asking. 🤖
User: Can you help with this: https://example.com/issue?id=123&type=urgent
Assistant: Absolutely! I'll check that URL for you.
`.trim();
const messages = parseTranscriptContent(transcript);
expect(messages).toHaveLength(4);
expect(messages[0].content).toBe("Hello! How are you? 😊");
expect(messages[2].content).toBe(
"Can you help with this: https://example.com/issue?id=123&type=urgent"
);
});
it("should normalize role names consistently", () => {
const transcript = `
customer: Hello
support: Hi there
CUSTOMER: How are you?
SUPPORT: Good thanks
Client: Great
Agent: Wonderful
`.trim();
const messages = parseTranscriptContent(transcript);
expect(messages[0].role).toBe("User");
expect(messages[1].role).toBe("Assistant");
expect(messages[2].role).toBe("User");
expect(messages[3].role).toBe("Assistant");
expect(messages[4].role).toBe("User");
expect(messages[5].role).toBe("Assistant");
});
it("should handle long content without truncation", () => {
const longContent = "A".repeat(5000);
const transcript = `User: ${longContent}`;
const messages = parseTranscriptContent(transcript);
expect(messages).toHaveLength(1);
expect(messages[0].content).toBe(longContent);
expect(messages[0].content.length).toBe(5000);
});
});
});