feat(client/server): search works again, maybe only works after restart?

This commit is contained in:
perfectra1n 2025-06-11 22:10:02 -07:00
parent aa8af7e018
commit e03e9daeed
9 changed files with 1860 additions and 74 deletions

View File

@ -29,6 +29,12 @@ import {
Divider,
IconButton,
Tooltip,
Autocomplete,
LinearProgress,
FormControlLabel,
Switch,
Paper,
Skeleton,
} from '@mui/material';
import {
Search as SearchIcon,
@ -46,6 +52,10 @@ import {
Storage as SizeIcon,
Tag as TagIcon,
Visibility as ViewIcon,
Settings as SettingsIcon,
Speed as SpeedIcon,
AccessTime as TimeIcon,
TrendingUp as TrendingIcon,
} from '@mui/icons-material';
import { documentService } from '../services/api';
@ -56,6 +66,16 @@ const SearchPage = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [viewMode, setViewMode] = useState('grid');
const [queryTime, setQueryTime] = useState(0);
const [totalResults, setTotalResults] = useState(0);
const [suggestions, setSuggestions] = useState([]);
// Search settings
const [useEnhancedSearch, setUseEnhancedSearch] = useState(true);
const [searchMode, setSearchMode] = useState('simple');
const [includeSnippets, setIncludeSnippets] = useState(true);
const [snippetLength, setSnippetLength] = useState(200);
const [showAdvanced, setShowAdvanced] = useState(false);
// Filter states
const [selectedTags, setSelectedTags] = useState([]);
@ -86,6 +106,9 @@ const SearchPage = () => {
const performSearch = useCallback(async (query, filters = {}) => {
if (!query.trim()) {
setSearchResults([]);
setTotalResults(0);
setQueryTime(0);
setSuggestions([]);
return;
}
@ -99,9 +122,14 @@ const SearchPage = () => {
mime_types: filters.mimeTypes?.length ? filters.mimeTypes : undefined,
limit: 100,
offset: 0,
include_snippets: includeSnippets,
snippet_length: snippetLength,
search_mode: searchMode,
};
const response = await documentService.search(searchRequest);
const response = useEnhancedSearch
? await documentService.enhancedSearch(searchRequest)
: await documentService.search(searchRequest);
// Apply additional client-side filters
let results = response.data.documents || [];
@ -134,6 +162,9 @@ const SearchPage = () => {
}
setSearchResults(results);
setTotalResults(response.data.total || results.length);
setQueryTime(response.data.query_time_ms || 0);
setSuggestions(response.data.suggestions || []);
// Extract unique tags for filter options
const tags = [...new Set(results.flatMap(doc => doc.tags))];
@ -145,7 +176,7 @@ const SearchPage = () => {
} finally {
setLoading(false);
}
}, []);
}, [useEnhancedSearch, includeSnippets, snippetLength, searchMode]);
const debouncedSearch = useCallback(
debounce((query, filters) => performSearch(query, filters), 500),
@ -209,9 +240,63 @@ const SearchPage = () => {
}
};
const renderHighlightedText = (text, highlightRanges) => {
if (!highlightRanges || highlightRanges.length === 0) {
return text;
}
const parts = [];
let lastIndex = 0;
highlightRanges.forEach((range, index) => {
// Add text before highlight
if (range.start > lastIndex) {
parts.push(
<span key={`text-${index}`}>
{text.substring(lastIndex, range.start)}
</span>
);
}
// Add highlighted text
parts.push(
<Box
key={`highlight-${index}`}
component="mark"
sx={{
backgroundColor: 'primary.light',
color: 'primary.contrastText',
padding: '0 2px',
borderRadius: '2px',
fontWeight: 600,
}}
>
{text.substring(range.start, range.end)}
</Box>
);
lastIndex = range.end;
});
// Add remaining text
if (lastIndex < text.length) {
parts.push(
<span key="final-text">
{text.substring(lastIndex)}
</span>
);
}
return parts;
};
const handleSuggestionClick = (suggestion) => {
setSearchQuery(suggestion);
};
return (
<Box sx={{ p: 3 }}>
{/* Header */}
{/* Header with Prominent Search */}
<Box sx={{ mb: 4 }}>
<Typography
variant="h4"
@ -221,14 +306,218 @@ const SearchPage = () => {
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
color: 'transparent',
mb: 1,
mb: 2,
}}
>
Search Documents
</Typography>
<Typography variant="body1" color="text.secondary">
Find documents using full-text search and advanced filters
</Typography>
{/* Enhanced Search Bar */}
<Paper
elevation={3}
sx={{
p: 2,
mb: 3,
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%)',
border: '1px solid',
borderColor: 'primary.light',
}}
>
<Box sx={{ position: 'relative' }}>
<TextField
fullWidth
placeholder="Search documents by content, filename, or tags... Try 'invoice', 'contract', or tag:important"
variant="outlined"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon color="primary" sx={{ fontSize: '1.5rem' }} />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<Stack direction="row" spacing={1}>
{loading && <CircularProgress size={20} />}
{searchQuery && (
<IconButton
size="small"
onClick={() => setSearchQuery('')}
>
<ClearIcon />
</IconButton>
)}
<IconButton
size="small"
onClick={() => setShowAdvanced(!showAdvanced)}
color={showAdvanced ? 'primary' : 'default'}
>
<SettingsIcon />
</IconButton>
</Stack>
</InputAdornment>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
'& fieldset': {
borderWidth: 2,
},
'&:hover fieldset': {
borderColor: 'primary.main',
},
'&.Mui-focused fieldset': {
borderColor: 'primary.main',
},
},
'& .MuiInputBase-input': {
fontSize: '1.1rem',
py: 2,
},
}}
/>
{/* Loading Progress Bar */}
{loading && (
<LinearProgress
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
borderRadius: '0 0 4px 4px',
}}
/>
)}
</Box>
{/* Quick Stats */}
{(searchQuery && !loading) && (
<Box sx={{
mt: 2,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 2,
}}>
<Stack direction="row" spacing={2} alignItems="center">
<Chip
icon={<TrendingIcon />}
label={`${totalResults} results`}
size="small"
color="primary"
variant="outlined"
/>
<Chip
icon={<TimeIcon />}
label={`${queryTime}ms`}
size="small"
variant="outlined"
/>
{useEnhancedSearch && (
<Chip
icon={<SpeedIcon />}
label="Enhanced"
size="small"
color="success"
variant="outlined"
/>
)}
</Stack>
{/* Search Mode Selector */}
<ToggleButtonGroup
value={searchMode}
exclusive
onChange={(e, newMode) => newMode && setSearchMode(newMode)}
size="small"
>
<ToggleButton value="simple">Simple</ToggleButton>
<ToggleButton value="phrase">Phrase</ToggleButton>
<ToggleButton value="fuzzy">Fuzzy</ToggleButton>
<ToggleButton value="boolean">Boolean</ToggleButton>
</ToggleButtonGroup>
</Box>
)}
{/* Suggestions */}
{suggestions.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" color="text.secondary" gutterBottom>
Suggestions:
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
{suggestions.map((suggestion, index) => (
<Chip
key={index}
label={suggestion}
size="small"
onClick={() => handleSuggestionClick(suggestion)}
clickable
variant="outlined"
sx={{
'&:hover': {
backgroundColor: 'primary.light',
color: 'primary.contrastText',
}
}}
/>
))}
</Stack>
</Box>
)}
{/* Advanced Search Options */}
{showAdvanced && (
<Box sx={{ mt: 3, pt: 2, borderTop: '1px dashed', borderColor: 'divider' }}>
<Typography variant="subtitle2" gutterBottom>
Search Options
</Typography>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} sm={6} md={3}>
<FormControlLabel
control={
<Switch
checked={useEnhancedSearch}
onChange={(e) => setUseEnhancedSearch(e.target.checked)}
color="primary"
/>
}
label="Enhanced Search"
/>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<FormControlLabel
control={
<Switch
checked={includeSnippets}
onChange={(e) => setIncludeSnippets(e.target.checked)}
color="primary"
/>
}
label="Show Snippets"
/>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<FormControl size="small" fullWidth>
<InputLabel>Snippet Length</InputLabel>
<Select
value={snippetLength}
onChange={(e) => setSnippetLength(e.target.value)}
label="Snippet Length"
>
<MenuItem value={100}>Short (100)</MenuItem>
<MenuItem value={200}>Medium (200)</MenuItem>
<MenuItem value={400}>Long (400)</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
</Box>
)}
</Paper>
</Box>
<Grid container spacing={3}>
@ -391,66 +680,34 @@ const SearchPage = () => {
{/* Search Results */}
<Grid item xs={12} md={9}>
{/* Search Bar */}
<Box sx={{ mb: 3 }}>
<TextField
fullWidth
placeholder="Search documents by filename, content, or tags..."
variant="outlined"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon color="action" />
</InputAdornment>
),
endAdornment: searchQuery && (
<InputAdornment position="end">
<IconButton
size="small"
onClick={() => setSearchQuery('')}
>
<ClearIcon />
</IconButton>
</InputAdornment>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
'& fieldset': {
borderWidth: 2,
},
},
}}
/>
</Box>
{/* Toolbar */}
<Box sx={{
mb: 3,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<Typography variant="body2" color="text.secondary">
{loading ? 'Searching...' : `${searchResults.length} results found`}
</Typography>
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(e, newView) => newView && setViewMode(newView)}
size="small"
>
<ToggleButton value="grid">
<GridViewIcon />
</ToggleButton>
<ToggleButton value="list">
<ListViewIcon />
</ToggleButton>
</ToggleButtonGroup>
</Box>
{searchQuery && (
<Box sx={{
mb: 3,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<Typography variant="body2" color="text.secondary">
{loading ? 'Searching...' : `${searchResults.length} results found`}
</Typography>
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(e, newView) => newView && setViewMode(newView)}
size="small"
>
<ToggleButton value="grid">
<GridViewIcon />
</ToggleButton>
<ToggleButton value="list">
<ListViewIcon />
</ToggleButton>
</ToggleButtonGroup>
</Box>
)}
{/* Results */}
{loading && (
@ -496,11 +753,16 @@ const SearchPage = () => {
>
<SearchIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h6" color="text.secondary" gutterBottom>
Start searching
Start searching your documents
</Typography>
<Typography variant="body2" color="text.secondary">
Enter keywords to search through your documents
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Use the enhanced search bar above to find documents by content, filename, or tags
</Typography>
<Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap">
<Chip label="Try: invoice" size="small" variant="outlined" />
<Chip label="Try: contract" size="small" variant="outlined" />
<Chip label="Try: tag:important" size="small" variant="outlined" />
</Stack>
</Box>
)}
@ -610,6 +872,54 @@ const SearchPage = () => {
)}
</Stack>
)}
{/* Search Snippets */}
{doc.snippets && doc.snippets.length > 0 && (
<Box sx={{ mt: 2, mb: 1 }}>
{doc.snippets.slice(0, 2).map((snippet, index) => (
<Paper
key={index}
variant="outlined"
sx={{
p: 1.5,
mb: 1,
backgroundColor: 'grey.50',
borderLeft: '3px solid',
borderLeftColor: 'primary.main',
}}
>
<Typography
variant="body2"
sx={{
fontSize: '0.8rem',
lineHeight: 1.4,
color: 'text.primary',
}}
>
...{renderHighlightedText(snippet.text, snippet.highlight_ranges)}...
</Typography>
</Paper>
))}
{doc.snippets.length > 2 && (
<Typography variant="caption" color="text.secondary">
+{doc.snippets.length - 2} more matches
</Typography>
)}
</Box>
)}
{/* Search Rank */}
{doc.search_rank && (
<Box sx={{ mt: 1 }}>
<Chip
label={`Relevance: ${(doc.search_rank * 100).toFixed(1)}%`}
size="small"
color="info"
variant="outlined"
sx={{ fontSize: '0.7rem', height: '18px' }}
/>
</Box>
)}
</Box>
<Tooltip title="View Details">

View File

@ -0,0 +1,460 @@
import React from 'react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter } from 'react-router-dom';
import SearchPage from '../SearchPage';
import { documentService } from '../../services/api';
// Mock the API service
jest.mock('../../services/api', () => ({
documentService: {
enhancedSearch: jest.fn(),
search: jest.fn(),
download: jest.fn(),
}
}));
// Mock useNavigate
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
// Mock data
const mockSearchResponse = {
data: {
documents: [
{
id: '1',
filename: 'test.pdf',
original_filename: 'test.pdf',
file_size: 1024,
mime_type: 'application/pdf',
tags: ['test', 'document'],
created_at: '2023-01-01T00:00:00Z',
has_ocr_text: true,
search_rank: 0.85,
snippets: [
{
text: 'This is a test document with important information',
start_offset: 0,
end_offset: 48,
highlight_ranges: [
{ start: 10, end: 14 }
]
}
]
}
],
total: 1,
query_time_ms: 45,
suggestions: ['\"test\"', 'test*']
}
};
// Helper to render component with router
const renderWithRouter = (component) => {
return render(
<BrowserRouter>
{component}
</BrowserRouter>
);
};
describe('SearchPage', () => {
beforeEach(() => {
jest.clearAllMocks();
documentService.enhancedSearch.mockResolvedValue(mockSearchResponse);
documentService.search.mockResolvedValue(mockSearchResponse);
});
test('renders search page with prominent search bar', () => {
renderWithRouter(<SearchPage />);
expect(screen.getByText('Search Documents')).toBeInTheDocument();
expect(screen.getByPlaceholderText(/Search documents by content, filename, or tags/)).toBeInTheDocument();
expect(screen.getByText('Start searching your documents')).toBeInTheDocument();
});
test('displays search suggestions when no query is entered', () => {
renderWithRouter(<SearchPage />);
expect(screen.getByText('Try: invoice')).toBeInTheDocument();
expect(screen.getByText('Try: contract')).toBeInTheDocument();
expect(screen.getByText('Try: tag:important')).toBeInTheDocument();
});
test('performs search when user types in search box', async () => {
const user = userEvent.setup();
renderWithRouter(<SearchPage />);
const searchInput = screen.getByPlaceholderText(/Search documents by content, filename, or tags/);
await act(async () => {
await user.type(searchInput, 'test query');
});
// Wait for debounced search
await waitFor(() => {
expect(documentService.enhancedSearch).toHaveBeenCalledWith(
expect.objectContaining({
query: 'test query',
include_snippets: true,
snippet_length: 200,
search_mode: 'simple'
})
);
}, { timeout: 2000 });
});
test('displays search results with snippets', async () => {
const user = userEvent.setup();
renderWithRouter(<SearchPage />);
const searchInput = screen.getByPlaceholderText(/Search documents by content, filename, or tags/);
await act(async () => {
await user.type(searchInput, 'test');
});
await waitFor(() => {
expect(screen.getByText('test.pdf')).toBeInTheDocument();
expect(screen.getByText(/This is a test document/)).toBeInTheDocument();
expect(screen.getByText('1 results')).toBeInTheDocument();
expect(screen.getByText('45ms')).toBeInTheDocument();
});
});
test('shows search suggestions when available', async () => {
const user = userEvent.setup();
renderWithRouter(<SearchPage />);
const searchInput = screen.getByPlaceholderText(/Search documents by content, filename, or tags/);
await act(async () => {
await user.type(searchInput, 'test');
});
await waitFor(() => {
expect(screen.getByText('Suggestions:')).toBeInTheDocument();
expect(screen.getByText('\"test\"')).toBeInTheDocument();
expect(screen.getByText('test*')).toBeInTheDocument();
});
});
test('toggles advanced search options', async () => {
const user = userEvent.setup();
renderWithRouter(<SearchPage />);
const settingsButton = screen.getByRole('button', { name: /settings/i });
await user.click(settingsButton);
expect(screen.getByText('Search Options')).toBeInTheDocument();
expect(screen.getByText('Enhanced Search')).toBeInTheDocument();
expect(screen.getByText('Show Snippets')).toBeInTheDocument();
});
test('changes search mode', async () => {
const user = userEvent.setup();
renderWithRouter(<SearchPage />);
// Type a search query first to show the search mode selector
const searchInput = screen.getByPlaceholderText(/Search documents by content, filename, or tags/);
await act(async () => {
await user.type(searchInput, 'test');
});
await waitFor(() => {
const phraseButton = screen.getByRole('button', { name: 'Phrase' });
expect(phraseButton).toBeInTheDocument();
});
const phraseButton = screen.getByRole('button', { name: 'Phrase' });
await user.click(phraseButton);
// Wait for search to be called with new mode
await waitFor(() => {
expect(documentService.enhancedSearch).toHaveBeenCalledWith(
expect.objectContaining({
search_mode: 'phrase'
})
);
});
});
test('handles search suggestions click', async () => {
const user = userEvent.setup();
renderWithRouter(<SearchPage />);
const searchInput = screen.getByPlaceholderText(/Search documents by content, filename, or tags/);
await act(async () => {
await user.type(searchInput, 'test');
});
await waitFor(() => {
expect(screen.getByText('\"test\"')).toBeInTheDocument();
});
const suggestionChip = screen.getByText('\"test\"');
await user.click(suggestionChip);
expect(searchInput.value).toBe('\"test\"');
});
test('clears search input', async () => {
const user = userEvent.setup();
renderWithRouter(<SearchPage />);
const searchInput = screen.getByPlaceholderText(/Search documents by content, filename, or tags/);
await act(async () => {
await user.type(searchInput, 'test query');
});
const clearButton = screen.getByRole('button', { name: /clear/i });
await user.click(clearButton);
expect(searchInput.value).toBe('');
});
test('toggles enhanced search setting', async () => {
const user = userEvent.setup();
renderWithRouter(<SearchPage />);
// Open advanced options
const settingsButton = screen.getByRole('button', { name: /settings/i });
await user.click(settingsButton);
const enhancedSearchSwitch = screen.getByRole('checkbox', { name: /enhanced search/i });
await user.click(enhancedSearchSwitch);
// Type a search to trigger API call
const searchInput = screen.getByPlaceholderText(/Search documents by content, filename, or tags/);
await act(async () => {
await user.type(searchInput, 'test');
});
// Should use regular search instead of enhanced search
await waitFor(() => {
expect(documentService.search).toHaveBeenCalled();
});
});
test('changes snippet length setting', async () => {
const user = userEvent.setup();
renderWithRouter(<SearchPage />);
// Open advanced options
const settingsButton = screen.getByRole('button', { name: /settings/i });
await user.click(settingsButton);
const snippetSelect = screen.getByLabelText('Snippet Length');
await user.click(snippetSelect);
const longOption = screen.getByText('Long (400)');
await user.click(longOption);
// Type a search to trigger API call
const searchInput = screen.getByPlaceholderText(/Search documents by content, filename, or tags/);
await act(async () => {
await user.type(searchInput, 'test');
});
await waitFor(() => {
expect(documentService.enhancedSearch).toHaveBeenCalledWith(
expect.objectContaining({
snippet_length: 400
})
);
});
});
test('displays loading state during search', async () => {
const user = userEvent.setup();
// Mock a delayed response
documentService.enhancedSearch.mockImplementation(() =>
new Promise(resolve => setTimeout(() => resolve(mockSearchResponse), 100))
);
renderWithRouter(<SearchPage />);
const searchInput = screen.getByPlaceholderText(/Search documents by content, filename, or tags/);
await act(async () => {
await user.type(searchInput, 'test');
});
// Should show loading indicator
expect(screen.getByRole('progressbar')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('test.pdf')).toBeInTheDocument();
});
});
test('handles search error gracefully', async () => {
const user = userEvent.setup();
documentService.enhancedSearch.mockRejectedValue(new Error('Search failed'));
renderWithRouter(<SearchPage />);
const searchInput = screen.getByPlaceholderText(/Search documents by content, filename, or tags/);
await act(async () => {
await user.type(searchInput, 'test');
});
await waitFor(() => {
expect(screen.getByText('Search failed. Please try again.')).toBeInTheDocument();
});
});
test('navigates to document details on view click', async () => {
const user = userEvent.setup();
renderWithRouter(<SearchPage />);
const searchInput = screen.getByPlaceholderText(/Search documents by content, filename, or tags/);
await act(async () => {
await user.type(searchInput, 'test');
});
await waitFor(() => {
expect(screen.getByText('test.pdf')).toBeInTheDocument();
});
const viewButton = screen.getByLabelText('View Details');
await user.click(viewButton);
expect(mockNavigate).toHaveBeenCalledWith('/documents/1');
});
test('handles document download', async () => {
const user = userEvent.setup();
const mockBlob = new Blob(['test content'], { type: 'application/pdf' });
documentService.download.mockResolvedValue({ data: mockBlob });
// Mock URL.createObjectURL
global.URL.createObjectURL = jest.fn(() => 'mock-url');
global.URL.revokeObjectURL = jest.fn();
renderWithRouter(<SearchPage />);
const searchInput = screen.getByPlaceholderText(/Search documents by content, filename, or tags/);
await act(async () => {
await user.type(searchInput, 'test');
});
await waitFor(() => {
expect(screen.getByText('test.pdf')).toBeInTheDocument();
});
const downloadButton = screen.getByLabelText('Download');
await user.click(downloadButton);
expect(documentService.download).toHaveBeenCalledWith('1');
});
test('switches between grid and list view modes', async () => {
const user = userEvent.setup();
renderWithRouter(<SearchPage />);
const searchInput = screen.getByPlaceholderText(/Search documents by content, filename, or tags/);
await act(async () => {
await user.type(searchInput, 'test');
});
await waitFor(() => {
expect(screen.getByText('test.pdf')).toBeInTheDocument();
});
const listViewButton = screen.getByRole('button', { name: /list view/i });
await user.click(listViewButton);
// The view should change (this would be more thoroughly tested with visual regression tests)
expect(listViewButton).toHaveAttribute('aria-pressed', 'true');
});
test('displays file type icons correctly', async () => {
const user = userEvent.setup();
renderWithRouter(<SearchPage />);
const searchInput = screen.getByPlaceholderText(/Search documents by content, filename, or tags/);
await act(async () => {
await user.type(searchInput, 'test');
});
await waitFor(() => {
// Should show PDF icon for PDF file
expect(screen.getByTestId('PictureAsPdfIcon')).toBeInTheDocument();
});
});
test('displays OCR badge when document has OCR text', async () => {
const user = userEvent.setup();
renderWithRouter(<SearchPage />);
const searchInput = screen.getByPlaceholderText(/Search documents by content, filename, or tags/);
await act(async () => {
await user.type(searchInput, 'test');
});
await waitFor(() => {
expect(screen.getByText('OCR')).toBeInTheDocument();
});
});
test('highlights search terms in snippets', async () => {
const user = userEvent.setup();
renderWithRouter(<SearchPage />);
const searchInput = screen.getByPlaceholderText(/Search documents by content, filename, or tags/);
await act(async () => {
await user.type(searchInput, 'test');
});
await waitFor(() => {
// Should render the snippet with highlighted text
expect(screen.getByText(/This is a test document/)).toBeInTheDocument();
});
});
test('shows relevance score when available', async () => {
const user = userEvent.setup();
renderWithRouter(<SearchPage />);
const searchInput = screen.getByPlaceholderText(/Search documents by content, filename, or tags/);
await act(async () => {
await user.type(searchInput, 'test');
});
await waitFor(() => {
expect(screen.getByText('Relevance: 85.0%')).toBeInTheDocument();
});
});
});
// Test helper functions
describe('Search Helper Functions', () => {
test('formats file sizes correctly', () => {
// These would test utility functions if they were exported
// For now, we test the component behavior
expect(true).toBe(true);
});
test('formats dates correctly', () => {
// These would test utility functions if they were exported
expect(true).toBe(true);
});
});

View File

@ -27,11 +27,41 @@ export interface SearchRequest {
mime_types?: string[]
limit?: number
offset?: number
include_snippets?: boolean
snippet_length?: number
search_mode?: 'simple' | 'phrase' | 'fuzzy' | 'boolean'
}
export interface HighlightRange {
start: number
end: number
}
export interface SearchSnippet {
text: string
start_offset: number
end_offset: number
highlight_ranges: HighlightRange[]
}
export interface EnhancedDocument {
id: string
filename: string
original_filename: string
file_size: number
mime_type: string
tags: string[]
created_at: string
has_ocr_text: boolean
search_rank?: number
snippets: SearchSnippet[]
}
export interface SearchResponse {
documents: Document[]
documents: EnhancedDocument[]
total: number
query_time_ms: number
suggestions: string[]
}
export const documentService = {
@ -62,4 +92,15 @@ export const documentService = {
params: searchRequest,
})
},
enhancedSearch: (searchRequest: SearchRequest) => {
return api.get<SearchResponse>('/search/enhanced', {
params: {
...searchRequest,
include_snippets: searchRequest.include_snippets ?? true,
snippet_length: searchRequest.snippet_length ?? 200,
search_mode: searchRequest.search_mode ?? 'simple',
},
})
},
}

165
src/db.rs
View File

@ -3,7 +3,7 @@ use chrono::Utc;
use sqlx::{PgPool, Row};
use uuid::Uuid;
use crate::models::{CreateUser, Document, SearchRequest, User};
use crate::models::{CreateUser, Document, SearchRequest, SearchMode, SearchSnippet, HighlightRange, EnhancedDocumentResponse, User};
#[derive(Clone)]
pub struct Database {
@ -328,6 +328,169 @@ impl Database {
Ok((documents, total))
}
pub async fn enhanced_search_documents(&self, user_id: Uuid, search: SearchRequest) -> Result<(Vec<EnhancedDocumentResponse>, i64, u64)> {
let start_time = std::time::Instant::now();
// Build search query based on search mode
let search_mode = search.search_mode.as_ref().unwrap_or(&SearchMode::Simple);
let query_function = match search_mode {
SearchMode::Simple => "plainto_tsquery",
SearchMode::Phrase => "phraseto_tsquery",
SearchMode::Fuzzy => "plainto_tsquery", // Could be enhanced with similarity
SearchMode::Boolean => "to_tsquery",
};
let mut query_builder = sqlx::QueryBuilder::new(&format!(
r#"
SELECT id, filename, original_filename, file_path, file_size, mime_type, content, ocr_text, tags, created_at, updated_at, user_id,
ts_rank(to_tsvector('english', COALESCE(content, '') || ' ' || COALESCE(ocr_text, '')), {}('english', "#,
query_function
));
query_builder.push_bind(&search.query);
query_builder.push(&format!(")) as rank FROM documents WHERE user_id = "));
query_builder.push_bind(user_id);
query_builder.push(&format!(" AND to_tsvector('english', COALESCE(content, '') || ' ' || COALESCE(ocr_text, '')) @@ {}('english', ", query_function));
query_builder.push_bind(&search.query);
query_builder.push(")");
if let Some(tags) = &search.tags {
if !tags.is_empty() {
query_builder.push(" AND tags && ");
query_builder.push_bind(tags);
}
}
if let Some(mime_types) = &search.mime_types {
if !mime_types.is_empty() {
query_builder.push(" AND mime_type = ANY(");
query_builder.push_bind(mime_types);
query_builder.push(")");
}
}
query_builder.push(" ORDER BY rank DESC, created_at DESC");
if let Some(limit) = search.limit {
query_builder.push(" LIMIT ");
query_builder.push_bind(limit);
}
if let Some(offset) = search.offset {
query_builder.push(" OFFSET ");
query_builder.push_bind(offset);
}
let rows = query_builder.build().fetch_all(&self.pool).await?;
let include_snippets = search.include_snippets.unwrap_or(true);
let snippet_length = search.snippet_length.unwrap_or(200);
let mut documents = Vec::new();
for row in rows {
let doc_id: Uuid = row.get("id");
let content: Option<String> = row.get("content");
let ocr_text: Option<String> = row.get("ocr_text");
let rank: f32 = row.get("rank");
let snippets = if include_snippets {
self.generate_snippets(&search.query, content.as_deref(), ocr_text.as_deref(), snippet_length)
} else {
Vec::new()
};
documents.push(EnhancedDocumentResponse {
id: doc_id,
filename: row.get("filename"),
original_filename: row.get("original_filename"),
file_size: row.get("file_size"),
mime_type: row.get("mime_type"),
tags: row.get("tags"),
created_at: row.get("created_at"),
has_ocr_text: ocr_text.is_some(),
search_rank: Some(rank),
snippets,
});
}
let total_row = sqlx::query(&format!(
r#"
SELECT COUNT(*) as total FROM documents
WHERE user_id = $1
AND to_tsvector('english', COALESCE(content, '') || ' ' || COALESCE(ocr_text, '')) @@ {}('english', $2)
"#, query_function
))
.bind(user_id)
.bind(&search.query)
.fetch_one(&self.pool)
.await?;
let total: i64 = total_row.get("total");
let query_time = start_time.elapsed().as_millis() as u64;
Ok((documents, total, query_time))
}
fn generate_snippets(&self, query: &str, content: Option<&str>, ocr_text: Option<&str>, snippet_length: i32) -> Vec<SearchSnippet> {
let mut snippets = Vec::new();
// Combine content and OCR text
let full_text = match (content, ocr_text) {
(Some(c), Some(o)) => format!("{} {}", c, o),
(Some(c), None) => c.to_string(),
(None, Some(o)) => o.to_string(),
(None, None) => return snippets,
};
// Simple keyword matching for snippets (could be enhanced with better search algorithms)
let _query_terms: Vec<&str> = query.split_whitespace().collect();
let text_lower = full_text.to_lowercase();
let query_lower = query.to_lowercase();
// Find matches
for (i, _) in text_lower.match_indices(&query_lower) {
let snippet_start = if i >= snippet_length as usize / 2 {
i - snippet_length as usize / 2
} else {
0
};
let snippet_end = std::cmp::min(
snippet_start + snippet_length as usize,
full_text.len()
);
if snippet_start < full_text.len() {
let snippet_text = &full_text[snippet_start..snippet_end];
// Find highlight ranges within this snippet
let mut highlight_ranges = Vec::new();
let snippet_lower = snippet_text.to_lowercase();
for (match_start, _) in snippet_lower.match_indices(&query_lower) {
highlight_ranges.push(HighlightRange {
start: match_start as i32,
end: (match_start + query.len()) as i32,
});
}
snippets.push(SearchSnippet {
text: snippet_text.to_string(),
start_offset: snippet_start as i32,
end_offset: snippet_end as i32,
highlight_ranges,
});
// Limit to a few snippets per document
if snippets.len() >= 3 {
break;
}
}
}
snippets
}
pub async fn update_document_ocr(&self, id: Uuid, ocr_text: &str) -> Result<()> {
sqlx::query("UPDATE documents SET ocr_text = $1, updated_at = NOW() WHERE id = $2")
.bind(ocr_text)

View File

@ -74,12 +74,63 @@ pub struct SearchRequest {
pub mime_types: Option<Vec<String>>,
pub limit: Option<i64>,
pub offset: Option<i64>,
pub include_snippets: Option<bool>,
pub snippet_length: Option<i32>,
pub search_mode: Option<SearchMode>,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum SearchMode {
#[serde(rename = "simple")]
Simple,
#[serde(rename = "phrase")]
Phrase,
#[serde(rename = "fuzzy")]
Fuzzy,
#[serde(rename = "boolean")]
Boolean,
}
impl Default for SearchMode {
fn default() -> Self {
SearchMode::Simple
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SearchSnippet {
pub text: String,
pub start_offset: i32,
pub end_offset: i32,
pub highlight_ranges: Vec<HighlightRange>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct HighlightRange {
pub start: i32,
pub end: i32,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EnhancedDocumentResponse {
pub id: Uuid,
pub filename: String,
pub original_filename: String,
pub file_size: i64,
pub mime_type: String,
pub tags: Vec<String>,
pub created_at: DateTime<Utc>,
pub has_ocr_text: bool,
pub search_rank: Option<f32>,
pub snippets: Vec<SearchSnippet>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SearchResponse {
pub documents: Vec<DocumentResponse>,
pub documents: Vec<EnhancedDocumentResponse>,
pub total: i64,
pub query_time_ms: u64,
pub suggestions: Vec<String>,
}
impl From<Document> for DocumentResponse {

View File

@ -9,12 +9,14 @@ use std::sync::Arc;
use crate::{
auth::AuthUser,
models::{SearchRequest, SearchResponse},
models::{SearchRequest, SearchResponse, EnhancedDocumentResponse},
AppState,
};
pub fn router() -> Router<Arc<AppState>> {
Router::new().route("/", get(search_documents))
Router::new()
.route("/", get(search_documents))
.route("/enhanced", get(enhanced_search_documents))
}
async fn search_documents(
@ -29,9 +31,69 @@ async fn search_documents(
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let response = SearchResponse {
documents: documents.into_iter().map(|doc| doc.into()).collect(),
documents: documents.into_iter().map(|doc| EnhancedDocumentResponse {
id: doc.id,
filename: doc.filename,
original_filename: doc.original_filename,
file_size: doc.file_size,
mime_type: doc.mime_type,
tags: doc.tags,
created_at: doc.created_at,
has_ocr_text: doc.ocr_text.is_some(),
search_rank: None,
snippets: Vec::new(),
}).collect(),
total,
query_time_ms: 0,
suggestions: Vec::new(),
};
Ok(Json(response))
}
async fn enhanced_search_documents(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Query(search_request): Query<SearchRequest>,
) -> Result<Json<SearchResponse>, StatusCode> {
// Generate suggestions before moving search_request
let suggestions = generate_search_suggestions(&search_request.query);
let (documents, total, query_time) = state
.db
.enhanced_search_documents(auth_user.user.id, search_request)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let response = SearchResponse {
documents,
total,
query_time_ms: query_time,
suggestions,
};
Ok(Json(response))
}
fn generate_search_suggestions(query: &str) -> Vec<String> {
// Simple suggestion generation - could be enhanced with a proper suggestion system
let mut suggestions = Vec::new();
if query.len() > 3 {
// Common search variations
suggestions.push(format!("\"{}\"", query)); // Exact phrase
// Add wildcard suggestions
if !query.contains('*') {
suggestions.push(format!("{}*", query));
}
// Add similar terms (this would typically come from a thesaurus or ML model)
if query.contains("document") {
suggestions.push(query.replace("document", "file"));
suggestions.push(query.replace("document", "paper"));
}
}
suggestions.into_iter().take(3).collect()
}

View File

@ -147,6 +147,9 @@ mod tests {
mime_types: None,
limit: Some(10),
offset: Some(0),
include_snippets: Some(true),
snippet_length: Some(200),
search_mode: None,
};
let result = db.search_documents(user.id, search_request).await;

View File

@ -0,0 +1,695 @@
#[cfg(test)]
mod tests {
use crate::db::Database;
use crate::models::{
CreateUser, Document, SearchRequest, SearchMode,
EnhancedDocumentResponse, SearchSnippet, HighlightRange
};
use chrono::Utc;
use uuid::Uuid;
// Mock database for testing snippet generation without PostgreSQL dependency
struct MockDatabase;
impl MockDatabase {
fn new() -> Self {
Self
}
// Test the snippet generation logic directly
fn generate_snippets(&self, query: &str, content: Option<&str>, ocr_text: Option<&str>, snippet_length: i32) -> Vec<SearchSnippet> {
let mut snippets = Vec::new();
// Combine content and OCR text
let full_text = match (content, ocr_text) {
(Some(c), Some(o)) => format!("{} {}", c, o),
(Some(c), None) => c.to_string(),
(None, Some(o)) => o.to_string(),
(None, None) => return snippets,
};
// Simple keyword matching for snippets
let text_lower = full_text.to_lowercase();
let query_lower = query.to_lowercase();
// Find matches
for (i, _) in text_lower.match_indices(&query_lower) {
let snippet_start = if i >= snippet_length as usize / 2 {
i - snippet_length as usize / 2
} else {
0
};
let snippet_end = std::cmp::min(
snippet_start + snippet_length as usize,
full_text.len()
);
if snippet_start < full_text.len() {
let snippet_text = &full_text[snippet_start..snippet_end];
// Find highlight ranges within this snippet
let mut highlight_ranges = Vec::new();
let snippet_lower = snippet_text.to_lowercase();
for (match_start, _) in snippet_lower.match_indices(&query_lower) {
highlight_ranges.push(HighlightRange {
start: match_start as i32,
end: (match_start + query.len()) as i32,
});
}
snippets.push(SearchSnippet {
text: snippet_text.to_string(),
start_offset: snippet_start as i32,
end_offset: snippet_end as i32,
highlight_ranges,
});
// Limit to a few snippets per document
if snippets.len() >= 3 {
break;
}
}
}
snippets
}
}
#[test]
fn test_snippet_generation_basic() {
let mock_db = MockDatabase::new();
let content = "This is a test document with some important information about testing and quality assurance.";
let snippets = mock_db.generate_snippets("test", Some(content), None, 50);
assert!(!snippets.is_empty());
assert!(snippets[0].text.contains("test"));
assert!(!snippets[0].highlight_ranges.is_empty());
// Check that highlight range is correct
let highlight = &snippets[0].highlight_ranges[0];
let highlighted_text = &snippets[0].text[highlight.start as usize..highlight.end as usize];
assert_eq!(highlighted_text.to_lowercase(), "test");
}
#[test]
fn test_snippet_generation_multiple_matches() {
let mock_db = MockDatabase::new();
let content = "The first test shows that testing is important. Another test demonstrates test effectiveness.";
let snippets = mock_db.generate_snippets("test", Some(content), None, 100);
assert!(!snippets.is_empty());
// Should find multiple highlight ranges in the snippet
let total_highlights: usize = snippets.iter()
.map(|s| s.highlight_ranges.len())
.sum();
assert!(total_highlights >= 2);
}
#[test]
fn test_snippet_generation_with_ocr_text() {
let mock_db = MockDatabase::new();
let content = "Document content with information";
let ocr_text = "OCR extracted text with important data";
let snippets = mock_db.generate_snippets("important", Some(content), Some(ocr_text), 100);
assert!(!snippets.is_empty());
assert!(snippets[0].text.contains("important"));
}
#[test]
fn test_snippet_generation_case_insensitive() {
let mock_db = MockDatabase::new();
let content = "This Document contains IMPORTANT Information";
let snippets = mock_db.generate_snippets("important", Some(content), None, 50);
assert!(!snippets.is_empty());
let highlight = &snippets[0].highlight_ranges[0];
let highlighted_text = &snippets[0].text[highlight.start as usize..highlight.end as usize];
assert_eq!(highlighted_text, "IMPORTANT");
}
#[test]
fn test_snippet_generation_empty_content() {
let mock_db = MockDatabase::new();
let snippets = mock_db.generate_snippets("test", None, None, 100);
assert!(snippets.is_empty());
}
#[test]
fn test_snippet_generation_no_matches() {
let mock_db = MockDatabase::new();
let content = "This document has no matching terms";
let snippets = mock_db.generate_snippets("xyzabc", Some(content), None, 100);
assert!(snippets.is_empty());
}
#[test]
fn test_snippet_length_limits() {
let mock_db = MockDatabase::new();
let content = "A very long document with lots of text that should be truncated when generating snippets to test the length limiting functionality of the snippet generation system.";
let short_snippets = mock_db.generate_snippets("text", Some(content), None, 50);
let long_snippets = mock_db.generate_snippets("text", Some(content), None, 150);
assert!(!short_snippets.is_empty());
assert!(!long_snippets.is_empty());
assert!(short_snippets[0].text.len() <= 50);
assert!(long_snippets[0].text.len() > short_snippets[0].text.len());
}
#[test]
fn test_snippet_positioning() {
let mock_db = MockDatabase::new();
let content = "Start of document. This is the middle part with test content. End of document.";
let snippets = mock_db.generate_snippets("test", Some(content), None, 40);
assert!(!snippets.is_empty());
let snippet = &snippets[0];
// Should have reasonable start and end offsets
assert!(snippet.start_offset >= 0);
assert!(snippet.end_offset > snippet.start_offset);
assert!(snippet.end_offset <= content.len() as i32);
}
#[test]
fn test_search_request_defaults() {
let request = SearchRequest {
query: "test".to_string(),
tags: None,
mime_types: None,
limit: None,
offset: None,
include_snippets: None,
snippet_length: None,
search_mode: None,
};
// Test that default values work correctly
assert_eq!(request.query, "test");
assert!(request.include_snippets.is_none());
assert!(request.search_mode.is_none());
}
#[test]
fn test_search_request_with_options() {
let request = SearchRequest {
query: "test query".to_string(),
tags: Some(vec!["tag1".to_string(), "tag2".to_string()]),
mime_types: Some(vec!["application/pdf".to_string()]),
limit: Some(10),
offset: Some(0),
include_snippets: Some(true),
snippet_length: Some(300),
search_mode: Some(SearchMode::Phrase),
};
assert_eq!(request.query, "test query");
assert_eq!(request.tags.as_ref().unwrap().len(), 2);
assert_eq!(request.include_snippets, Some(true));
assert_eq!(request.snippet_length, Some(300));
assert!(matches!(request.search_mode, Some(SearchMode::Phrase)));
}
#[test]
fn test_search_mode_variants() {
// Test all search mode variants
let simple = SearchMode::Simple;
let phrase = SearchMode::Phrase;
let fuzzy = SearchMode::Fuzzy;
let boolean = SearchMode::Boolean;
// Test serialization names
assert_eq!(format!("{:?}", simple), "Simple");
assert_eq!(format!("{:?}", phrase), "Phrase");
assert_eq!(format!("{:?}", fuzzy), "Fuzzy");
assert_eq!(format!("{:?}", boolean), "Boolean");
}
#[test]
fn test_search_mode_default() {
let default_mode = SearchMode::default();
assert!(matches!(default_mode, SearchMode::Simple));
}
#[test]
fn test_highlight_range_creation() {
let range = HighlightRange {
start: 10,
end: 20,
};
assert_eq!(range.start, 10);
assert_eq!(range.end, 20);
assert!(range.end > range.start);
}
#[test]
fn test_enhanced_document_response_creation() {
let doc_id = Uuid::new_v4();
let now = Utc::now();
let snippets = vec![
SearchSnippet {
text: "This is a test snippet".to_string(),
start_offset: 0,
end_offset: 22,
highlight_ranges: vec![
HighlightRange { start: 10, end: 14 }
],
}
];
let response = EnhancedDocumentResponse {
id: doc_id,
filename: "test.pdf".to_string(),
original_filename: "test.pdf".to_string(),
file_size: 1024,
mime_type: "application/pdf".to_string(),
tags: vec!["test".to_string()],
created_at: now,
has_ocr_text: true,
search_rank: Some(0.75),
snippets,
};
assert_eq!(response.id, doc_id);
assert_eq!(response.filename, "test.pdf");
assert_eq!(response.search_rank, Some(0.75));
assert!(response.has_ocr_text);
assert_eq!(response.snippets.len(), 1);
assert_eq!(response.snippets[0].text, "This is a test snippet");
}
#[test]
fn test_snippet_overlap_handling() {
let mock_db = MockDatabase::new();
// Content with multiple overlapping matches
let content = "test testing tested test";
let snippets = mock_db.generate_snippets("test", Some(content), None, 30);
assert!(!snippets.is_empty());
// Should handle overlapping matches gracefully
for snippet in &snippets {
assert!(!snippet.text.is_empty());
assert!(!snippet.highlight_ranges.is_empty());
}
}
#[test]
fn test_snippet_boundary_conditions() {
let mock_db = MockDatabase::new();
// Test with very short content
let short_content = "test";
let snippets = mock_db.generate_snippets("test", Some(short_content), None, 100);
assert!(!snippets.is_empty());
assert_eq!(snippets[0].text, "test");
// Test with match at the beginning
let start_content = "test document content";
let snippets = mock_db.generate_snippets("test", Some(start_content), None, 50);
assert!(!snippets.is_empty());
assert!(snippets[0].text.starts_with("test"));
// Test with match at the end
let end_content = "document content test";
let snippets = mock_db.generate_snippets("test", Some(end_content), None, 50);
assert!(!snippets.is_empty());
assert!(snippets[0].text.ends_with("test"));
}
#[test]
fn test_complex_search_scenarios() {
let mock_db = MockDatabase::new();
// Test with content that has multiple search terms
let complex_content = "This is a comprehensive test document that contains testing methodologies and test cases for quality assurance testing procedures.";
let snippets = mock_db.generate_snippets("test", Some(complex_content), None, 80);
assert!(!snippets.is_empty());
// Verify that highlights are properly positioned
for snippet in &snippets {
for highlight in &snippet.highlight_ranges {
assert!(highlight.start >= 0);
assert!(highlight.end > highlight.start);
assert!(highlight.end <= snippet.text.len() as i32);
let highlighted_text = &snippet.text[highlight.start as usize..highlight.end as usize];
assert_eq!(highlighted_text.to_lowercase(), "test");
}
}
}
#[test]
fn test_unicode_content_handling() {
let mock_db = MockDatabase::new();
let unicode_content = "Это тест документ с важной информацией для тестирования";
let snippets = mock_db.generate_snippets("тест", Some(unicode_content), None, 50);
assert!(!snippets.is_empty());
assert!(snippets[0].text.contains("тест"));
}
#[test]
fn test_special_characters_in_query() {
let mock_db = MockDatabase::new();
let content = "Document with special chars: test@example.com and test-case";
let snippets = mock_db.generate_snippets("test", Some(content), None, 60);
assert!(!snippets.is_empty());
// Should find both occurrences of "test"
let total_highlights: usize = snippets.iter()
.map(|s| s.highlight_ranges.len())
.sum();
assert!(total_highlights >= 2);
}
// Test search suggestions functionality
fn generate_search_suggestions(query: &str) -> Vec<String> {
// Copy of the function from search.rs for testing
let mut suggestions = Vec::new();
if query.len() > 3 {
// Common search variations
suggestions.push(format!("\"{}\"", query)); // Exact phrase
// Add wildcard suggestions
if !query.contains('*') {
suggestions.push(format!("{}*", query));
}
// Add similar terms (this would typically come from a thesaurus or ML model)
if query.contains("document") {
suggestions.push(query.replace("document", "file"));
suggestions.push(query.replace("document", "paper"));
}
}
suggestions.into_iter().take(3).collect()
}
#[test]
fn test_search_suggestions_basic() {
let suggestions = generate_search_suggestions("invoice");
assert!(!suggestions.is_empty());
assert!(suggestions.contains(&"\"invoice\"".to_string()));
assert!(suggestions.contains(&"invoice*".to_string()));
}
#[test]
fn test_search_suggestions_short_query() {
let suggestions = generate_search_suggestions("ab");
// Should not generate suggestions for very short queries
assert!(suggestions.is_empty());
}
#[test]
fn test_search_suggestions_document_replacement() {
let suggestions = generate_search_suggestions("document search");
assert!(!suggestions.is_empty());
assert!(suggestions.iter().any(|s| s.contains("file search")));
assert!(suggestions.iter().any(|s| s.contains("paper search")));
}
#[test]
fn test_search_suggestions_with_wildcard() {
let suggestions = generate_search_suggestions("test*");
assert!(!suggestions.is_empty());
// Should not add another wildcard if one already exists
assert!(!suggestions.iter().any(|s| s.contains("test**")));
}
#[test]
fn test_search_suggestions_limit() {
let suggestions = generate_search_suggestions("document test example");
// Should limit to 3 suggestions
assert!(suggestions.len() <= 3);
}
#[test]
fn test_search_suggestions_case_sensitivity() {
let suggestions = generate_search_suggestions("Document");
assert!(!suggestions.is_empty());
// Should work with different cases
assert!(suggestions.iter().any(|s| s.contains("file") || s.contains("File")));
}
// Performance and error handling tests
#[test]
fn test_snippet_generation_performance() {
let mock_db = MockDatabase::new();
// Test with large content
let large_content = "test ".repeat(10000); // 50KB of repeated "test "
let start_time = std::time::Instant::now();
let snippets = mock_db.generate_snippets("test", Some(&large_content), None, 200);
let duration = start_time.elapsed();
// Should complete within reasonable time (100ms for this size)
assert!(duration.as_millis() < 100);
assert!(!snippets.is_empty());
// Should still limit snippets even with many matches
assert!(snippets.len() <= 3);
}
#[test]
fn test_snippet_generation_memory_usage() {
let mock_db = MockDatabase::new();
// Test with content that could cause memory issues
let content_with_many_matches = (0..1000)
.map(|i| format!("test{} ", i))
.collect::<String>();
let snippets = mock_db.generate_snippets("test", Some(&content_with_many_matches), None, 100);
// Should handle gracefully without consuming excessive memory
assert!(!snippets.is_empty());
assert!(snippets.len() <= 3); // Should still limit results
}
#[test]
fn test_search_request_validation() {
// Test with empty query
let empty_request = SearchRequest {
query: "".to_string(),
tags: None,
mime_types: None,
limit: None,
offset: None,
include_snippets: None,
snippet_length: None,
search_mode: None,
};
// Should handle empty query gracefully
assert_eq!(empty_request.query, "");
// Test with extreme values
let extreme_request = SearchRequest {
query: "a".repeat(10000), // Very long query
tags: Some(vec!["tag".to_string(); 1000]), // Many tags
mime_types: Some(vec!["type".to_string(); 100]), // Many mime types
limit: Some(i64::MAX),
offset: Some(i64::MAX),
include_snippets: Some(true),
snippet_length: Some(i32::MAX),
search_mode: Some(SearchMode::Boolean),
};
// Should handle extreme values without panicking
assert!(extreme_request.query.len() == 10000);
assert!(extreme_request.tags.as_ref().unwrap().len() == 1000);
}
#[test]
fn test_highlight_range_validation() {
let mock_db = MockDatabase::new();
let content = "This is a test document for validation";
let snippets = mock_db.generate_snippets("test", Some(content), None, 50);
assert!(!snippets.is_empty());
// Validate all highlight ranges
for snippet in &snippets {
for highlight in &snippet.highlight_ranges {
// Ranges should be valid
assert!(highlight.start >= 0);
assert!(highlight.end > highlight.start);
assert!(highlight.end <= snippet.text.len() as i32);
// Highlighted text should match query (case insensitive)
let highlighted_text = &snippet.text[highlight.start as usize..highlight.end as usize];
assert_eq!(highlighted_text.to_lowercase(), "test");
}
}
}
#[test]
fn test_search_mode_query_function_mapping() {
// Test that different search modes would map to correct PostgreSQL functions
let modes = vec![
(SearchMode::Simple, "plainto_tsquery"),
(SearchMode::Phrase, "phraseto_tsquery"),
(SearchMode::Fuzzy, "plainto_tsquery"), // Same as simple for now
(SearchMode::Boolean, "to_tsquery"),
];
for (mode, expected_function) in modes {
// This tests the logic that would be used in the database layer
let query_function = match mode {
SearchMode::Simple => "plainto_tsquery",
SearchMode::Phrase => "phraseto_tsquery",
SearchMode::Fuzzy => "plainto_tsquery",
SearchMode::Boolean => "to_tsquery",
};
assert_eq!(query_function, expected_function);
}
}
#[test]
fn test_enhanced_document_response_serialization() {
let doc_id = Uuid::new_v4();
let now = Utc::now();
let response = EnhancedDocumentResponse {
id: doc_id,
filename: "test.pdf".to_string(),
original_filename: "test.pdf".to_string(),
file_size: 1024,
mime_type: "application/pdf".to_string(),
tags: vec!["test".to_string(), "document".to_string()],
created_at: now,
has_ocr_text: true,
search_rank: Some(0.85),
snippets: vec![
SearchSnippet {
text: "Test snippet".to_string(),
start_offset: 0,
end_offset: 12,
highlight_ranges: vec![
HighlightRange { start: 0, end: 4 }
],
}
],
};
// Test that all fields are properly accessible
assert_eq!(response.id, doc_id);
assert_eq!(response.tags.len(), 2);
assert_eq!(response.snippets.len(), 1);
assert!(response.search_rank.unwrap() > 0.8);
}
#[test]
fn test_snippet_edge_cases() {
let mock_db = MockDatabase::new();
// Test with query longer than content
let short_content = "hi";
let snippets = mock_db.generate_snippets("hello world", Some(short_content), None, 100);
assert!(snippets.is_empty());
// Test with whitespace-only content
let whitespace_content = " \t\n ";
let snippets = mock_db.generate_snippets("test", Some(whitespace_content), None, 100);
assert!(snippets.is_empty());
// Test with special characters in content
let special_content = "test@example.com, test-case, test/path, test(1)";
let snippets = mock_db.generate_snippets("test", Some(special_content), None, 100);
assert!(!snippets.is_empty());
assert!(snippets[0].highlight_ranges.len() >= 3); // Should find multiple "test" instances
}
// Integration tests that would work with actual database
#[tokio::test]
#[ignore = "Requires PostgreSQL database for integration testing"]
async fn test_enhanced_search_integration() {
// This would test the actual database integration
// Similar to existing db_tests but for enhanced search
let db_url = std::env::var("TEST_DATABASE_URL")
.unwrap_or_else(|_| "postgresql://postgres:postgres@localhost:5432/readur_test".to_string());
let db = Database::new(&db_url).await.expect("Failed to connect to test database");
db.migrate().await.expect("Failed to migrate test database");
// Create test user
let user_data = CreateUser {
username: "test_enhanced_search".to_string(),
email: "enhanced@test.com".to_string(),
password: "password123".to_string(),
};
let user = db.create_user(user_data).await.unwrap();
// Create test document with rich content
let document = Document {
id: Uuid::new_v4(),
filename: "enhanced_test.pdf".to_string(),
original_filename: "enhanced_test.pdf".to_string(),
file_path: "/path/to/enhanced_test.pdf".to_string(),
file_size: 2048,
mime_type: "application/pdf".to_string(),
content: Some("This is a comprehensive test document for enhanced search functionality testing".to_string()),
ocr_text: Some("OCR extracted content with additional test information for search validation".to_string()),
tags: vec!["enhanced".to_string(), "search".to_string(), "test".to_string()],
created_at: Utc::now(),
updated_at: Utc::now(),
user_id: user.id,
};
db.create_document(document).await.unwrap();
// Test enhanced search with snippets
let search_request = SearchRequest {
query: "test".to_string(),
tags: None,
mime_types: None,
limit: Some(10),
offset: Some(0),
include_snippets: Some(true),
snippet_length: Some(100),
search_mode: Some(SearchMode::Simple),
};
let result = db.enhanced_search_documents(user.id, search_request).await;
assert!(result.is_ok());
let (documents, total, query_time) = result.unwrap();
assert_eq!(total, 1);
assert_eq!(documents.len(), 1);
assert!(query_time > 0);
let doc = &documents[0];
assert!(!doc.snippets.is_empty());
assert!(doc.search_rank.is_some());
assert!(doc.search_rank.unwrap() > 0.0);
}
}

View File

@ -3,5 +3,6 @@ mod auth_tests;
mod db_tests;
mod file_service_tests;
mod ocr_tests;
mod enhanced_search_tests;
mod settings_tests;
mod users_tests;