mirror of
https://github.com/run-llama/auto_rfp.git
synced 2026-06-30 21:57:56 -04:00
Dev branch improvements and bug fixes (#42)
* Add agentic mode support for LlamaParse and fix eligibility extraction
- Add agenticMode option to ParseOptions interface and Zod schema
- Enable agentic parsing by default for better multi-sheet Excel support
- Fix eligibility extraction to gracefully handle documents without
eligibility criteria by returning empty array instead of throwing error
* Refactor project index selection to single-select with auto-save
- Change ProjectIndexSelector from multi-select checkboxes to single-select
clickable rows with radio-style indicators
- Implement debounced auto-save (800ms) when selection changes
- Remove Edit/Save/Cancel buttons in favor of automatic persistence
- Add refreshKey prop to ProjectDocuments for efficient refetch on changes
- Update DocumentsSection to wire components with onSaveSuccess callback
- Simplify Questions page index selector to show index name with Active badge
* Fix ESLint configuration and resolve all lint errors
- Add eslint and eslint-config-next as dev dependencies
- Create .eslintrc.json with next/core-web-vitals preset
- Fix 43 unescaped entity errors by replacing quotes and apostrophes
with HTML entities across 14 component files
- Fix 9 react-hooks/exhaustive-deps warnings by wrapping fetch
functions in useCallback with proper dependency arrays
* Add Vitest testing infrastructure with 173 unit tests
- Configure Vitest with coverage reporting and path aliases
- Add test scripts (test, test:run, test:coverage) to package.json
- Install vitest, @vitest/coverage-v8, vite-tsconfig-paths, vitest-mock-extended
Test coverage includes:
- validators: extract-questions, generate-response, llamaparse, multi-step-response
- errors: all API error classes with type guard
- services: FileValidator (file type/size validation), DefaultResponseService
- middleware: apiHandler and withApiHandler request validation
Add mock infrastructure for Prisma, OpenAI, and test fixtures
* Fix missing agenticMode in LlamaParse processing service
Add agentic_mode field to request schema and form data parser to ensure
the agenticMode option is properly passed through the parsing pipeline.
This resolves TypeScript compilation error where agenticMode was required
in LlamaParseOptions but not provided by the processing service.
* Fix Add Manually button routing and improve Vercel preview URL handling
- Fix 404 error when clicking Add Manually button by using correct route path
/projects/{projectId}/questions/create instead of /questions/create?projectId=
- Update magic link auth to use VERCEL_URL for preview deployments
* Simplify IndexSelector to display-only mode
Remove index configuration UI since only single file selection is now supported:
- Remove Configure/Hide toggle button and collapsible panel
- Remove Select All/Deselect All functionality
- Remove checkbox selection grid for indexes
- Clean up handleIndexToggle and handleSelectAllIndexes from provider
- Keep project card display showing active index name and status
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
+3
-3
@@ -128,7 +128,7 @@ export default function HelpPage() {
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
||||
Click "Create Organization" on the dashboard
|
||||
Click "Create Organization" on the dashboard
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
||||
@@ -183,7 +183,7 @@ export default function HelpPage() {
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
||||
Click "Create Project" button
|
||||
Click "Create Project" button
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
||||
@@ -443,7 +443,7 @@ export default function HelpPage() {
|
||||
<CardHeader>
|
||||
<CardTitle>Organization Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your organization's AI and integration settings
|
||||
Configure your organization's AI and integration settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
||||
+13
-2
@@ -18,8 +18,19 @@ export async function signInWithMagicLink(formData: FormData) {
|
||||
}
|
||||
|
||||
// Get the origin for creating the full redirect URL
|
||||
// In production, you should set NEXT_PUBLIC_APP_URL in your environment variables
|
||||
const origin = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
// Use Vercel URL for preview deployments, otherwise use NEXT_PUBLIC_APP_URL
|
||||
const getOrigin = () => {
|
||||
// Check if we're on Vercel (preview or production)
|
||||
if (process.env.VERCEL_URL) {
|
||||
// For Vercel deployments, use the deployment URL
|
||||
const protocol = process.env.VERCEL_ENV === 'development' ? 'http' : 'https'
|
||||
return `${protocol}://${process.env.VERCEL_URL}`
|
||||
}
|
||||
// For local development or custom deployments
|
||||
return process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
}
|
||||
|
||||
const origin = getOrigin()
|
||||
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
|
||||
@@ -4,10 +4,10 @@ export default function ConfirmationPage() {
|
||||
<h1 className="text-2xl font-bold">Check your email</h1>
|
||||
<div className="max-w-md text-center">
|
||||
<p className="mb-4">
|
||||
We've sent you a magic link to your email address. Click the link in the email to sign in.
|
||||
We've sent you a magic link to your email address. Click the link in the email to sign in.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
If you don't see the email, check your spam folder. The link will expire after 24 hours.
|
||||
If you don't see the email, check your spam folder. The link will expire after 24 hours.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ export default function LoginPage() {
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-xs text-gray-500">
|
||||
We'll email you a magic link for a password-free sign in.
|
||||
We'll email you a magic link for a password-free sign in.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { Organization } from '@/types/organization';
|
||||
import { ProjectGrid } from '@/components/projects/ProjectGrid';
|
||||
@@ -33,19 +33,19 @@ export default function OrganizationPage({ params }: OrganizationPageProps) {
|
||||
handleParams();
|
||||
}, [params]);
|
||||
|
||||
const fetchOrganization = async () => {
|
||||
const fetchOrganization = useCallback(async () => {
|
||||
if (!organizationId) return;
|
||||
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
|
||||
// First fetch the organization by slug
|
||||
const orgResponse = await fetch(`/api/organizations/${organizationId}`);
|
||||
|
||||
|
||||
if (!orgResponse.ok) {
|
||||
throw new Error('Failed to fetch organization');
|
||||
}
|
||||
|
||||
|
||||
const orgData = await orgResponse.json();
|
||||
|
||||
setOrganization(orgData);
|
||||
@@ -59,13 +59,13 @@ export default function OrganizationPage({ params }: OrganizationPageProps) {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [organizationId, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (organizationId) {
|
||||
fetchOrganization();
|
||||
}
|
||||
}, [organizationId, toast]);
|
||||
}, [organizationId, fetchOrganization]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -83,7 +83,7 @@ export default function OrganizationPage({ params }: OrganizationPageProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<h1 className="text-2xl font-bold">Organization not found</h1>
|
||||
<p className="text-gray-600 mb-4">The organization you're looking for doesn't exist or you don't have access to it.</p>
|
||||
<p className="text-gray-600 mb-4">The organization you're looking for doesn't exist or you don't have access to it.</p>
|
||||
<Link href="/">
|
||||
<Button>Back to Dashboard</Button>
|
||||
</Link>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -63,11 +63,11 @@ export default function OrganizationsPage() {
|
||||
description: "",
|
||||
});
|
||||
|
||||
const fetchOrganizations = async () => {
|
||||
const fetchOrganizations = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/organizations");
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.success) {
|
||||
setOrganizations(data.data);
|
||||
} else {
|
||||
@@ -79,18 +79,18 @@ export default function OrganizationsPage() {
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: "Error",
|
||||
description: "Failed to fetch organizations",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrganizations();
|
||||
}, []);
|
||||
}, [fetchOrganizations]);
|
||||
|
||||
const handleCreateOrganization = async () => {
|
||||
try {
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { AlertCircle, Database, Settings } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { AlertCircle, Database } from "lucide-react"
|
||||
|
||||
interface ProjectIndex {
|
||||
id: string;
|
||||
@@ -17,147 +13,51 @@ interface IndexSelectorProps {
|
||||
availableIndexes: ProjectIndex[];
|
||||
selectedIndexes: Set<string>;
|
||||
organizationConnected: boolean;
|
||||
onIndexToggle: (indexId: string) => void;
|
||||
onSelectAllIndexes: () => void;
|
||||
}
|
||||
|
||||
export function IndexSelector({
|
||||
availableIndexes,
|
||||
selectedIndexes,
|
||||
organizationConnected,
|
||||
onIndexToggle,
|
||||
onSelectAllIndexes
|
||||
}: IndexSelectorProps) {
|
||||
const [showIndexSelector, setShowIndexSelector] = useState(false);
|
||||
|
||||
if (!organizationConnected) {
|
||||
if (!organizationConnected || availableIndexes.length === 0) {
|
||||
return (
|
||||
<Card className="mb-6 border-amber-200 bg-amber-50">
|
||||
<CardContent className="pt-6">
|
||||
<CardHeader className="py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600" />
|
||||
<div>
|
||||
<p className="font-medium text-amber-800">No Project Indexes Configured</p>
|
||||
<p className="text-sm text-amber-700">
|
||||
This project has no document indexes configured. Go to the project's Documents tab to select indexes from your organization's LlamaCloud connection.
|
||||
This project has no document indexes configured. Go to the project's Documents tab to select indexes from your organization's LlamaCloud connection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (availableIndexes.length === 0) {
|
||||
return (
|
||||
<Card className="mb-6 border-amber-200 bg-amber-50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600" />
|
||||
<div>
|
||||
<p className="font-medium text-amber-800">No Project Indexes Configured</p>
|
||||
<p className="text-sm text-amber-700">
|
||||
This project has no document indexes configured. Go to the project's Documents tab to select indexes from your organization's LlamaCloud connection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">
|
||||
{availableIndexes.length === 1
|
||||
? availableIndexes[0].name
|
||||
: `${availableIndexes.length} Project Indexes`
|
||||
}
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{selectedIndexes.size} of {availableIndexes.length} selected
|
||||
<CardHeader className="py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">
|
||||
{availableIndexes[0].name}
|
||||
</CardTitle>
|
||||
{selectedIndexes.size > 0 && (
|
||||
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-200">
|
||||
Active
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowIndexSelector(!showIndexSelector)}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-1" />
|
||||
{showIndexSelector ? 'Hide' : 'Configure'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{selectedIndexes.size === 0 && (
|
||||
<p className="text-sm text-amber-600 mt-2">
|
||||
⚠️ No project indexes selected. AI generation will use default responses.
|
||||
No project indexes selected. AI generation will use default responses.
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{showIndexSelector && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Note:</strong> This selection is temporary for AI generation in this session.
|
||||
You can only select from indexes that are already configured for this project.
|
||||
To add or remove project indexes permanently, use the <strong>Documents</strong> tab.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select which of this project's configured indexes to use when generating AI answers:
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onSelectAllIndexes}
|
||||
>
|
||||
{selectedIndexes.size === availableIndexes.length ? 'Deselect All' : 'Select All'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{availableIndexes.map((index) => (
|
||||
<div
|
||||
key={index.id}
|
||||
className={cn(
|
||||
"flex items-center space-x-3 p-3 border rounded-lg cursor-pointer transition-colors",
|
||||
selectedIndexes.has(index.id)
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-muted hover:border-muted-foreground/30"
|
||||
)}
|
||||
onClick={() => onIndexToggle(index.id)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedIndexes.has(index.id)}
|
||||
onChange={() => onIndexToggle(index.id)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="font-medium text-sm truncate">{index.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedIndexes.size > 0 && (
|
||||
<div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg">
|
||||
<p className="text-sm text-green-800">
|
||||
✓ AI will use documents from {selectedIndexes.size} selected project {selectedIndexes.size === 1 ? 'index' : 'indexes'} to generate answers.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function NoQuestionsAvailable({ projectId, onUploadClick }: NoQuestionsAv
|
||||
};
|
||||
|
||||
const handleAddManuallyClick = () => {
|
||||
router.push(`/questions/create?projectId=${projectId}`);
|
||||
router.push(`/projects/${projectId}/questions/create`);
|
||||
};
|
||||
|
||||
const sampleFileUrl = "https://qluspotebpidccpfbdho.supabase.co/storage/v1/object/public/sample-files//RFP%20-%20Launch%20Services%20for%20Medium-Lift%20Payloads.pdf";
|
||||
|
||||
@@ -72,8 +72,6 @@ interface QuestionsContextType {
|
||||
saveAllAnswers: () => Promise<void>;
|
||||
handleExportAnswers: () => void;
|
||||
handleSourceClick: (source: AnswerSource) => void;
|
||||
handleIndexToggle: (indexId: string) => void;
|
||||
handleSelectAllIndexes: () => void;
|
||||
handleAcceptMultiStepResponse: (response: string, sources: any[]) => void;
|
||||
handleCloseMultiStepDialog: () => void;
|
||||
|
||||
@@ -251,28 +249,6 @@ export function QuestionsProvider({ children, projectId }: QuestionsProviderProp
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
// Handle index selection
|
||||
const handleIndexToggle = (indexId: string) => {
|
||||
setSelectedIndexes(prev => {
|
||||
const newSelected = new Set(prev);
|
||||
if (newSelected.has(indexId)) {
|
||||
newSelected.delete(indexId);
|
||||
} else {
|
||||
newSelected.add(indexId);
|
||||
}
|
||||
return newSelected;
|
||||
});
|
||||
};
|
||||
|
||||
// Toggle all indexes
|
||||
const handleSelectAllIndexes = () => {
|
||||
if (selectedIndexes.size === availableIndexes.length) {
|
||||
setSelectedIndexes(new Set());
|
||||
} else {
|
||||
setSelectedIndexes(new Set(availableIndexes.map(index => index.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle answer changes
|
||||
const handleAnswerChange = (questionId: string, value: string) => {
|
||||
setAnswers(prev => {
|
||||
@@ -706,8 +682,6 @@ export function QuestionsProvider({ children, projectId }: QuestionsProviderProp
|
||||
saveAllAnswers,
|
||||
handleExportAnswers,
|
||||
handleSourceClick,
|
||||
handleIndexToggle,
|
||||
handleSelectAllIndexes,
|
||||
handleAcceptMultiStepResponse,
|
||||
handleCloseMultiStepDialog,
|
||||
|
||||
|
||||
@@ -38,9 +38,7 @@ function QuestionsSectionInner({ projectId }: QuestionsSectionProps) {
|
||||
selectedIndexes,
|
||||
availableIndexes,
|
||||
organizationConnected,
|
||||
handleIndexToggle,
|
||||
handleSelectAllIndexes,
|
||||
refreshQuestions, // Add this method to refresh questions after upload
|
||||
refreshQuestions,
|
||||
} = useQuestions();
|
||||
|
||||
const handleUploadComplete = () => {
|
||||
@@ -80,8 +78,6 @@ function QuestionsSectionInner({ projectId }: QuestionsSectionProps) {
|
||||
availableIndexes={availableIndexes}
|
||||
selectedIndexes={selectedIndexes}
|
||||
organizationConnected={organizationConnected}
|
||||
onIndexToggle={handleIndexToggle}
|
||||
onSelectAllIndexes={handleSelectAllIndexes}
|
||||
/>
|
||||
|
||||
{/* Questions Filter Tabs */}
|
||||
|
||||
@@ -186,7 +186,7 @@ export function AISuggestionsPanel({ questionId }: AISuggestionsPanelProps) {
|
||||
<TabsContent value="suggestion3" className="space-y-3">
|
||||
<div className="rounded-lg border p-3 text-sm">
|
||||
<p>
|
||||
Our technical architecture is specifically designed to meet Velocity Labs' requirements for a scalable,
|
||||
Our technical architecture is specifically designed to meet Velocity Labs' requirements for a scalable,
|
||||
secure, and maintainable healthcare solution. The architecture leverages industry best practices and our
|
||||
extensive experience in HIPAA-compliant systems:
|
||||
</p>
|
||||
@@ -194,7 +194,7 @@ export function AISuggestionsPanel({ questionId }: AISuggestionsPanelProps) {
|
||||
<p>
|
||||
<strong>1. User Interface:</strong> A responsive web application built with React, featuring an
|
||||
intuitive interface designed specifically for healthcare professionals. The UI incorporates Velocity
|
||||
Labs' design system for brand consistency.
|
||||
Labs' design system for brand consistency.
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
@@ -210,7 +210,7 @@ export function AISuggestionsPanel({ questionId }: AISuggestionsPanelProps) {
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
<strong>4. Integration Hub:</strong> A dedicated integration layer for connecting with Velocity Labs'
|
||||
<strong>4. Integration Hub:</strong> A dedicated integration layer for connecting with Velocity Labs'
|
||||
existing systems, including HL7 FHIR support for healthcare interoperability and secure connections to
|
||||
third-party services.
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { ProjectIndexSelector } from "@/components/projects/ProjectIndexSelector"
|
||||
import { ProjectDocuments } from "@/components/projects/ProjectDocuments"
|
||||
@@ -13,6 +14,13 @@ export function DocumentsSection({ projectId: propProjectId }: DocumentsSectionP
|
||||
// Use prop if provided, otherwise fall back to search params
|
||||
const projectId = propProjectId || searchParams.get("projectId")
|
||||
|
||||
// Counter to trigger ProjectDocuments refresh when index changes
|
||||
const [documentsRefreshKey, setDocumentsRefreshKey] = useState(0)
|
||||
|
||||
const handleSaveSuccess = useCallback(() => {
|
||||
setDocumentsRefreshKey(prev => prev + 1)
|
||||
}, [])
|
||||
|
||||
if (!projectId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -27,12 +35,12 @@ export function DocumentsSection({ projectId: propProjectId }: DocumentsSectionP
|
||||
return (
|
||||
<div className="space-y-6 p-12">
|
||||
<h2 className="text-2xl font-bold">Documents</h2>
|
||||
|
||||
|
||||
{/* Project Index Selection */}
|
||||
<ProjectIndexSelector projectId={projectId} />
|
||||
|
||||
{/* Project Documents from Selected Indexes */}
|
||||
<ProjectDocuments projectId={projectId} />
|
||||
<ProjectIndexSelector projectId={projectId} onSaveSuccess={handleSaveSuccess} />
|
||||
|
||||
{/* Project Documents from Selected Index */}
|
||||
<ProjectDocuments projectId={projectId} refreshKey={documentsRefreshKey} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useEffect } from "react"
|
||||
import React, { useState, useEffect, useCallback } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -29,15 +29,15 @@ export function ProjectTimeline({ projectId }: ProjectTimelineProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchTimeline = async () => {
|
||||
const fetchTimeline = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch(`/api/projects/${projectId}/timeline`);
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch timeline');
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
setActivities(data.activities || []);
|
||||
setError(null);
|
||||
@@ -47,13 +47,13 @@ export function ProjectTimeline({ projectId }: ProjectTimelineProps) {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
fetchTimeline();
|
||||
}
|
||||
}, [projectId]);
|
||||
}, [projectId, fetchTimeline]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -319,7 +319,7 @@ export function FileUploader({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Choose the appropriate mode based on your document's complexity
|
||||
Choose the appropriate mode based on your document's complexity
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ export function ProcessingModal({
|
||||
: "Waiting for processing to begin"}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
The file is being configured. This may take a while, we will notify you when it's done.
|
||||
The file is being configured. This may take a while, we will notify you when it's done.
|
||||
</div>
|
||||
{status === "mapping" && (
|
||||
<div className="mt-2">
|
||||
|
||||
@@ -37,7 +37,7 @@ export function TestimonialsSection() {
|
||||
))}
|
||||
</div>
|
||||
<blockquote className="text-lg font-medium">
|
||||
"For every $1 that our company invests in AutoRFP, I estimate a return on investment of $500. We have seen really massive growth over the past few years and we couldn't have done it without AutoRFP."
|
||||
"For every $1 that our company invests in AutoRFP, I estimate a return on investment of $500. We have seen really massive growth over the past few years and we couldn't have done it without AutoRFP."
|
||||
</blockquote>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
@@ -91,7 +91,7 @@ export function TestimonialsSection() {
|
||||
))}
|
||||
</div>
|
||||
<blockquote className="text-lg font-medium">
|
||||
"We were able to reduce the time maintaining our content library by 50% through the elimination of writing/editing tasks involved in each RFP response, and AI Assistant has contributed to our increasing win rate."
|
||||
"We were able to reduce the time maintaining our content library by 50% through the elimination of writing/editing tasks involved in each RFP response, and AI Assistant has contributed to our increasing win rate."
|
||||
</blockquote>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
@@ -145,7 +145,7 @@ export function TestimonialsSection() {
|
||||
))}
|
||||
</div>
|
||||
<blockquote className="text-lg font-medium">
|
||||
"It could take 5 to 10 minutes to manually find something in the library or to take two things and merge them together. With AI Assistant, it is actually answering, on average, our questions in 30 seconds."
|
||||
"It could take 5 to 10 minutes to manually find something in the library or to take two things and merge them together. With AI Assistant, it is actually answering, on average, our questions in 30 seconds."
|
||||
</blockquote>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -83,7 +83,7 @@ export function KnowledgeBaseContent({ params }: KnowledgeBaseContentProps) {
|
||||
});
|
||||
|
||||
// Fetch knowledge bases
|
||||
const fetchKnowledgeBases = async () => {
|
||||
const fetchKnowledgeBases = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch(`/api/organizations/${orgId}/knowledge-bases`);
|
||||
@@ -103,10 +103,10 @@ export function KnowledgeBaseContent({ params }: KnowledgeBaseContentProps) {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [orgId, selectedKnowledgeBase, toast]);
|
||||
|
||||
// Fetch questions for selected knowledge base
|
||||
const fetchQuestions = async (kbId: string) => {
|
||||
const fetchQuestions = useCallback(async (kbId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/organizations/${orgId}/knowledge-bases/${kbId}/questions`);
|
||||
if (response.ok) {
|
||||
@@ -120,17 +120,17 @@ export function KnowledgeBaseContent({ params }: KnowledgeBaseContentProps) {
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [orgId, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchKnowledgeBases();
|
||||
}, [orgId]);
|
||||
}, [fetchKnowledgeBases]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedKnowledgeBase) {
|
||||
fetchQuestions(selectedKnowledgeBase.id);
|
||||
}
|
||||
}, [selectedKnowledgeBase]);
|
||||
}, [selectedKnowledgeBase, fetchQuestions]);
|
||||
|
||||
// Create knowledge base
|
||||
const handleCreateKB = async (e: React.FormEvent) => {
|
||||
|
||||
@@ -99,7 +99,7 @@ export function LlamaCloudConnectionDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect to LlamaCloud Project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Connect your organization to a LlamaCloud project using the project's API key to access its pipelines and documents.
|
||||
Connect your organization to a LlamaCloud project using the project's API key to access its pipelines and documents.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -65,38 +65,38 @@ export function LlamaCloudDocuments({ organizationId, onDisconnect }: LlamaCloud
|
||||
const [shownDocuments, setShownDocuments] = useState<Record<string, number>>({});
|
||||
const { toast } = useToast();
|
||||
|
||||
const fetchDocuments = async () => {
|
||||
const fetchDocuments = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
const response = await fetch(`/api/llamacloud/documents?organizationId=${organizationId}`);
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to fetch documents');
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
setDocuments(data.documents || []);
|
||||
setPipelines(data.pipelines || []);
|
||||
setConnectedAt(data.connectedAt);
|
||||
|
||||
|
||||
// Auto-expand first pipeline and initialize shown documents
|
||||
if (data.documents && data.documents.length > 0) {
|
||||
const pipelineNames: string[] = Array.from(new Set(data.documents.map((doc: LlamaCloudDocument) => doc.pipelineName)));
|
||||
const initialExpanded: Record<string, boolean> = {};
|
||||
const initialShown: Record<string, number> = {};
|
||||
|
||||
|
||||
pipelineNames.forEach((pipelineName, index) => {
|
||||
initialExpanded[pipelineName] = index === 0; // Expand first pipeline
|
||||
initialShown[pipelineName] = INITIAL_DOCUMENTS_SHOWN;
|
||||
});
|
||||
|
||||
|
||||
setExpandedPipelines(initialExpanded);
|
||||
setShownDocuments(initialShown);
|
||||
}
|
||||
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch documents';
|
||||
setError(errorMessage);
|
||||
@@ -104,11 +104,11 @@ export function LlamaCloudDocuments({ organizationId, onDisconnect }: LlamaCloud
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [organizationId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocuments();
|
||||
}, [organizationId]);
|
||||
}, [fetchDocuments]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchDocuments();
|
||||
|
||||
@@ -124,7 +124,7 @@ export function SettingsContent({ orgId }: SettingsContentProps) {
|
||||
<CardHeader>
|
||||
<CardTitle>General Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your organization's basic information
|
||||
Manage your organization's basic information
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -47,6 +47,7 @@ interface ProjectIndex {
|
||||
|
||||
interface ProjectDocumentsProps {
|
||||
projectId: string;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
// Styling functions from the global DocumentList
|
||||
@@ -110,7 +111,7 @@ const getPillText = (fileType: string): string => {
|
||||
|
||||
const INITIAL_DOCUMENTS_SHOWN = 12;
|
||||
|
||||
export function ProjectDocuments({ projectId }: ProjectDocumentsProps) {
|
||||
export function ProjectDocuments({ projectId, refreshKey }: ProjectDocumentsProps) {
|
||||
const [documents, setDocuments] = useState<ProjectDocument[]>([]);
|
||||
const [projectIndexes, setProjectIndexes] = useState<ProjectIndex[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -136,21 +137,21 @@ export function ProjectDocuments({ projectId }: ProjectDocumentsProps) {
|
||||
return fileTypeMap[extension] || 'other';
|
||||
};
|
||||
|
||||
const fetchProjectDocuments = async () => {
|
||||
const fetchProjectDocuments = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
// First get the project indexes
|
||||
const indexesResponse = await fetch(`/api/projects/${projectId}/indexes`);
|
||||
|
||||
|
||||
if (!indexesResponse.ok) {
|
||||
const errorData = await indexesResponse.json();
|
||||
throw new Error(errorData.error || 'Failed to fetch project indexes');
|
||||
}
|
||||
|
||||
|
||||
const indexesData = await indexesResponse.json();
|
||||
|
||||
|
||||
if (!indexesData.organizationConnected) {
|
||||
setOrganizationConnected(false);
|
||||
setProjectIndexes([]);
|
||||
@@ -176,17 +177,17 @@ export function ProjectDocuments({ projectId }: ProjectDocumentsProps) {
|
||||
|
||||
// Fetch all organization documents
|
||||
const documentsResponse = await fetch(`/api/llamacloud/documents?organizationId=${projectData.organizationId}`);
|
||||
|
||||
|
||||
if (!documentsResponse.ok) {
|
||||
const errorData = await documentsResponse.json();
|
||||
throw new Error(errorData.error || 'Failed to fetch documents');
|
||||
}
|
||||
|
||||
|
||||
const documentsData = await documentsResponse.json();
|
||||
|
||||
|
||||
// Filter documents to only include those from selected indexes
|
||||
const selectedIndexIds = new Set(indexesData.currentIndexes.map((index: ProjectIndex) => index.id));
|
||||
const filteredDocuments = (documentsData.documents || []).filter((doc: any) =>
|
||||
const filteredDocuments = (documentsData.documents || []).filter((doc: any) =>
|
||||
selectedIndexIds.has(doc.pipelineId)
|
||||
).map((doc: any) => ({
|
||||
...doc,
|
||||
@@ -208,16 +209,16 @@ export function ProjectDocuments({ projectId }: ProjectDocumentsProps) {
|
||||
const indexNames: string[] = Array.from(new Set(filteredDocuments.map((doc: ProjectDocument) => doc.indexName)));
|
||||
const initialExpanded: Record<string, boolean> = {};
|
||||
const initialShown: Record<string, number> = {};
|
||||
|
||||
|
||||
indexNames.forEach((indexName, index) => {
|
||||
initialExpanded[indexName] = index === 0; // Expand first index
|
||||
initialShown[indexName] = INITIAL_DOCUMENTS_SHOWN;
|
||||
});
|
||||
|
||||
|
||||
setExpandedIndexes(initialExpanded);
|
||||
setShownDocuments(initialShown);
|
||||
}
|
||||
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch project documents';
|
||||
setError(errorMessage);
|
||||
@@ -225,11 +226,11 @@ export function ProjectDocuments({ projectId }: ProjectDocumentsProps) {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjectDocuments();
|
||||
}, [projectId]);
|
||||
}, [projectId, refreshKey]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchProjectDocuments();
|
||||
@@ -429,7 +430,7 @@ export function ProjectDocuments({ projectId }: ProjectDocumentsProps) {
|
||||
Project Documents
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{documents.length} documents available from {projectIndexes.length} selected {projectIndexes.length === 1 ? 'index' : 'indexes'}
|
||||
{documents.length} documents from {projectIndexes.length > 0 ? projectIndexes[0].name : 'no index selected'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import {
|
||||
Database,
|
||||
Settings,
|
||||
Check,
|
||||
import {
|
||||
Database,
|
||||
Settings,
|
||||
Check,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
FolderOpen,
|
||||
Edit3,
|
||||
X
|
||||
FolderOpen
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ProjectIndex {
|
||||
id: string;
|
||||
@@ -28,44 +25,46 @@ interface ProjectIndex {
|
||||
|
||||
interface ProjectIndexSelectorProps {
|
||||
projectId: string;
|
||||
onSaveSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function ProjectIndexSelector({ projectId }: ProjectIndexSelectorProps) {
|
||||
export function ProjectIndexSelector({ projectId, onSaveSuccess }: ProjectIndexSelectorProps) {
|
||||
const [currentIndexes, setCurrentIndexes] = useState<ProjectIndex[]>([]);
|
||||
const [availableIndexes, setAvailableIndexes] = useState<ProjectIndex[]>([]);
|
||||
const [selectedIndexIds, setSelectedIndexIds] = useState<string[]>([]);
|
||||
const [selectedIndexId, setSelectedIndexId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [organizationConnected, setOrganizationConnected] = useState(false);
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [organizationName, setOrganizationName] = useState('');
|
||||
const [llamaCloudProjectName, setLlamaCloudProjectName] = useState('');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const fetchProjectIndexes = async () => {
|
||||
const fetchProjectIndexes = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/indexes`);
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to fetch project indexes');
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
setCurrentIndexes(data.currentIndexes || []);
|
||||
setAvailableIndexes(data.availableIndexes || []);
|
||||
setSelectedIndexIds(data.currentIndexes?.map((index: ProjectIndex) => index.id) || []);
|
||||
// Take first index if any exist (single select)
|
||||
const currentIds = data.currentIndexes?.map((index: ProjectIndex) => index.id) || [];
|
||||
setSelectedIndexId(currentIds.length > 0 ? currentIds[0] : null);
|
||||
setOrganizationConnected(data.organizationConnected);
|
||||
setProjectName(data.project?.name || '');
|
||||
setOrganizationName(data.organizationName || '');
|
||||
setLlamaCloudProjectName(data.llamaCloudProjectName || '');
|
||||
|
||||
setIsInitialized(true);
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch project indexes';
|
||||
setError(errorMessage);
|
||||
@@ -73,61 +72,45 @@ export function ProjectIndexSelector({ projectId }: ProjectIndexSelectorProps) {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjectIndexes();
|
||||
}, [projectId]);
|
||||
}, [fetchProjectIndexes]);
|
||||
|
||||
const handleIndexToggle = (indexId: string, checked: boolean) => {
|
||||
if (!isEditing) return; // Only allow changes in edit mode
|
||||
|
||||
if (checked) {
|
||||
setSelectedIndexIds(prev => [...prev, indexId]);
|
||||
} else {
|
||||
setSelectedIndexIds(prev => prev.filter(id => id !== indexId));
|
||||
}
|
||||
};
|
||||
const handleIndexSelect = (indexId: string) => {
|
||||
if (isSaving) return;
|
||||
|
||||
const handleEdit = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// Reset to current indexes
|
||||
setSelectedIndexIds(currentIndexes.map(index => index.id));
|
||||
setIsEditing(false);
|
||||
// Toggle: if already selected, deselect; otherwise select this one
|
||||
setSelectedIndexId(prev => prev === indexId ? null : indexId);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/indexes`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
indexIds: selectedIndexIds,
|
||||
indexIds: selectedIndexId ? [selectedIndexId] : [],
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to update project indexes');
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
setCurrentIndexes(data.projectIndexes || []);
|
||||
setIsEditing(false); // Exit edit mode after successful save
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: data.message || 'Project indexes updated successfully',
|
||||
});
|
||||
|
||||
|
||||
// Notify parent that save succeeded
|
||||
onSaveSuccess?.();
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to update project indexes';
|
||||
toast({
|
||||
@@ -141,18 +124,25 @@ export function ProjectIndexSelector({ projectId }: ProjectIndexSelectorProps) {
|
||||
};
|
||||
|
||||
const hasChanges = () => {
|
||||
const currentIds = new Set(currentIndexes.map(index => index.id));
|
||||
const selectedIds = new Set(selectedIndexIds);
|
||||
|
||||
if (currentIds.size !== selectedIds.size) return true;
|
||||
|
||||
for (const id of currentIds) {
|
||||
if (!selectedIds.has(id)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
const currentId = currentIndexes.length > 0 ? currentIndexes[0].id : null;
|
||||
return selectedIndexId !== currentId;
|
||||
};
|
||||
|
||||
// Debounced auto-save effect
|
||||
useEffect(() => {
|
||||
// Don't save on initial load or while loading
|
||||
if (!isInitialized || isLoading) return;
|
||||
|
||||
// Only save if there are actual changes
|
||||
if (!hasChanges()) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
handleSave();
|
||||
}, 800);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [selectedIndexId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -169,7 +159,7 @@ export function ProjectIndexSelector({ projectId }: ProjectIndexSelectorProps) {
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center space-x-3">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
))}
|
||||
@@ -185,10 +175,10 @@ export function ProjectIndexSelector({ projectId }: ProjectIndexSelectorProps) {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
Project Indexes
|
||||
Project Index
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Select which indexes this project can access
|
||||
Select an index for this project
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -196,7 +186,7 @@ export function ProjectIndexSelector({ projectId }: ProjectIndexSelectorProps) {
|
||||
<AlertCircle className="mx-auto h-8 w-8 text-muted-foreground mb-3" />
|
||||
<h3 className="text-lg font-medium mb-2">No LlamaCloud Connection</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Your organization needs to be connected to LlamaCloud to select indexes for this project.
|
||||
Your organization needs to be connected to LlamaCloud to select an index for this project.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ask your organization admin to connect to LlamaCloud in the organization settings.
|
||||
@@ -213,10 +203,10 @@ export function ProjectIndexSelector({ projectId }: ProjectIndexSelectorProps) {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
Project Indexes
|
||||
Project Index
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Select which indexes this project can access
|
||||
Select an index for this project
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -239,16 +229,10 @@ export function ProjectIndexSelector({ projectId }: ProjectIndexSelectorProps) {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
Project Indexes
|
||||
Project Index
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Select which indexes from {organizationName}'s LlamaCloud project "{llamaCloudProjectName}" this project can access
|
||||
{isEditing && (
|
||||
<span className="inline-flex items-center gap-1 ml-2 text-blue-600 font-medium">
|
||||
<Edit3 className="h-3 w-3" />
|
||||
Editing
|
||||
</span>
|
||||
)}
|
||||
Select an index from {organizationName}'s LlamaCloud project "{llamaCloudProjectName}"
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
@@ -257,37 +241,39 @@ export function ProjectIndexSelector({ projectId }: ProjectIndexSelectorProps) {
|
||||
<FolderOpen className="mx-auto h-8 w-8 text-muted-foreground mb-3" />
|
||||
<h3 className="text-lg font-medium mb-2">No Indexes Available</h3>
|
||||
<p className="text-muted-foreground">
|
||||
No indexes were found in your organization's LlamaCloud project.
|
||||
No indexes were found in your organization's LlamaCloud project.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
{availableIndexes.map((index) => {
|
||||
const isSelected = selectedIndexIds.includes(index.id);
|
||||
|
||||
const isSelected = selectedIndexId === index.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index.id}
|
||||
className={`flex items-start space-x-3 p-3 border rounded-lg transition-colors ${
|
||||
isEditing
|
||||
? 'hover:bg-muted/50 border-blue-200'
|
||||
: 'hover:bg-muted/20'
|
||||
}`}
|
||||
onClick={() => handleIndexSelect(index.id)}
|
||||
className={cn(
|
||||
"flex items-start space-x-3 p-3 border rounded-lg transition-colors cursor-pointer",
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "hover:bg-muted/50",
|
||||
isSaving && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`index-${index.id}`}
|
||||
checked={isSelected}
|
||||
disabled={!isEditing}
|
||||
onCheckedChange={(checked) => handleIndexToggle(index.id, checked as boolean)}
|
||||
/>
|
||||
{/* Radio-style indicator */}
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
isSelected ? "border-blue-500 bg-blue-500" : "border-gray-300"
|
||||
)}>
|
||||
{isSelected && <div className="w-2 h-2 rounded-full bg-white" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<label
|
||||
htmlFor={`index-${index.id}`}
|
||||
className={`block font-medium ${isEditing ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
>
|
||||
<div className="block font-medium">
|
||||
{index.name}
|
||||
</label>
|
||||
</div>
|
||||
{index.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{index.description}
|
||||
@@ -299,10 +285,11 @@ export function ProjectIndexSelector({ projectId }: ProjectIndexSelectorProps) {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSelected && (
|
||||
<Badge variant="secondary" className="bg-green-100 text-green-800">
|
||||
<Badge variant="secondary" className="bg-blue-100 text-blue-800">
|
||||
<Check className="mr-1 h-3 w-3" />
|
||||
Selected
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -312,58 +299,26 @@ export function ProjectIndexSelector({ projectId }: ProjectIndexSelectorProps) {
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{selectedIndexIds.length} of {availableIndexes.length} indexes selected
|
||||
{selectedIndexId
|
||||
? `Selected: ${availableIndexes.find(i => i.id === selectedIndexId)?.name}`
|
||||
: "No index selected"}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSaving && (
|
||||
<span className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Settings className="h-3 w-3 animate-spin" />
|
||||
Saving...
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchProjectIndexes}
|
||||
disabled={isSaving || isEditing}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
{!isEditing ? (
|
||||
<Button
|
||||
onClick={handleEdit}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Edit3 className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isSaving}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges() || isSaving}
|
||||
size="sm"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Settings className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -371,4 +326,4 @@ export function ProjectIndexSelector({ projectId }: ProjectIndexSelectorProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function DeleteConfirmationDialog({
|
||||
<AlertDialogDescription className="space-y-2">
|
||||
<span className="block">{description}</span>
|
||||
<span className="block font-medium text-foreground">
|
||||
Are you sure you want to delete "{itemName}"?
|
||||
Are you sure you want to delete "{itemName}"?
|
||||
</span>
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
This action cannot be undone. All associated data including questions, answers, and documents will be permanently removed.
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface ParseOptions {
|
||||
fastMode?: boolean;
|
||||
premiumMode?: boolean;
|
||||
complexTables?: boolean;
|
||||
agenticMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,9 +54,13 @@ export class LlamaParseService {
|
||||
const { LlamaParseReader } = llamaIndexModule;
|
||||
|
||||
// Step 4: Configure options based on the mode
|
||||
// Default to agentic mode for better multi-sheet/multi-page parsing
|
||||
const useAgentic = options.agenticMode !== false;
|
||||
|
||||
let readerOptions: Record<string, any> = {
|
||||
apiKey: this.apiKey,
|
||||
resultType: "markdown"
|
||||
resultType: "markdown",
|
||||
useAgenticParse: useAgentic,
|
||||
};
|
||||
|
||||
// Add mode-specific options
|
||||
|
||||
@@ -27,6 +27,7 @@ export class LlamaParseProcessingService implements ILlamaParseProcessingService
|
||||
fastMode: request.fast_mode || false,
|
||||
premiumMode: request.premium_mode || false,
|
||||
complexTables: request.preset === 'complexTables',
|
||||
agenticMode: request.agentic_mode ?? true,
|
||||
};
|
||||
|
||||
// Step 3: Parse the file using LlamaParse
|
||||
|
||||
@@ -85,13 +85,18 @@ export class OpenAIQuestionExtractor implements IAIQuestionExtractor {
|
||||
|
||||
// Parse and validate the JSON response
|
||||
const rawData = JSON.parse(assistantMessage);
|
||||
|
||||
// Expect format: { "eligibility": ["requirement 1", "requirement 2", ...] }
|
||||
if (!rawData.eligibility || !Array.isArray(rawData.eligibility)) {
|
||||
throw new AIServiceError('Invalid eligibility format from AI service');
|
||||
|
||||
// Handle cases where no eligibility criteria are found
|
||||
// The AI may return {}, { "eligibility": null }, or { "eligibility": [] }
|
||||
const eligibility = rawData.eligibility;
|
||||
|
||||
// If eligibility is missing or not an array, return empty array (document has no eligibility criteria)
|
||||
if (!eligibility || !Array.isArray(eligibility)) {
|
||||
console.log('No eligibility criteria found in document, returning empty array');
|
||||
return [];
|
||||
}
|
||||
|
||||
return rawData.eligibility.filter((item: any) => typeof item === 'string' && item.trim().length > 0);
|
||||
return eligibility.filter((item: any) => typeof item === 'string' && item.trim().length > 0);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new AIServiceError('Invalid JSON response from AI service for eligibility extraction');
|
||||
|
||||
@@ -15,11 +15,12 @@ export function parseFormDataToLlamaParseRequest(formData: FormData): LlamaParse
|
||||
// Parse boolean values
|
||||
const fast_mode = formData.get('fast_mode') === 'true';
|
||||
const premium_mode = formData.get('premium_mode') === 'true';
|
||||
|
||||
const agentic_mode = formData.get('agentic_mode') !== 'false'; // Default to true
|
||||
|
||||
// Parse preset
|
||||
const presetValue = formData.get('preset') as string | null;
|
||||
const preset = presetValue === 'complexTables' ? 'complexTables' as const : undefined;
|
||||
|
||||
|
||||
// Parse document name
|
||||
const documentName = formData.get('documentName') as string | null;
|
||||
|
||||
@@ -27,6 +28,7 @@ export function parseFormDataToLlamaParseRequest(formData: FormData): LlamaParse
|
||||
file,
|
||||
fast_mode,
|
||||
premium_mode,
|
||||
agentic_mode,
|
||||
preset,
|
||||
documentName: documentName || undefined,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ export const LlamaParseRequestSchema = z.object({
|
||||
file: z.instanceof(File, { message: 'Valid file is required' }),
|
||||
fast_mode: z.boolean().optional().default(false),
|
||||
premium_mode: z.boolean().optional().default(false),
|
||||
agentic_mode: z.boolean().optional().default(true),
|
||||
preset: z.enum(['complexTables']).optional(),
|
||||
documentName: z.string().optional(),
|
||||
});
|
||||
@@ -17,6 +18,7 @@ export const LlamaParseOptionsSchema = z.object({
|
||||
fastMode: z.boolean().default(false),
|
||||
premiumMode: z.boolean().default(false),
|
||||
complexTables: z.boolean().default(false),
|
||||
agenticMode: z.boolean().default(false),
|
||||
});
|
||||
|
||||
// File validation schema
|
||||
|
||||
+12
-2
@@ -12,7 +12,10 @@
|
||||
"health-check": "curl http://localhost:3000/api/health",
|
||||
"lint": "next lint",
|
||||
"postinstall": "prisma generate",
|
||||
"start": "next start"
|
||||
"start": "next start",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^1.3.22",
|
||||
@@ -85,9 +88,16 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "^15.5.9",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.2.9",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vite-tsconfig-paths": "^6.0.3",
|
||||
"vitest": "^4.0.16",
|
||||
"vitest-mock-extended": "^3.1.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
|
||||
Generated
+3986
-18
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
// Test fixtures for AutoRFP tests
|
||||
|
||||
export const mockQuestion = {
|
||||
id: 'q-1',
|
||||
question: 'What security measures do you implement?',
|
||||
};
|
||||
|
||||
export const mockSection = {
|
||||
id: 'sec-1',
|
||||
title: 'Security Requirements',
|
||||
description: 'Security-related questions',
|
||||
questions: [mockQuestion],
|
||||
};
|
||||
|
||||
export const mockExtractQuestionsRequest = {
|
||||
documentId: 'doc-123',
|
||||
documentName: 'test-rfp.pdf',
|
||||
content: 'This is the RFP document content with questions.',
|
||||
projectId: 'proj-456',
|
||||
};
|
||||
|
||||
export const mockGenerateResponseRequest = {
|
||||
question: 'What is your security approach?',
|
||||
projectId: 'proj-123',
|
||||
documentIds: [],
|
||||
selectedIndexIds: [],
|
||||
useAllIndexes: false,
|
||||
};
|
||||
|
||||
export const mockMultiStepRequest = {
|
||||
question: 'Describe your implementation approach',
|
||||
questionId: 'q-123',
|
||||
projectId: 'proj-456',
|
||||
indexIds: ['idx-1', 'idx-2'],
|
||||
context: 'Enterprise deployment',
|
||||
};
|
||||
|
||||
export const mockFile = (options: {
|
||||
name?: string;
|
||||
size?: number;
|
||||
type?: string;
|
||||
} = {}) => {
|
||||
const {
|
||||
name = 'test.pdf',
|
||||
size = 1024,
|
||||
type = 'application/pdf'
|
||||
} = options;
|
||||
|
||||
return new File(['test content'], name, { type });
|
||||
};
|
||||
|
||||
export const mockUser = {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
};
|
||||
|
||||
export const mockOrganization = {
|
||||
id: 'org-123',
|
||||
name: 'Test Organization',
|
||||
slug: 'test-org',
|
||||
};
|
||||
|
||||
export const mockProject = {
|
||||
id: 'proj-123',
|
||||
name: 'Test Project',
|
||||
organizationId: 'org-123',
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { vi } from 'vitest';
|
||||
|
||||
export const mockOpenAIResponse = {
|
||||
choices: [{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
complexity: 'moderate',
|
||||
requiredInformation: ['technical specs'],
|
||||
specificEntities: [],
|
||||
searchQueries: ['test query'],
|
||||
expectedSources: 2,
|
||||
reasoning: 'Test analysis',
|
||||
}),
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
export const mockOpenAIQuestionExtractionResponse = {
|
||||
choices: [{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
sections: [{
|
||||
id: 'sec-1',
|
||||
title: 'Requirements',
|
||||
questions: [{ id: 'q-1', question: 'What is your approach?' }],
|
||||
}],
|
||||
}),
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
export const createOpenAIMock = () => ({
|
||||
chat: {
|
||||
completions: {
|
||||
create: vi.fn().mockResolvedValue(mockOpenAIResponse),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const mockOpenAIClient = createOpenAIMock();
|
||||
@@ -0,0 +1,26 @@
|
||||
import { vi } from 'vitest';
|
||||
import { mockDeep, DeepMockProxy } from 'vitest-mock-extended';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
|
||||
export type MockPrismaClient = DeepMockProxy<PrismaClient>;
|
||||
|
||||
export const createPrismaMock = (): MockPrismaClient => mockDeep<PrismaClient>();
|
||||
|
||||
let prismaMock: MockPrismaClient | null = null;
|
||||
|
||||
export const getPrismaMock = () => {
|
||||
if (!prismaMock) {
|
||||
prismaMock = createPrismaMock();
|
||||
}
|
||||
return prismaMock;
|
||||
};
|
||||
|
||||
export const resetPrismaMock = () => {
|
||||
prismaMock = createPrismaMock();
|
||||
return prismaMock;
|
||||
};
|
||||
|
||||
// Mock the db module
|
||||
vi.mock('@/lib/db', () => ({
|
||||
db: getPrismaMock(),
|
||||
}));
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
ApiError,
|
||||
ValidationError,
|
||||
AuthorizationError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
ExternalServiceError,
|
||||
LlamaCloudConnectionError,
|
||||
AIServiceError,
|
||||
DatabaseError,
|
||||
ConfigurationError,
|
||||
isApiError,
|
||||
} from '@/lib/errors/api-errors';
|
||||
|
||||
describe('ApiError', () => {
|
||||
it('should create error with all properties', () => {
|
||||
const error = new ApiError('Test error', 500, 'TEST_ERROR');
|
||||
expect(error.message).toBe('Test error');
|
||||
expect(error.statusCode).toBe(500);
|
||||
expect(error.code).toBe('TEST_ERROR');
|
||||
expect(error.name).toBe('ApiError');
|
||||
});
|
||||
|
||||
it('should extend Error', () => {
|
||||
const error = new ApiError('Test', 400);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('should work without code', () => {
|
||||
const error = new ApiError('Test', 400);
|
||||
expect(error.code).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ValidationError', () => {
|
||||
it('should have correct status code and code', () => {
|
||||
const error = new ValidationError('Invalid input');
|
||||
expect(error.statusCode).toBe(400);
|
||||
expect(error.code).toBe('VALIDATION_ERROR');
|
||||
expect(error.name).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('should store validation details', () => {
|
||||
const details = [{ field: 'email', message: 'Invalid email' }];
|
||||
const error = new ValidationError('Validation failed', details);
|
||||
expect(error.details).toEqual(details);
|
||||
});
|
||||
|
||||
it('should extend ApiError', () => {
|
||||
const error = new ValidationError('Test');
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthorizationError', () => {
|
||||
it('should have correct status code and code', () => {
|
||||
const error = new AuthorizationError();
|
||||
expect(error.statusCode).toBe(401);
|
||||
expect(error.code).toBe('UNAUTHORIZED');
|
||||
expect(error.name).toBe('AuthorizationError');
|
||||
});
|
||||
|
||||
it('should use default message', () => {
|
||||
const error = new AuthorizationError();
|
||||
expect(error.message).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('should allow custom message', () => {
|
||||
const error = new AuthorizationError('Token expired');
|
||||
expect(error.message).toBe('Token expired');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ForbiddenError', () => {
|
||||
it('should have correct status code and code', () => {
|
||||
const error = new ForbiddenError();
|
||||
expect(error.statusCode).toBe(403);
|
||||
expect(error.code).toBe('FORBIDDEN');
|
||||
expect(error.name).toBe('ForbiddenError');
|
||||
});
|
||||
|
||||
it('should use default message', () => {
|
||||
const error = new ForbiddenError();
|
||||
expect(error.message).toBe('Access denied');
|
||||
});
|
||||
|
||||
it('should allow custom message', () => {
|
||||
const error = new ForbiddenError('Admin access required');
|
||||
expect(error.message).toBe('Admin access required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotFoundError', () => {
|
||||
it('should have correct status code and code', () => {
|
||||
const error = new NotFoundError();
|
||||
expect(error.statusCode).toBe(404);
|
||||
expect(error.code).toBe('NOT_FOUND');
|
||||
expect(error.name).toBe('NotFoundError');
|
||||
});
|
||||
|
||||
it('should use default message', () => {
|
||||
const error = new NotFoundError();
|
||||
expect(error.message).toBe('Resource not found');
|
||||
});
|
||||
|
||||
it('should allow custom message', () => {
|
||||
const error = new NotFoundError('Project not found');
|
||||
expect(error.message).toBe('Project not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExternalServiceError', () => {
|
||||
it('should have correct status code and code', () => {
|
||||
const error = new ExternalServiceError('Service unavailable', 'OpenAI');
|
||||
expect(error.statusCode).toBe(502);
|
||||
expect(error.code).toBe('EXTERNAL_SERVICE_ERROR');
|
||||
expect(error.name).toBe('ExternalServiceError');
|
||||
});
|
||||
|
||||
it('should store service name', () => {
|
||||
const error = new ExternalServiceError('Error', 'LlamaCloud');
|
||||
expect(error.service).toBe('LlamaCloud');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LlamaCloudConnectionError', () => {
|
||||
it('should have correct status code and code', () => {
|
||||
const error = new LlamaCloudConnectionError();
|
||||
expect(error.statusCode).toBe(502);
|
||||
expect(error.code).toBe('EXTERNAL_SERVICE_ERROR');
|
||||
expect(error.name).toBe('LlamaCloudConnectionError');
|
||||
});
|
||||
|
||||
it('should use default message', () => {
|
||||
const error = new LlamaCloudConnectionError();
|
||||
expect(error.message).toBe('LlamaCloud connection failed');
|
||||
});
|
||||
|
||||
it('should allow custom message', () => {
|
||||
const error = new LlamaCloudConnectionError('API key invalid');
|
||||
expect(error.message).toBe('API key invalid');
|
||||
});
|
||||
|
||||
it('should have LlamaCloud as service', () => {
|
||||
const error = new LlamaCloudConnectionError();
|
||||
expect(error.service).toBe('LlamaCloud');
|
||||
});
|
||||
|
||||
it('should extend ExternalServiceError', () => {
|
||||
const error = new LlamaCloudConnectionError();
|
||||
expect(error).toBeInstanceOf(ExternalServiceError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AIServiceError', () => {
|
||||
it('should have correct default status code and code', () => {
|
||||
const error = new AIServiceError('AI operation failed');
|
||||
expect(error.statusCode).toBe(500);
|
||||
expect(error.code).toBe('AI_SERVICE_ERROR');
|
||||
});
|
||||
|
||||
it('should allow custom status code', () => {
|
||||
const error = new AIServiceError('Rate limited', 429);
|
||||
expect(error.statusCode).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DatabaseError', () => {
|
||||
it('should have correct default status code and code', () => {
|
||||
const error = new DatabaseError('Query failed');
|
||||
expect(error.statusCode).toBe(500);
|
||||
expect(error.code).toBe('DATABASE_ERROR');
|
||||
});
|
||||
|
||||
it('should allow custom status code', () => {
|
||||
const error = new DatabaseError('Connection timeout', 503);
|
||||
expect(error.statusCode).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConfigurationError', () => {
|
||||
it('should have correct status code and code', () => {
|
||||
const error = new ConfigurationError('Missing API key');
|
||||
expect(error.statusCode).toBe(500);
|
||||
expect(error.code).toBe('CONFIGURATION_ERROR');
|
||||
expect(error.name).toBe('ConfigurationError');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isApiError', () => {
|
||||
it('should return true for ApiError instances', () => {
|
||||
expect(isApiError(new ApiError('test', 500))).toBe(true);
|
||||
expect(isApiError(new ValidationError('test'))).toBe(true);
|
||||
expect(isApiError(new AuthorizationError())).toBe(true);
|
||||
expect(isApiError(new ForbiddenError())).toBe(true);
|
||||
expect(isApiError(new NotFoundError())).toBe(true);
|
||||
expect(isApiError(new ExternalServiceError('test', 'service'))).toBe(true);
|
||||
expect(isApiError(new LlamaCloudConnectionError())).toBe(true);
|
||||
expect(isApiError(new AIServiceError('test'))).toBe(true);
|
||||
expect(isApiError(new DatabaseError('test'))).toBe(true);
|
||||
expect(isApiError(new ConfigurationError('test'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for regular Error', () => {
|
||||
expect(isApiError(new Error('test'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-error values', () => {
|
||||
expect(isApiError(null)).toBe(false);
|
||||
expect(isApiError(undefined)).toBe(false);
|
||||
expect(isApiError('error string')).toBe(false);
|
||||
expect(isApiError({ message: 'error object' })).toBe(false);
|
||||
expect(isApiError(123)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { apiHandler, withApiHandler } from '@/lib/middleware/api-handler';
|
||||
import {
|
||||
ValidationError,
|
||||
AuthorizationError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
ApiError,
|
||||
} from '@/lib/errors/api-errors';
|
||||
|
||||
// Mock NextRequest
|
||||
const createMockRequest = (body: any = {}) => ({
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
method: 'POST',
|
||||
headers: new Headers(),
|
||||
url: 'http://localhost:3000/api/test',
|
||||
});
|
||||
|
||||
// Helper to create NextResponse for withApiHandler tests
|
||||
const createJsonResponse = (data: any, status = 200) =>
|
||||
NextResponse.json(data, { status });
|
||||
|
||||
describe('apiHandler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return success response for successful handler', async () => {
|
||||
const handler = async () => ({ data: 'test', success: true });
|
||||
const response = await apiHandler(handler);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.data).toBe('test');
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should return JSON response with data', async () => {
|
||||
const testData = { items: [1, 2, 3], count: 3 };
|
||||
const handler = async () => testData;
|
||||
const response = await apiHandler(handler);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body).toEqual(testData);
|
||||
});
|
||||
|
||||
it('should return 400 for ValidationError', async () => {
|
||||
const handler = async () => {
|
||||
throw new ValidationError('Invalid input');
|
||||
};
|
||||
|
||||
const response = await apiHandler(handler);
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe('Invalid input');
|
||||
expect(body.code).toBe('VALIDATION_ERROR');
|
||||
});
|
||||
|
||||
it('should include details for ValidationError', async () => {
|
||||
const details = [{ path: ['email'], message: 'Invalid email format' }];
|
||||
const handler = async () => {
|
||||
throw new ValidationError('Validation failed', details);
|
||||
};
|
||||
|
||||
const response = await apiHandler(handler);
|
||||
const body = await response.json();
|
||||
expect(body.details).toEqual(details);
|
||||
});
|
||||
|
||||
it('should return 401 for AuthorizationError', async () => {
|
||||
const handler = async () => {
|
||||
throw new AuthorizationError('Token expired');
|
||||
};
|
||||
|
||||
const response = await apiHandler(handler);
|
||||
expect(response.status).toBe(401);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe('Token expired');
|
||||
expect(body.code).toBe('UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('should return 403 for ForbiddenError', async () => {
|
||||
const handler = async () => {
|
||||
throw new ForbiddenError('Admin access required');
|
||||
};
|
||||
|
||||
const response = await apiHandler(handler);
|
||||
expect(response.status).toBe(403);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe('Admin access required');
|
||||
expect(body.code).toBe('FORBIDDEN');
|
||||
});
|
||||
|
||||
it('should return 404 for NotFoundError', async () => {
|
||||
const handler = async () => {
|
||||
throw new NotFoundError('Project not found');
|
||||
};
|
||||
|
||||
const response = await apiHandler(handler);
|
||||
expect(response.status).toBe(404);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe('Project not found');
|
||||
expect(body.code).toBe('NOT_FOUND');
|
||||
});
|
||||
|
||||
it('should return custom status code for ApiError', async () => {
|
||||
const handler = async () => {
|
||||
throw new ApiError('Custom error', 418, 'TEAPOT');
|
||||
};
|
||||
|
||||
const response = await apiHandler(handler);
|
||||
expect(response.status).toBe(418);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe('Custom error');
|
||||
expect(body.code).toBe('TEAPOT');
|
||||
});
|
||||
|
||||
it('should return 500 for unexpected errors', async () => {
|
||||
const handler = async () => {
|
||||
throw new Error('Unexpected error');
|
||||
};
|
||||
|
||||
const response = await apiHandler(handler);
|
||||
expect(response.status).toBe(500);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe('Internal server error');
|
||||
});
|
||||
|
||||
it('should not leak error details for unexpected errors', async () => {
|
||||
const handler = async () => {
|
||||
throw new Error('Database password exposed');
|
||||
};
|
||||
|
||||
const response = await apiHandler(handler);
|
||||
const body = await response.json();
|
||||
expect(body.error).not.toContain('password');
|
||||
expect(body.error).toBe('Internal server error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withApiHandler', () => {
|
||||
const TestSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
value: z.number().positive('Value must be positive'),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should validate request body with schema', async () => {
|
||||
const handler = vi.fn().mockImplementation(() =>
|
||||
createJsonResponse({ success: true })
|
||||
);
|
||||
const wrappedHandler = withApiHandler(handler, {
|
||||
validationSchema: TestSchema,
|
||||
});
|
||||
|
||||
const mockRequest = createMockRequest({ name: 'test', value: 42 });
|
||||
const response = await wrappedHandler(mockRequest as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalledWith(mockRequest, { name: 'test', value: 42 });
|
||||
});
|
||||
|
||||
it('should reject invalid request body', async () => {
|
||||
const handler = vi.fn();
|
||||
const wrappedHandler = withApiHandler(handler, {
|
||||
validationSchema: TestSchema,
|
||||
});
|
||||
|
||||
const mockRequest = createMockRequest({ name: '', value: -5 });
|
||||
const response = await wrappedHandler(mockRequest as any);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.code).toBe('VALIDATION_ERROR');
|
||||
});
|
||||
|
||||
it('should include validation error details', async () => {
|
||||
const handler = vi.fn();
|
||||
const wrappedHandler = withApiHandler(handler, {
|
||||
validationSchema: TestSchema,
|
||||
});
|
||||
|
||||
const mockRequest = createMockRequest({ name: '', value: 'not a number' });
|
||||
const response = await wrappedHandler(mockRequest as any);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.details).toBeDefined();
|
||||
expect(Array.isArray(body.details)).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip validation when skipValidation is true', async () => {
|
||||
const handler = vi.fn().mockImplementation(() =>
|
||||
createJsonResponse({ skipped: true })
|
||||
);
|
||||
const wrappedHandler = withApiHandler(handler, {
|
||||
skipValidation: true,
|
||||
});
|
||||
|
||||
const mockRequest = createMockRequest({ any: 'data' });
|
||||
const response = await wrappedHandler(mockRequest as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalledWith(mockRequest, {});
|
||||
});
|
||||
|
||||
it('should parse JSON body without schema', async () => {
|
||||
const handler = vi.fn().mockImplementation(() =>
|
||||
createJsonResponse({ received: true })
|
||||
);
|
||||
const wrappedHandler = withApiHandler(handler, {});
|
||||
|
||||
const testBody = { custom: 'data', nested: { value: 123 } };
|
||||
const mockRequest = createMockRequest(testBody);
|
||||
const response = await wrappedHandler(mockRequest as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalledWith(mockRequest, testBody);
|
||||
});
|
||||
|
||||
it('should handle JSON parse errors', async () => {
|
||||
const handler = vi.fn();
|
||||
const wrappedHandler = withApiHandler(handler, {});
|
||||
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockRejectedValue(new Error('Invalid JSON')),
|
||||
};
|
||||
|
||||
const response = await wrappedHandler(mockRequest as any);
|
||||
expect(response.status).toBe(500);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should propagate handler errors correctly', async () => {
|
||||
const handler = vi.fn().mockRejectedValue(new NotFoundError('Not found'));
|
||||
const wrappedHandler = withApiHandler(handler, {
|
||||
validationSchema: TestSchema,
|
||||
});
|
||||
|
||||
const mockRequest = createMockRequest({ name: 'test', value: 1 });
|
||||
const response = await wrappedHandler(mockRequest as any);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should handle missing required fields', async () => {
|
||||
const handler = vi.fn();
|
||||
const wrappedHandler = withApiHandler(handler, {
|
||||
validationSchema: TestSchema,
|
||||
});
|
||||
|
||||
const mockRequest = createMockRequest({ name: 'test' }); // missing value
|
||||
const response = await wrappedHandler(mockRequest as any);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should validate complex nested schemas', async () => {
|
||||
const NestedSchema = z.object({
|
||||
user: z.object({
|
||||
name: z.string(),
|
||||
email: z.string().email(),
|
||||
}),
|
||||
items: z.array(z.string()),
|
||||
});
|
||||
|
||||
const handler = vi.fn().mockImplementation(() =>
|
||||
createJsonResponse({ ok: true })
|
||||
);
|
||||
const wrappedHandler = withApiHandler(handler, {
|
||||
validationSchema: NestedSchema,
|
||||
});
|
||||
|
||||
const validData = {
|
||||
user: { name: 'Test', email: 'test@example.com' },
|
||||
items: ['a', 'b'],
|
||||
};
|
||||
|
||||
const mockRequest = createMockRequest(validData);
|
||||
const response = await wrappedHandler(mockRequest as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalledWith(mockRequest, validData);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { DefaultResponseService } from '@/lib/services/default-response-service';
|
||||
|
||||
describe('DefaultResponseService', () => {
|
||||
let service: DefaultResponseService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new DefaultResponseService();
|
||||
});
|
||||
|
||||
describe('generateResponse', () => {
|
||||
it('should return security-related response for security keywords', () => {
|
||||
const keywords = ['security', 'secure', 'compliance', 'gdpr', 'hipaa', 'encrypt'];
|
||||
|
||||
keywords.forEach(keyword => {
|
||||
const result = service.generateResponse(`What is your ${keyword} approach?`);
|
||||
expect(result.response).toContain('security');
|
||||
expect(result.confidence).toBe(0.7);
|
||||
expect(result.sources).toEqual([]);
|
||||
expect(result.generatedAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return implementation-related response for deployment keywords', () => {
|
||||
const keywords = ['implementation', 'deploy', 'timeline', 'rollout', 'setup'];
|
||||
|
||||
keywords.forEach(keyword => {
|
||||
const result = service.generateResponse(`Describe your ${keyword} process`);
|
||||
expect(result.response).toContain('implementation');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return pricing-related response for cost keywords', () => {
|
||||
const keywords = ['price', 'pricing', 'cost', 'budget', 'fee', 'subscription'];
|
||||
|
||||
keywords.forEach(keyword => {
|
||||
const result = service.generateResponse(`What is the ${keyword}?`);
|
||||
expect(result.response).toContain('pricing');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return support-related response for help keywords', () => {
|
||||
const keywords = ['support', 'maintenance', 'help', 'training', 'documentation'];
|
||||
|
||||
keywords.forEach(keyword => {
|
||||
const result = service.generateResponse(`Tell me about ${keyword}`);
|
||||
expect(result.response).toContain('support');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return integration-related response for API keywords', () => {
|
||||
const keywords = ['integration', 'api', 'connect', 'interoperability', 'sync'];
|
||||
|
||||
keywords.forEach(keyword => {
|
||||
const result = service.generateResponse(`How does ${keyword} work?`);
|
||||
expect(result.response).toContain('integration');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return scalability-related response for performance keywords', () => {
|
||||
const keywords = ['scalability', 'performance', 'capacity', 'load', 'enterprise'];
|
||||
|
||||
keywords.forEach(keyword => {
|
||||
const result = service.generateResponse(`Explain ${keyword}`);
|
||||
expect(result.response).toContain('scale');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return default response for unmatched questions', () => {
|
||||
const result = service.generateResponse('What is the meaning of life?');
|
||||
expect(result.response).toContain('comprehensive capabilities');
|
||||
expect(result.confidence).toBe(0.7);
|
||||
});
|
||||
|
||||
it('should be case-insensitive in keyword matching', () => {
|
||||
const result1 = service.generateResponse('SECURITY measures');
|
||||
const result2 = service.generateResponse('security measures');
|
||||
expect(result1.response).toBe(result2.response);
|
||||
});
|
||||
|
||||
it('should match keywords anywhere in the question', () => {
|
||||
const result = service.generateResponse('We need to know about your security protocols and compliance');
|
||||
expect(result.response).toContain('security');
|
||||
});
|
||||
|
||||
it('should return response with correct structure', () => {
|
||||
const result = service.generateResponse('Any question');
|
||||
expect(result).toHaveProperty('response');
|
||||
expect(result).toHaveProperty('sources');
|
||||
expect(result).toHaveProperty('confidence');
|
||||
expect(result).toHaveProperty('generatedAt');
|
||||
expect(typeof result.response).toBe('string');
|
||||
expect(Array.isArray(result.sources)).toBe(true);
|
||||
expect(typeof result.confidence).toBe('number');
|
||||
expect(typeof result.generatedAt).toBe('string');
|
||||
});
|
||||
|
||||
it('should return ISO date string for generatedAt', () => {
|
||||
const result = service.generateResponse('Test question');
|
||||
const date = new Date(result.generatedAt);
|
||||
expect(date.toISOString()).toBe(result.generatedAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addResponseTemplate', () => {
|
||||
it('should add new response template', () => {
|
||||
const templatesBefore = service.getResponseTemplates();
|
||||
const countBefore = templatesBefore.length;
|
||||
|
||||
service.addResponseTemplate(
|
||||
['custom', 'keyword'],
|
||||
'Custom response text'
|
||||
);
|
||||
|
||||
const templatesAfter = service.getResponseTemplates();
|
||||
expect(templatesAfter.length).toBe(countBefore + 1);
|
||||
});
|
||||
|
||||
it('should use newly added template for matching', () => {
|
||||
service.addResponseTemplate(
|
||||
['unicorn', 'magical'],
|
||||
'This is a magical unicorn response'
|
||||
);
|
||||
|
||||
const result = service.generateResponse('Tell me about unicorns');
|
||||
expect(result.response).toBe('This is a magical unicorn response');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResponseTemplates', () => {
|
||||
it('should return all templates', () => {
|
||||
const templates = service.getResponseTemplates();
|
||||
expect(Array.isArray(templates)).toBe(true);
|
||||
expect(templates.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return templates with correct structure', () => {
|
||||
const templates = service.getResponseTemplates();
|
||||
templates.forEach(template => {
|
||||
expect(template).toHaveProperty('keywords');
|
||||
expect(template).toHaveProperty('response');
|
||||
expect(Array.isArray(template.keywords)).toBe(true);
|
||||
expect(typeof template.response).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return a copy of templates array', () => {
|
||||
const templates1 = service.getResponseTemplates();
|
||||
const templates2 = service.getResponseTemplates();
|
||||
expect(templates1).not.toBe(templates2);
|
||||
});
|
||||
|
||||
it('should include default templates', () => {
|
||||
const templates = service.getResponseTemplates();
|
||||
const hasSecurityTemplate = templates.some(t =>
|
||||
t.keywords.includes('security')
|
||||
);
|
||||
expect(hasSecurityTemplate).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { FileValidator } from '@/lib/services/file-validator';
|
||||
import { ValidationError } from '@/lib/errors/api-errors';
|
||||
|
||||
describe('FileValidator', () => {
|
||||
let validator: FileValidator;
|
||||
|
||||
beforeEach(() => {
|
||||
validator = new FileValidator();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should use default config', () => {
|
||||
const v = new FileValidator();
|
||||
expect(v).toBeDefined();
|
||||
});
|
||||
|
||||
it('should accept custom config', () => {
|
||||
const v = new FileValidator({
|
||||
maxFileSize: 10 * 1024 * 1024, // 10MB
|
||||
});
|
||||
expect(v).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSupportedFileType', () => {
|
||||
it('should return true for PDF files', () => {
|
||||
expect(validator.isSupportedFileType('document.pdf')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for Excel files', () => {
|
||||
expect(validator.isSupportedFileType('data.xlsx')).toBe(true);
|
||||
expect(validator.isSupportedFileType('data.xls')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for Word documents', () => {
|
||||
expect(validator.isSupportedFileType('document.doc')).toBe(true);
|
||||
expect(validator.isSupportedFileType('document.docx')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for CSV files', () => {
|
||||
expect(validator.isSupportedFileType('data.csv')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unsupported files', () => {
|
||||
expect(validator.isSupportedFileType('image.png')).toBe(false);
|
||||
expect(validator.isSupportedFileType('script.js')).toBe(false);
|
||||
expect(validator.isSupportedFileType('styles.css')).toBe(false);
|
||||
expect(validator.isSupportedFileType('archive.zip')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle files without extensions', () => {
|
||||
expect(validator.isSupportedFileType('noextension')).toBe(false);
|
||||
});
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
expect(validator.isSupportedFileType('document.PDF')).toBe(true);
|
||||
expect(validator.isSupportedFileType('data.XLSX')).toBe(true);
|
||||
expect(validator.isSupportedFileType('file.Docx')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle multiple dots in filename', () => {
|
||||
expect(validator.isSupportedFileType('my.document.file.pdf')).toBe(true);
|
||||
expect(validator.isSupportedFileType('file.backup.xlsx')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileExtension', () => {
|
||||
it('should extract extension correctly', () => {
|
||||
expect(validator.getFileExtension('file.pdf')).toBe('pdf');
|
||||
expect(validator.getFileExtension('document.docx')).toBe('docx');
|
||||
});
|
||||
|
||||
it('should handle multiple dots', () => {
|
||||
expect(validator.getFileExtension('my.file.pdf')).toBe('pdf');
|
||||
expect(validator.getFileExtension('backup.2024.xlsx')).toBe('xlsx');
|
||||
});
|
||||
|
||||
it('should return lowercase extension', () => {
|
||||
expect(validator.getFileExtension('file.PDF')).toBe('pdf');
|
||||
expect(validator.getFileExtension('file.DOCX')).toBe('docx');
|
||||
});
|
||||
|
||||
it('should return null for files without extension', () => {
|
||||
expect(validator.getFileExtension('noext')).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle hidden files', () => {
|
||||
expect(validator.getFileExtension('.gitignore')).toBe('gitignore');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHumanFileSize', () => {
|
||||
it('should format bytes correctly', () => {
|
||||
expect(validator.getHumanFileSize(500)).toBe('500.0 B');
|
||||
expect(validator.getHumanFileSize(0)).toBe('0.0 B');
|
||||
});
|
||||
|
||||
it('should format kilobytes correctly', () => {
|
||||
expect(validator.getHumanFileSize(1024)).toBe('1.0 KB');
|
||||
expect(validator.getHumanFileSize(1536)).toBe('1.5 KB');
|
||||
expect(validator.getHumanFileSize(2048)).toBe('2.0 KB');
|
||||
});
|
||||
|
||||
it('should format megabytes correctly', () => {
|
||||
expect(validator.getHumanFileSize(1024 * 1024)).toBe('1.0 MB');
|
||||
expect(validator.getHumanFileSize(5 * 1024 * 1024)).toBe('5.0 MB');
|
||||
expect(validator.getHumanFileSize(10.5 * 1024 * 1024)).toBe('10.5 MB');
|
||||
});
|
||||
|
||||
it('should format gigabytes correctly', () => {
|
||||
expect(validator.getHumanFileSize(1024 * 1024 * 1024)).toBe('1.0 GB');
|
||||
expect(validator.getHumanFileSize(2.5 * 1024 * 1024 * 1024)).toBe('2.5 GB');
|
||||
});
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
expect(validator.getHumanFileSize(1023)).toBe('1023.0 B');
|
||||
expect(validator.getHumanFileSize(1025)).toBe('1.0 KB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFile', () => {
|
||||
it('should validate a valid PDF file', async () => {
|
||||
const file = new File(['test content'], 'document.pdf', { type: 'application/pdf' });
|
||||
const result = await validator.validateFile(file);
|
||||
expect(result.name).toBe('document.pdf');
|
||||
expect(result.type).toBe('application/pdf');
|
||||
});
|
||||
|
||||
it('should validate a valid Excel file', async () => {
|
||||
const file = new File(['test'], 'data.xlsx', {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
const result = await validator.validateFile(file);
|
||||
expect(result.name).toBe('data.xlsx');
|
||||
});
|
||||
|
||||
it('should validate a valid Word document', async () => {
|
||||
const file = new File(['test'], 'document.docx', {
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
});
|
||||
const result = await validator.validateFile(file);
|
||||
expect(result.name).toBe('document.docx');
|
||||
});
|
||||
|
||||
it('should reject unsupported file types', async () => {
|
||||
const file = new File(['test'], 'image.png', { type: 'image/png' });
|
||||
await expect(validator.validateFile(file)).rejects.toThrow(ValidationError);
|
||||
await expect(validator.validateFile(file)).rejects.toThrow(/Unsupported file format/);
|
||||
});
|
||||
|
||||
it('should reject files exceeding max size', async () => {
|
||||
// Create a validator with 1KB limit for testing
|
||||
const smallValidator = new FileValidator({ maxFileSize: 1024 });
|
||||
// Create a file larger than 1KB
|
||||
const largeContent = 'x'.repeat(2048);
|
||||
const file = new File([largeContent], 'large.pdf', { type: 'application/pdf' });
|
||||
|
||||
await expect(smallValidator.validateFile(file)).rejects.toThrow(ValidationError);
|
||||
await expect(smallValidator.validateFile(file)).rejects.toThrow(/exceeds maximum allowed size/);
|
||||
});
|
||||
|
||||
it('should accept files at exact max size', async () => {
|
||||
const smallValidator = new FileValidator({ maxFileSize: 1024 });
|
||||
const content = 'x'.repeat(1024);
|
||||
const file = new File([content], 'exact.pdf', { type: 'application/pdf' });
|
||||
|
||||
const result = await smallValidator.validateFile(file);
|
||||
expect(result.name).toBe('exact.pdf');
|
||||
});
|
||||
|
||||
it('should validate CSV files', async () => {
|
||||
const file = new File(['col1,col2\n1,2'], 'data.csv', { type: 'text/csv' });
|
||||
const result = await validator.validateFile(file);
|
||||
expect(result.name).toBe('data.csv');
|
||||
});
|
||||
|
||||
it('should warn but accept files with unrecognized MIME type but valid extension', async () => {
|
||||
// A PDF with generic octet-stream type
|
||||
const file = new File(['test'], 'document.pdf', { type: 'application/octet-stream' });
|
||||
const result = await validator.validateFile(file);
|
||||
expect(result.name).toBe('document.pdf');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
ExtractQuestionsRequestSchema,
|
||||
QuestionSchema,
|
||||
SectionSchema,
|
||||
ExtractedQuestionsSchema,
|
||||
ExtractQuestionsResponseSchema,
|
||||
} from '@/lib/validators/extract-questions';
|
||||
|
||||
describe('ExtractQuestionsRequestSchema', () => {
|
||||
it('should validate a complete valid request', () => {
|
||||
const validRequest = {
|
||||
documentId: 'doc-123',
|
||||
documentName: 'test.pdf',
|
||||
content: 'RFP document content',
|
||||
projectId: 'proj-456',
|
||||
};
|
||||
|
||||
const result = ExtractQuestionsRequestSchema.safeParse(validRequest);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual(validRequest);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject empty documentId', () => {
|
||||
const invalidRequest = {
|
||||
documentId: '',
|
||||
documentName: 'test.pdf',
|
||||
content: 'content',
|
||||
projectId: 'proj-456',
|
||||
};
|
||||
|
||||
const result = ExtractQuestionsRequestSchema.safeParse(invalidRequest);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('Document ID is required');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject empty documentName', () => {
|
||||
const invalidRequest = {
|
||||
documentId: 'doc-123',
|
||||
documentName: '',
|
||||
content: 'content',
|
||||
projectId: 'proj-456',
|
||||
};
|
||||
|
||||
const result = ExtractQuestionsRequestSchema.safeParse(invalidRequest);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject empty content', () => {
|
||||
const invalidRequest = {
|
||||
documentId: 'doc-123',
|
||||
documentName: 'test.pdf',
|
||||
content: '',
|
||||
projectId: 'proj-456',
|
||||
};
|
||||
|
||||
const result = ExtractQuestionsRequestSchema.safeParse(invalidRequest);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject empty projectId', () => {
|
||||
const invalidRequest = {
|
||||
documentId: 'doc-123',
|
||||
documentName: 'test.pdf',
|
||||
content: 'content',
|
||||
projectId: '',
|
||||
};
|
||||
|
||||
const result = ExtractQuestionsRequestSchema.safeParse(invalidRequest);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject missing required fields', () => {
|
||||
const result = ExtractQuestionsRequestSchema.safeParse({});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('QuestionSchema', () => {
|
||||
it('should validate a valid question', () => {
|
||||
const question = { id: 'q-1', question: 'What is your approach?' };
|
||||
const result = QuestionSchema.safeParse(question);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject question without id', () => {
|
||||
const question = { question: 'What is your approach?' };
|
||||
const result = QuestionSchema.safeParse(question);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject question without question text', () => {
|
||||
const question = { id: 'q-1' };
|
||||
const result = QuestionSchema.safeParse(question);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SectionSchema', () => {
|
||||
it('should validate a section with questions', () => {
|
||||
const section = {
|
||||
id: 'sec-1',
|
||||
title: 'Technical Requirements',
|
||||
questions: [{ id: 'q-1', question: 'Describe your architecture' }],
|
||||
};
|
||||
const result = SectionSchema.safeParse(section);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow optional description', () => {
|
||||
const section = {
|
||||
id: 'sec-1',
|
||||
title: 'Requirements',
|
||||
description: 'Optional description',
|
||||
questions: [],
|
||||
};
|
||||
const result = SectionSchema.safeParse(section);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.description).toBe('Optional description');
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate section without description', () => {
|
||||
const section = {
|
||||
id: 'sec-1',
|
||||
title: 'Requirements',
|
||||
questions: [],
|
||||
};
|
||||
const result = SectionSchema.safeParse(section);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject section without title', () => {
|
||||
const section = {
|
||||
id: 'sec-1',
|
||||
questions: [],
|
||||
};
|
||||
const result = SectionSchema.safeParse(section);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExtractedQuestionsSchema', () => {
|
||||
it('should validate extracted questions with sections', () => {
|
||||
const extracted = {
|
||||
sections: [
|
||||
{
|
||||
id: 'sec-1',
|
||||
title: 'Section 1',
|
||||
questions: [{ id: 'q-1', question: 'Question 1' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = ExtractedQuestionsSchema.safeParse(extracted);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate with empty sections array', () => {
|
||||
const extracted = { sections: [] };
|
||||
const result = ExtractedQuestionsSchema.safeParse(extracted);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExtractQuestionsResponseSchema', () => {
|
||||
it('should validate complete response', () => {
|
||||
const response = {
|
||||
documentId: 'doc-123',
|
||||
documentName: 'test.pdf',
|
||||
sections: [],
|
||||
extractedAt: new Date().toISOString(),
|
||||
};
|
||||
const result = ExtractQuestionsResponseSchema.safeParse(response);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow optional summary and eligibility', () => {
|
||||
const response = {
|
||||
documentId: 'doc-123',
|
||||
documentName: 'test.pdf',
|
||||
sections: [],
|
||||
extractedAt: new Date().toISOString(),
|
||||
summary: 'Document summary',
|
||||
eligibility: ['Requirement 1', 'Requirement 2'],
|
||||
};
|
||||
const result = ExtractQuestionsResponseSchema.safeParse(response);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.summary).toBe('Document summary');
|
||||
expect(result.data.eligibility).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
generateResponseSchema,
|
||||
generateResponseMetadataSchema,
|
||||
generateResponseSchema_Response,
|
||||
} from '@/lib/validators/generate-response';
|
||||
|
||||
describe('generateResponseSchema', () => {
|
||||
it('should validate a minimal valid request', () => {
|
||||
const request = {
|
||||
question: 'What is your approach?',
|
||||
projectId: 'proj-123',
|
||||
};
|
||||
const result = generateResponseSchema.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.question).toBe('What is your approach?');
|
||||
expect(result.data.projectId).toBe('proj-123');
|
||||
expect(result.data.documentIds).toEqual([]);
|
||||
expect(result.data.selectedIndexIds).toEqual([]);
|
||||
expect(result.data.useAllIndexes).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate a complete request with all fields', () => {
|
||||
const request = {
|
||||
question: 'Describe your security measures',
|
||||
projectId: 'proj-123',
|
||||
documentIds: ['doc-1', 'doc-2'],
|
||||
selectedIndexIds: ['idx-1'],
|
||||
useAllIndexes: true,
|
||||
};
|
||||
const result = generateResponseSchema.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.documentIds).toEqual(['doc-1', 'doc-2']);
|
||||
expect(result.data.useAllIndexes).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject empty question', () => {
|
||||
const request = {
|
||||
question: '',
|
||||
projectId: 'proj-123',
|
||||
};
|
||||
const result = generateResponseSchema.safeParse(request);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('Question is required');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject question exceeding max length', () => {
|
||||
const request = {
|
||||
question: 'a'.repeat(1001),
|
||||
projectId: 'proj-123',
|
||||
};
|
||||
const result = generateResponseSchema.safeParse(request);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('Question too long');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject empty projectId', () => {
|
||||
const request = {
|
||||
question: 'Valid question',
|
||||
projectId: '',
|
||||
};
|
||||
const result = generateResponseSchema.safeParse(request);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept question at max length', () => {
|
||||
const request = {
|
||||
question: 'a'.repeat(1000),
|
||||
projectId: 'proj-123',
|
||||
};
|
||||
const result = generateResponseSchema.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateResponseMetadataSchema', () => {
|
||||
it('should validate valid metadata', () => {
|
||||
const metadata = {
|
||||
confidence: 0.85,
|
||||
generatedAt: new Date().toISOString(),
|
||||
indexesUsed: ['idx-1', 'idx-2'],
|
||||
};
|
||||
const result = generateResponseMetadataSchema.safeParse(metadata);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject confidence below 0', () => {
|
||||
const metadata = {
|
||||
confidence: -0.1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
indexesUsed: [],
|
||||
};
|
||||
const result = generateResponseMetadataSchema.safeParse(metadata);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject confidence above 1', () => {
|
||||
const metadata = {
|
||||
confidence: 1.1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
indexesUsed: [],
|
||||
};
|
||||
const result = generateResponseMetadataSchema.safeParse(metadata);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept confidence at boundaries (0 and 1)', () => {
|
||||
expect(generateResponseMetadataSchema.safeParse({
|
||||
confidence: 0,
|
||||
generatedAt: new Date().toISOString(),
|
||||
indexesUsed: [],
|
||||
}).success).toBe(true);
|
||||
|
||||
expect(generateResponseMetadataSchema.safeParse({
|
||||
confidence: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
indexesUsed: [],
|
||||
}).success).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow optional note', () => {
|
||||
const metadata = {
|
||||
confidence: 0.5,
|
||||
generatedAt: new Date().toISOString(),
|
||||
indexesUsed: [],
|
||||
note: 'Additional context',
|
||||
};
|
||||
const result = generateResponseMetadataSchema.safeParse(metadata);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.note).toBe('Additional context');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateResponseSchema_Response', () => {
|
||||
it('should validate complete response', () => {
|
||||
const response = {
|
||||
success: true,
|
||||
response: 'Generated response text',
|
||||
sources: [{
|
||||
id: 1,
|
||||
fileName: 'document.pdf',
|
||||
}],
|
||||
metadata: {
|
||||
confidence: 0.9,
|
||||
generatedAt: new Date().toISOString(),
|
||||
indexesUsed: ['idx-1'],
|
||||
},
|
||||
};
|
||||
const result = generateResponseSchema_Response.safeParse(response);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate source with all optional fields', () => {
|
||||
const response = {
|
||||
success: true,
|
||||
response: 'Response text',
|
||||
sources: [{
|
||||
id: 1,
|
||||
fileName: 'document.pdf',
|
||||
filePath: '/path/to/file',
|
||||
pageNumber: '5',
|
||||
documentId: 'doc-123',
|
||||
relevance: 0.95,
|
||||
textContent: 'Relevant excerpt',
|
||||
}],
|
||||
metadata: {
|
||||
confidence: 0.8,
|
||||
generatedAt: new Date().toISOString(),
|
||||
indexesUsed: [],
|
||||
},
|
||||
};
|
||||
const result = generateResponseSchema_Response.safeParse(response);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.sources[0].filePath).toBe('/path/to/file');
|
||||
expect(result.data.sources[0].relevance).toBe(0.95);
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate response with empty sources array', () => {
|
||||
const response = {
|
||||
success: false,
|
||||
response: 'No sources found',
|
||||
sources: [],
|
||||
metadata: {
|
||||
confidence: 0,
|
||||
generatedAt: new Date().toISOString(),
|
||||
indexesUsed: [],
|
||||
},
|
||||
};
|
||||
const result = generateResponseSchema_Response.safeParse(response);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
SUPPORTED_FILE_EXTENSIONS,
|
||||
LlamaParseOptionsSchema,
|
||||
FileValidationSchema,
|
||||
LlamaParseResultSchema,
|
||||
LlamaParseResponseSchema,
|
||||
} from '@/lib/validators/llamaparse';
|
||||
|
||||
describe('SUPPORTED_FILE_EXTENSIONS', () => {
|
||||
it('should include all expected extensions', () => {
|
||||
expect(SUPPORTED_FILE_EXTENSIONS).toContain('pdf');
|
||||
expect(SUPPORTED_FILE_EXTENSIONS).toContain('doc');
|
||||
expect(SUPPORTED_FILE_EXTENSIONS).toContain('docx');
|
||||
expect(SUPPORTED_FILE_EXTENSIONS).toContain('csv');
|
||||
expect(SUPPORTED_FILE_EXTENSIONS).toContain('xlsx');
|
||||
expect(SUPPORTED_FILE_EXTENSIONS).toContain('xls');
|
||||
});
|
||||
|
||||
it('should have exactly 6 supported extensions', () => {
|
||||
expect(SUPPORTED_FILE_EXTENSIONS).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LlamaParseOptionsSchema', () => {
|
||||
it('should validate with default values', () => {
|
||||
const options = {};
|
||||
const result = LlamaParseOptionsSchema.safeParse(options);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.fastMode).toBe(false);
|
||||
expect(result.data.premiumMode).toBe(false);
|
||||
expect(result.data.complexTables).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate with all options enabled', () => {
|
||||
const options = {
|
||||
fastMode: true,
|
||||
premiumMode: true,
|
||||
complexTables: true,
|
||||
};
|
||||
const result = LlamaParseOptionsSchema.safeParse(options);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.fastMode).toBe(true);
|
||||
expect(result.data.premiumMode).toBe(true);
|
||||
expect(result.data.complexTables).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should coerce non-boolean values', () => {
|
||||
const options = {
|
||||
fastMode: 'true',
|
||||
premiumMode: 1,
|
||||
};
|
||||
const result = LlamaParseOptionsSchema.safeParse(options);
|
||||
// Zod boolean will fail on non-boolean values without coerce
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileValidationSchema', () => {
|
||||
it('should validate valid file metadata', () => {
|
||||
const file = {
|
||||
name: 'document.pdf',
|
||||
size: 1024,
|
||||
type: 'application/pdf',
|
||||
};
|
||||
const result = FileValidationSchema.safeParse(file);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject empty file name', () => {
|
||||
const file = {
|
||||
name: '',
|
||||
size: 1024,
|
||||
type: 'application/pdf',
|
||||
};
|
||||
const result = FileValidationSchema.safeParse(file);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('File name is required');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject zero file size', () => {
|
||||
const file = {
|
||||
name: 'document.pdf',
|
||||
size: 0,
|
||||
type: 'application/pdf',
|
||||
};
|
||||
const result = FileValidationSchema.safeParse(file);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('File size must be positive');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject negative file size', () => {
|
||||
const file = {
|
||||
name: 'document.pdf',
|
||||
size: -100,
|
||||
type: 'application/pdf',
|
||||
};
|
||||
const result = FileValidationSchema.safeParse(file);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject empty file type', () => {
|
||||
const file = {
|
||||
name: 'document.pdf',
|
||||
size: 1024,
|
||||
type: '',
|
||||
};
|
||||
const result = FileValidationSchema.safeParse(file);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('File type is required');
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept large file sizes', () => {
|
||||
const file = {
|
||||
name: 'large-document.pdf',
|
||||
size: 100 * 1024 * 1024, // 100MB
|
||||
type: 'application/pdf',
|
||||
};
|
||||
const result = FileValidationSchema.safeParse(file);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LlamaParseResultSchema', () => {
|
||||
it('should validate complete result', () => {
|
||||
const result = {
|
||||
id: 'parse-123',
|
||||
documentName: 'document.pdf',
|
||||
status: 'completed',
|
||||
content: 'Parsed document content',
|
||||
};
|
||||
const parseResult = LlamaParseResultSchema.safeParse(result);
|
||||
expect(parseResult.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow optional metadata', () => {
|
||||
const result = {
|
||||
id: 'parse-123',
|
||||
documentName: 'document.pdf',
|
||||
status: 'completed',
|
||||
content: 'Content',
|
||||
metadata: {
|
||||
pageCount: 10,
|
||||
processingTime: 500,
|
||||
},
|
||||
};
|
||||
const parseResult = LlamaParseResultSchema.safeParse(result);
|
||||
expect(parseResult.success).toBe(true);
|
||||
if (parseResult.success) {
|
||||
expect(parseResult.data.metadata).toBeDefined();
|
||||
expect(parseResult.data.metadata?.pageCount).toBe(10);
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate without metadata', () => {
|
||||
const result = {
|
||||
id: 'parse-123',
|
||||
documentName: 'document.pdf',
|
||||
status: 'pending',
|
||||
content: '',
|
||||
};
|
||||
const parseResult = LlamaParseResultSchema.safeParse(result);
|
||||
expect(parseResult.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LlamaParseResponseSchema', () => {
|
||||
it('should validate successful response', () => {
|
||||
const response = {
|
||||
success: true,
|
||||
documentId: 'doc-123',
|
||||
documentName: 'document.pdf',
|
||||
status: 'completed',
|
||||
content: 'Parsed content',
|
||||
};
|
||||
const result = LlamaParseResponseSchema.safeParse(response);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate failed response', () => {
|
||||
const response = {
|
||||
success: false,
|
||||
documentId: 'doc-123',
|
||||
documentName: 'document.pdf',
|
||||
status: 'failed',
|
||||
content: '',
|
||||
};
|
||||
const result = LlamaParseResponseSchema.safeParse(response);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.success).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow metadata in response', () => {
|
||||
const response = {
|
||||
success: true,
|
||||
documentId: 'doc-123',
|
||||
documentName: 'document.pdf',
|
||||
status: 'completed',
|
||||
content: 'Content',
|
||||
metadata: {
|
||||
format: 'pdf',
|
||||
pages: 5,
|
||||
},
|
||||
};
|
||||
const result = LlamaParseResponseSchema.safeParse(response);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
StepTypeSchema,
|
||||
StepStatusSchema,
|
||||
StepResultSchema,
|
||||
QuestionAnalysisSchema,
|
||||
DocumentSearchResultSchema,
|
||||
InformationExtractionSchema,
|
||||
ResponseSynthesisSchema,
|
||||
MultiStepGenerateRequestSchema,
|
||||
StepUpdateSchema,
|
||||
MultiStepConfigSchema,
|
||||
} from '@/lib/validators/multi-step-response';
|
||||
|
||||
describe('StepTypeSchema', () => {
|
||||
it('should validate all step types', () => {
|
||||
const types = [
|
||||
'analyze_question',
|
||||
'search_documents',
|
||||
'extract_information',
|
||||
'synthesize_response',
|
||||
'validate_answer',
|
||||
];
|
||||
|
||||
types.forEach(type => {
|
||||
const result = StepTypeSchema.safeParse(type);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject invalid step type', () => {
|
||||
const result = StepTypeSchema.safeParse('invalid_step');
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StepStatusSchema', () => {
|
||||
it('should validate all statuses', () => {
|
||||
const statuses = ['pending', 'running', 'completed', 'failed'];
|
||||
|
||||
statuses.forEach(status => {
|
||||
const result = StepStatusSchema.safeParse(status);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject invalid status', () => {
|
||||
const result = StepStatusSchema.safeParse('unknown');
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StepResultSchema', () => {
|
||||
it('should validate minimal step result', () => {
|
||||
const step = {
|
||||
id: 'step-1',
|
||||
type: 'analyze_question',
|
||||
title: 'Analyzing Question',
|
||||
description: 'Analyzing the question complexity',
|
||||
status: 'completed',
|
||||
startTime: new Date(),
|
||||
};
|
||||
const result = StepResultSchema.safeParse(step);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate complete step result', () => {
|
||||
const step = {
|
||||
id: 'step-1',
|
||||
type: 'search_documents',
|
||||
title: 'Searching Documents',
|
||||
description: 'Searching for relevant documents',
|
||||
status: 'completed',
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
duration: 1500,
|
||||
output: { documentsFound: 5 },
|
||||
metadata: { query: 'security' },
|
||||
};
|
||||
const result = StepResultSchema.safeParse(step);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate failed step with error', () => {
|
||||
const step = {
|
||||
id: 'step-1',
|
||||
type: 'extract_information',
|
||||
title: 'Extracting Information',
|
||||
description: 'Extracting key information',
|
||||
status: 'failed',
|
||||
startTime: new Date(),
|
||||
error: 'Timeout exceeded',
|
||||
};
|
||||
const result = StepResultSchema.safeParse(step);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.error).toBe('Timeout exceeded');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('QuestionAnalysisSchema', () => {
|
||||
it('should validate all complexity levels', () => {
|
||||
const levels = ['simple', 'moderate', 'complex', 'multi-part'];
|
||||
|
||||
levels.forEach(level => {
|
||||
const analysis = {
|
||||
complexity: level,
|
||||
requiredInformation: ['spec'],
|
||||
specificEntities: [],
|
||||
searchQueries: ['query'],
|
||||
expectedSources: 2,
|
||||
reasoning: 'Analysis reasoning',
|
||||
};
|
||||
const result = QuestionAnalysisSchema.safeParse(analysis);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject invalid complexity level', () => {
|
||||
const analysis = {
|
||||
complexity: 'unknown',
|
||||
requiredInformation: [],
|
||||
specificEntities: [],
|
||||
searchQueries: [],
|
||||
expectedSources: 1,
|
||||
reasoning: 'Test',
|
||||
};
|
||||
const result = QuestionAnalysisSchema.safeParse(analysis);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate with arrays of varying lengths', () => {
|
||||
const analysis = {
|
||||
complexity: 'complex',
|
||||
requiredInformation: ['spec1', 'spec2', 'spec3'],
|
||||
specificEntities: ['entity1'],
|
||||
searchQueries: ['query1', 'query2'],
|
||||
expectedSources: 5,
|
||||
reasoning: 'Detailed reasoning',
|
||||
};
|
||||
const result = QuestionAnalysisSchema.safeParse(analysis);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.requiredInformation).toHaveLength(3);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DocumentSearchResultSchema', () => {
|
||||
it('should validate all coverage levels', () => {
|
||||
const levels = ['complete', 'partial', 'insufficient'];
|
||||
|
||||
levels.forEach(level => {
|
||||
const searchResult = {
|
||||
query: 'test query',
|
||||
documentsFound: 5,
|
||||
relevantSources: [],
|
||||
coverage: level,
|
||||
};
|
||||
const result = DocumentSearchResultSchema.safeParse(searchResult);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate with relevant sources', () => {
|
||||
const searchResult = {
|
||||
query: 'security measures',
|
||||
documentsFound: 3,
|
||||
relevantSources: [
|
||||
{
|
||||
id: 'src-1',
|
||||
title: 'Security Document',
|
||||
relevanceScore: 0.95,
|
||||
snippet: 'Relevant content snippet',
|
||||
},
|
||||
],
|
||||
coverage: 'complete',
|
||||
};
|
||||
const result = DocumentSearchResultSchema.safeParse(searchResult);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.relevantSources).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('InformationExtractionSchema', () => {
|
||||
it('should validate complete extraction', () => {
|
||||
const extraction = {
|
||||
extractedFacts: [
|
||||
{ fact: 'Fact 1', source: 'doc-1', confidence: 0.9 },
|
||||
],
|
||||
missingInformation: ['Missing item'],
|
||||
conflictingInformation: [
|
||||
{ topic: 'Topic', conflictingSources: ['src-1', 'src-2'] },
|
||||
],
|
||||
};
|
||||
const result = InformationExtractionSchema.safeParse(extraction);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate empty extraction', () => {
|
||||
const extraction = {
|
||||
extractedFacts: [],
|
||||
missingInformation: [],
|
||||
conflictingInformation: [],
|
||||
};
|
||||
const result = InformationExtractionSchema.safeParse(extraction);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate fact confidence bounds', () => {
|
||||
const extraction = {
|
||||
extractedFacts: [
|
||||
{ fact: 'Fact', source: 'src', confidence: 0 },
|
||||
{ fact: 'Fact 2', source: 'src', confidence: 1 },
|
||||
],
|
||||
missingInformation: [],
|
||||
conflictingInformation: [],
|
||||
};
|
||||
const result = InformationExtractionSchema.safeParse(extraction);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ResponseSynthesisSchema', () => {
|
||||
it('should validate complete synthesis', () => {
|
||||
const synthesis = {
|
||||
mainResponse: 'This is the main response',
|
||||
confidence: 0.85,
|
||||
sources: [
|
||||
{ id: 'src-1', relevance: 0.9, usedInResponse: true },
|
||||
],
|
||||
limitations: ['Limited data available'],
|
||||
recommendations: ['Consider additional research'],
|
||||
};
|
||||
const result = ResponseSynthesisSchema.safeParse(synthesis);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate with empty arrays', () => {
|
||||
const synthesis = {
|
||||
mainResponse: 'Response',
|
||||
confidence: 0.5,
|
||||
sources: [],
|
||||
limitations: [],
|
||||
recommendations: [],
|
||||
};
|
||||
const result = ResponseSynthesisSchema.safeParse(synthesis);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MultiStepGenerateRequestSchema', () => {
|
||||
it('should validate minimal request', () => {
|
||||
const request = {
|
||||
question: 'What is your approach?',
|
||||
questionId: 'q-123',
|
||||
projectId: 'proj-456',
|
||||
indexIds: ['idx-1'],
|
||||
};
|
||||
const result = MultiStepGenerateRequestSchema.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate complete request with preferences', () => {
|
||||
const request = {
|
||||
question: 'Describe implementation',
|
||||
questionId: 'q-123',
|
||||
projectId: 'proj-456',
|
||||
indexIds: ['idx-1', 'idx-2'],
|
||||
context: 'Enterprise deployment',
|
||||
userPreferences: {
|
||||
detailLevel: 'comprehensive',
|
||||
includeRecommendations: true,
|
||||
showReasoning: false,
|
||||
},
|
||||
};
|
||||
const result = MultiStepGenerateRequestSchema.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject empty question', () => {
|
||||
const request = {
|
||||
question: '',
|
||||
questionId: 'q-123',
|
||||
projectId: 'proj-456',
|
||||
indexIds: ['idx-1'],
|
||||
};
|
||||
const result = MultiStepGenerateRequestSchema.safeParse(request);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject empty indexIds array', () => {
|
||||
const request = {
|
||||
question: 'Valid question',
|
||||
questionId: 'q-123',
|
||||
projectId: 'proj-456',
|
||||
indexIds: [],
|
||||
};
|
||||
const result = MultiStepGenerateRequestSchema.safeParse(request);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate all detail levels', () => {
|
||||
const levels = ['brief', 'standard', 'comprehensive'];
|
||||
|
||||
levels.forEach(level => {
|
||||
const request = {
|
||||
question: 'Question',
|
||||
questionId: 'q-123',
|
||||
projectId: 'proj-456',
|
||||
indexIds: ['idx-1'],
|
||||
userPreferences: {
|
||||
detailLevel: level,
|
||||
},
|
||||
};
|
||||
const result = MultiStepGenerateRequestSchema.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('StepUpdateSchema', () => {
|
||||
it('should validate minimal update', () => {
|
||||
const update = {
|
||||
stepId: 'step-1',
|
||||
status: 'running',
|
||||
};
|
||||
const result = StepUpdateSchema.safeParse(update);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate complete update', () => {
|
||||
const update = {
|
||||
stepId: 'step-1',
|
||||
status: 'running',
|
||||
progress: 0.5,
|
||||
partialOutput: { processed: 50 },
|
||||
estimatedTimeRemaining: 5000,
|
||||
};
|
||||
const result = StepUpdateSchema.safeParse(update);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MultiStepConfigSchema', () => {
|
||||
it('should validate with defaults', () => {
|
||||
const config = {};
|
||||
const result = MultiStepConfigSchema.safeParse(config);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.maxSteps).toBe(5);
|
||||
expect(result.data.timeoutPerStep).toBe(30000);
|
||||
expect(result.data.enableDetailedLogging).toBe(true);
|
||||
expect(result.data.fallbackToSingleStep).toBe(true);
|
||||
expect(result.data.minConfidenceThreshold).toBe(0.7);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject maxSteps below 1', () => {
|
||||
const config = { maxSteps: 0 };
|
||||
const result = MultiStepConfigSchema.safeParse(config);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject maxSteps above 10', () => {
|
||||
const config = { maxSteps: 11 };
|
||||
const result = MultiStepConfigSchema.safeParse(config);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate maxSteps at boundaries', () => {
|
||||
expect(MultiStepConfigSchema.safeParse({ maxSteps: 1 }).success).toBe(true);
|
||||
expect(MultiStepConfigSchema.safeParse({ maxSteps: 10 }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject confidence threshold out of bounds', () => {
|
||||
expect(MultiStepConfigSchema.safeParse({ minConfidenceThreshold: -0.1 }).success).toBe(false);
|
||||
expect(MultiStepConfigSchema.safeParse({ minConfidenceThreshold: 1.1 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate confidence threshold at boundaries', () => {
|
||||
expect(MultiStepConfigSchema.safeParse({ minConfidenceThreshold: 0 }).success).toBe(true);
|
||||
expect(MultiStepConfigSchema.safeParse({ minConfidenceThreshold: 1 }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject timeout below minimum', () => {
|
||||
const config = { timeoutPerStep: 500 };
|
||||
const result = MultiStepConfigSchema.safeParse(config);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tsconfigPaths()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
include: ['tests/**/*.test.ts'],
|
||||
exclude: ['node_modules', '.next', 'dist'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html', 'lcov'],
|
||||
reportsDirectory: './coverage',
|
||||
include: [
|
||||
'lib/services/**/*.ts',
|
||||
'lib/validators/**/*.ts',
|
||||
'lib/errors/**/*.ts',
|
||||
'lib/utils/**/*.ts',
|
||||
'lib/middleware/**/*.ts',
|
||||
],
|
||||
exclude: [
|
||||
'lib/db.ts',
|
||||
'lib/utils/supabase/**',
|
||||
'**/*.d.ts',
|
||||
'**/index.ts',
|
||||
],
|
||||
// Initial thresholds - will increase as more tests are added
|
||||
thresholds: {
|
||||
lines: 10,
|
||||
functions: 10,
|
||||
branches: 5,
|
||||
statements: 10,
|
||||
},
|
||||
},
|
||||
testTimeout: 10000,
|
||||
hookTimeout: 10000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { afterEach, afterAll, vi } from 'vitest';
|
||||
|
||||
// Mock environment variables
|
||||
vi.stubEnv('OPENAI_API_KEY', 'test-openai-key');
|
||||
vi.stubEnv('LLAMACLOUD_API_KEY', 'test-llamacloud-key');
|
||||
vi.stubEnv('DATABASE_URL', 'postgresql://test:test@localhost:5432/test');
|
||||
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', 'https://test.supabase.co');
|
||||
vi.stubEnv('NEXT_PUBLIC_SUPABASE_ANON_KEY', 'test-anon-key');
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000');
|
||||
|
||||
// Global fetch mock
|
||||
global.fetch = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
// Reset all mocks between tests
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Global teardown
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
Reference in New Issue
Block a user