feat(client/server): add swagger-ui, improve search bar

This commit is contained in:
perfectra1n 2025-06-12 21:46:41 -07:00
parent 0abc8f272a
commit 1f50004d66
13 changed files with 834 additions and 45 deletions

115
Cargo.lock generated
View File

@ -1295,6 +1295,7 @@ checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
dependencies = [
"equivalent",
"hashbrown 0.15.4",
"serde",
]
[[package]]
@ -1975,6 +1976,30 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro-error"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
dependencies = [
"proc-macro-error-attr",
"proc-macro2",
"quote",
"syn 1.0.109",
"version_check",
]
[[package]]
name = "proc-macro-error-attr"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
dependencies = [
"proc-macro2",
"quote",
"version_check",
]
[[package]]
name = "proc-macro2"
version = "1.0.95"
@ -2066,6 +2091,8 @@ dependencies = [
"tower-http",
"tracing",
"tracing-subscriber",
"utoipa",
"utoipa-swagger-ui",
"uuid",
"walkdir",
]
@ -2183,6 +2210,40 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rust-embed"
version = "8.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "025908b8682a26ba8d12f6f2d66b987584a4a87bc024abc5bbc12553a8cd178a"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6065f1a4392b71819ec1ea1df1120673418bf386f50de1d6f54204d836d4349c"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn 2.0.102",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6cc0c81648b20b70c491ff8cce00c1c3b223bb8ed2b5d41f0e54c6c4c0a3594"
dependencies = [
"sha2",
"walkdir",
]
[[package]]
name = "rustc-demangle"
version = "0.1.25"
@ -3293,6 +3354,48 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "utoipa"
version = "4.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23"
dependencies = [
"indexmap",
"serde",
"serde_json",
"utoipa-gen",
]
[[package]]
name = "utoipa-gen"
version = "4.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20c24e8ab68ff9ee746aad22d39b5535601e6416d1b0feeabf78be986a5c4392"
dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"regex",
"syn 2.0.102",
"uuid",
]
[[package]]
name = "utoipa-swagger-ui"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b39868d43c011961e04b41623e050aedf2cc93652562ff7935ce0f819aaf2da"
dependencies = [
"axum",
"mime_guess",
"regex",
"rust-embed",
"serde",
"serde_json",
"utoipa",
"zip",
]
[[package]]
name = "uuid"
version = "1.17.0"
@ -3908,3 +4011,15 @@ dependencies = [
"quote",
"syn 2.0.102",
]
[[package]]
name = "zip"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
dependencies = [
"byteorder",
"crc32fast",
"crossbeam-utils",
"flate2",
]

View File

@ -86,7 +86,7 @@ describe('GlobalSearchBar', () => {
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
test('shows search suggestions when input is focused', async () => {
test('shows popular searches when input is focused', async () => {
renderWithRouter(<GlobalSearchBar />);
const searchInput = screen.getByPlaceholderText('Search documents...');
@ -97,6 +97,7 @@ describe('GlobalSearchBar', () => {
await waitFor(() => {
expect(screen.getByText('Start typing to search documents')).toBeInTheDocument();
expect(screen.getByText('Popular searches:')).toBeInTheDocument();
expect(screen.getByText('invoice')).toBeInTheDocument();
expect(screen.getByText('contract')).toBeInTheDocument();
expect(screen.getByText('report')).toBeInTheDocument();
@ -135,6 +136,7 @@ describe('GlobalSearchBar', () => {
await waitFor(() => {
expect(screen.getByText('Quick Results')).toBeInTheDocument();
expect(screen.getByText('2 found')).toBeInTheDocument(); // Enhanced result count display
expect(screen.getByText('test.pdf')).toBeInTheDocument();
expect(screen.getByText('image.png')).toBeInTheDocument();
});
@ -409,4 +411,55 @@ describe('GlobalSearchBar', () => {
expect(screen.queryByText('Quick Results')).not.toBeInTheDocument();
});
});
// New tests for enhanced functionality
test('shows typing indicator while user is typing', async () => {
const user = userEvent.setup();
renderWithRouter(<GlobalSearchBar />);
const searchInput = screen.getByPlaceholderText('Search documents...');
await act(async () => {
await user.type(searchInput, 't', { delay: 50 });
});
// Should show typing indicator
expect(screen.getAllByRole('progressbar').length).toBeGreaterThan(0);
});
test('shows smart suggestions while typing', async () => {
const user = userEvent.setup();
renderWithRouter(<GlobalSearchBar />);
const searchInput = screen.getByPlaceholderText('Search documents...');
await act(async () => {
await user.type(searchInput, 'inv');
});
await waitFor(() => {
expect(screen.getByText('Try these suggestions:')).toBeInTheDocument();
});
});
test('popular search chips are clickable', async () => {
const user = userEvent.setup();
renderWithRouter(<GlobalSearchBar />);
const searchInput = screen.getByPlaceholderText('Search documents...');
await act(async () => {
searchInput.focus();
});
await waitFor(() => {
expect(screen.getByText('invoice')).toBeInTheDocument();
});
const invoiceChip = screen.getByText('invoice');
await user.click(invoiceChip);
expect(searchInput.value).toBe('invoice');
expect(mockNavigate).toHaveBeenCalledWith('/search?q=invoice');
});
});

View File

@ -0,0 +1,279 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SearchGuidance from '../SearchGuidance';
describe('SearchGuidance', () => {
const mockOnExampleClick = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
test('renders search guidance with examples in expanded mode', () => {
render(<SearchGuidance onExampleClick={mockOnExampleClick} />);
expect(screen.getByText('Search Help & Examples')).toBeInTheDocument();
// Click to expand accordion
const accordionButton = screen.getByRole('button', { expanded: false });
fireEvent.click(accordionButton);
expect(screen.getByText('Example Searches')).toBeInTheDocument();
expect(screen.getByText('Search Tips')).toBeInTheDocument();
expect(screen.getByText('Quick Start')).toBeInTheDocument();
});
test('renders compact mode correctly', () => {
render(<SearchGuidance compact onExampleClick={mockOnExampleClick} />);
const helpButton = screen.getByRole('button');
expect(helpButton).toBeInTheDocument();
// Initially collapsed in compact mode
expect(screen.queryByText('Quick Search Tips')).not.toBeInTheDocument();
});
test('toggles compact help visibility', async () => {
const user = userEvent.setup();
render(<SearchGuidance compact onExampleClick={mockOnExampleClick} />);
const helpButton = screen.getByRole('button');
// Expand help
await user.click(helpButton);
expect(screen.getByText('Quick Search Tips')).toBeInTheDocument();
expect(screen.getByText('• Use quotes for exact phrases: "annual report"')).toBeInTheDocument();
// Collapse help
await user.click(helpButton);
await waitFor(() => {
expect(screen.queryByText('Quick Search Tips')).not.toBeInTheDocument();
});
});
test('displays search examples with clickable items', async () => {
const user = userEvent.setup();
render(<SearchGuidance onExampleClick={mockOnExampleClick} />);
// Expand accordion
const accordionButton = screen.getByRole('button', { expanded: false });
await user.click(accordionButton);
// Check for example queries
expect(screen.getByText('invoice 2024')).toBeInTheDocument();
expect(screen.getByText('"project proposal"')).toBeInTheDocument();
expect(screen.getByText('tag:important')).toBeInTheDocument();
expect(screen.getByText('contract AND payment')).toBeInTheDocument();
expect(screen.getByText('proj*')).toBeInTheDocument();
});
test('calls onExampleClick when example is clicked', async () => {
const user = userEvent.setup();
render(<SearchGuidance onExampleClick={mockOnExampleClick} />);
// Expand accordion
const accordionButton = screen.getByRole('button', { expanded: false });
await user.click(accordionButton);
// Click on an example
const exampleItem = screen.getByText('invoice 2024').closest('li');
await user.click(exampleItem);
expect(mockOnExampleClick).toHaveBeenCalledWith('invoice 2024');
});
test('displays search tips', async () => {
const user = userEvent.setup();
render(<SearchGuidance onExampleClick={mockOnExampleClick} />);
// Expand accordion
const accordionButton = screen.getByRole('button', { expanded: false });
await user.click(accordionButton);
// Check for search tips
expect(screen.getByText('• Use quotes for exact phrases: "annual report"')).toBeInTheDocument();
expect(screen.getByText('• Search by tags: tag:urgent or tag:personal')).toBeInTheDocument();
expect(screen.getByText('• Use AND/OR for complex queries: (invoice OR receipt) AND 2024')).toBeInTheDocument();
expect(screen.getByText('• Wildcards work great: proj* finds project, projects, projection')).toBeInTheDocument();
expect(screen.getByText('• Search OCR text in images and PDFs automatically')).toBeInTheDocument();
expect(screen.getByText('• File types are searchable: PDF, Word, Excel, images')).toBeInTheDocument();
});
test('displays quick start chips that are clickable', async () => {
const user = userEvent.setup();
render(<SearchGuidance onExampleClick={mockOnExampleClick} />);
// Expand accordion
const accordionButton = screen.getByRole('button', { expanded: false });
await user.click(accordionButton);
// Click on a quick start chip
const chipElement = screen.getByText('invoice 2024');
await user.click(chipElement);
expect(mockOnExampleClick).toHaveBeenCalledWith('invoice 2024');
});
test('compact mode shows limited examples', async () => {
const user = userEvent.setup();
render(<SearchGuidance compact onExampleClick={mockOnExampleClick} />);
const helpButton = screen.getByRole('button');
await user.click(helpButton);
// Should show only first 3 examples in compact mode
expect(screen.getByText('invoice 2024')).toBeInTheDocument();
expect(screen.getByText('"project proposal"')).toBeInTheDocument();
expect(screen.getByText('tag:important')).toBeInTheDocument();
// Should not show all examples in compact mode
expect(screen.queryByText('contract AND payment')).not.toBeInTheDocument();
});
test('compact mode shows limited tips', async () => {
const user = userEvent.setup();
render(<SearchGuidance compact onExampleClick={mockOnExampleClick} />);
const helpButton = screen.getByRole('button');
await user.click(helpButton);
// Should show only first 3 tips in compact mode
const tips = screen.getAllByText(/^•/);
expect(tips).toHaveLength(3);
});
test('handles missing onExampleClick gracefully', async () => {
const user = userEvent.setup();
render(<SearchGuidance />);
// Expand accordion
const accordionButton = screen.getByRole('button', { expanded: false });
await user.click(accordionButton);
// Click on an example - should not crash
const exampleItem = screen.getByText('invoice 2024').closest('li');
await user.click(exampleItem);
// Should not crash when onExampleClick is not provided
expect(true).toBe(true);
});
test('displays correct icons for different example types', async () => {
const user = userEvent.setup();
render(<SearchGuidance onExampleClick={mockOnExampleClick} />);
// Expand accordion
const accordionButton = screen.getByRole('button', { expanded: false });
await user.click(accordionButton);
// Check for different icons (by test id)
expect(screen.getByTestId('SearchIcon')).toBeInTheDocument();
expect(screen.getByTestId('FormatQuoteIcon')).toBeInTheDocument();
expect(screen.getByTestId('TagIcon')).toBeInTheDocument();
expect(screen.getByTestId('ExtensionIcon')).toBeInTheDocument();
expect(screen.getByTestId('TrendingUpIcon')).toBeInTheDocument();
});
test('compact mode toggle button changes icon', async () => {
const user = userEvent.setup();
render(<SearchGuidance compact onExampleClick={mockOnExampleClick} />);
const helpButton = screen.getByRole('button');
// Initially shows help icon
expect(screen.getByTestId('HelpIcon')).toBeInTheDocument();
// Click to expand
await user.click(helpButton);
// Should show close icon when expanded
expect(screen.getByTestId('CloseIcon')).toBeInTheDocument();
});
test('applies custom styling props', () => {
const customSx = { backgroundColor: 'red' };
render(<SearchGuidance sx={customSx} data-testid="search-guidance" />);
const component = screen.getByTestId('search-guidance');
expect(component).toBeInTheDocument();
});
test('provides helpful descriptions for each search example', async () => {
const user = userEvent.setup();
render(<SearchGuidance onExampleClick={mockOnExampleClick} />);
// Expand accordion
const accordionButton = screen.getByRole('button', { expanded: false });
await user.click(accordionButton);
// Check for example descriptions
expect(screen.getByText('Find documents containing both "invoice" and "2024"')).toBeInTheDocument();
expect(screen.getByText('Search for exact phrase "project proposal"')).toBeInTheDocument();
expect(screen.getByText('Find all documents tagged as "important"')).toBeInTheDocument();
expect(screen.getByText('Advanced search using AND operator')).toBeInTheDocument();
expect(screen.getByText('Wildcard search for project, projects, etc.')).toBeInTheDocument();
});
test('keyboard navigation works for examples', async () => {
const user = userEvent.setup();
render(<SearchGuidance onExampleClick={mockOnExampleClick} />);
// Expand accordion
const accordionButton = screen.getByRole('button', { expanded: false });
await user.click(accordionButton);
// Tab to first example and press Enter
const firstExample = screen.getByText('invoice 2024').closest('li');
firstExample.focus();
await user.keyboard('{Enter}');
expect(mockOnExampleClick).toHaveBeenCalledWith('invoice 2024');
});
});
describe('SearchGuidance Accessibility', () => {
test('has proper ARIA labels and roles', async () => {
const user = userEvent.setup();
render(<SearchGuidance />);
// Accordion should have proper role
const accordion = screen.getByRole('button', { expanded: false });
expect(accordion).toBeInTheDocument();
// Expand to check list accessibility
await user.click(accordion);
const list = screen.getByRole('list');
expect(list).toBeInTheDocument();
const listItems = screen.getAllByRole('listitem');
expect(listItems.length).toBeGreaterThan(0);
});
test('compact mode has accessible toggle button', () => {
render(<SearchGuidance compact />);
const toggleButton = screen.getByRole('button');
expect(toggleButton).toBeInTheDocument();
expect(toggleButton).toHaveAttribute('type', 'button');
});
test('examples are keyboard accessible', async () => {
const user = userEvent.setup();
const mockOnExampleClick = jest.fn();
render(<SearchGuidance onExampleClick={mockOnExampleClick} />);
// Expand accordion
const accordionButton = screen.getByRole('button', { expanded: false });
await user.click(accordionButton);
// All examples should be focusable
const examples = screen.getAllByRole('listitem');
examples.forEach(example => {
expect(example).toHaveAttribute('tabindex', '0');
});
});
});

View File

@ -14,6 +14,20 @@ jest.mock('../../services/api', () => ({
}
}));
// Mock SearchGuidance component
jest.mock('../../components/SearchGuidance', () => {
return function MockSearchGuidance({ onExampleClick, compact }) {
return (
<div data-testid="search-guidance">
<button onClick={() => onExampleClick?.('test query')}>
Mock Guidance Example
</button>
{compact && <span>Compact Mode</span>}
</div>
);
};
});
// Mock useNavigate
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
@ -49,7 +63,7 @@ const mockSearchResponse = {
],
total: 1,
query_time_ms: 45,
suggestions: ['\"test\"', 'test*']
suggestions: ['\"test\"', 'test*', 'tag:test']
}
};
@ -77,9 +91,10 @@ describe('SearchPage', () => {
expect(screen.getByText('Start searching your documents')).toBeInTheDocument();
});
test('displays search suggestions when no query is entered', () => {
test('displays search tips and examples when no query is entered', () => {
renderWithRouter(<SearchPage />);
expect(screen.getByText('Search Tips:')).toBeInTheDocument();
expect(screen.getByText('Try: invoice')).toBeInTheDocument();
expect(screen.getByText('Try: contract')).toBeInTheDocument();
expect(screen.getByText('Try: tag:important')).toBeInTheDocument();
@ -126,7 +141,7 @@ describe('SearchPage', () => {
});
});
test('shows search suggestions when available', async () => {
test('shows quick suggestions while typing', async () => {
const user = userEvent.setup();
renderWithRouter(<SearchPage />);
@ -137,13 +152,29 @@ describe('SearchPage', () => {
});
await waitFor(() => {
expect(screen.getByText('Suggestions:')).toBeInTheDocument();
expect(screen.getByText('Quick suggestions:')).toBeInTheDocument();
});
});
test('shows server suggestions from search results', 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('Related searches:')).toBeInTheDocument();
expect(screen.getByText('\"test\"')).toBeInTheDocument();
expect(screen.getByText('test*')).toBeInTheDocument();
expect(screen.getByText('tag:test')).toBeInTheDocument();
});
});
test('toggles advanced search options', async () => {
test('toggles advanced search options with guidance', async () => {
const user = userEvent.setup();
renderWithRouter(<SearchPage />);
@ -154,9 +185,11 @@ describe('SearchPage', () => {
expect(screen.getByText('Search Options')).toBeInTheDocument();
expect(screen.getByText('Enhanced Search')).toBeInTheDocument();
expect(screen.getByText('Show Snippets')).toBeInTheDocument();
expect(screen.getByTestId('search-guidance')).toBeInTheDocument();
expect(screen.getByText('Compact Mode')).toBeInTheDocument();
});
test('changes search mode', async () => {
test('changes search mode with simplified labels', async () => {
const user = userEvent.setup();
renderWithRouter(<SearchPage />);
@ -167,11 +200,11 @@ describe('SearchPage', () => {
});
await waitFor(() => {
const phraseButton = screen.getByRole('button', { name: 'Phrase' });
const phraseButton = screen.getByRole('button', { name: 'Exact phrase' });
expect(phraseButton).toBeInTheDocument();
});
const phraseButton = screen.getByRole('button', { name: 'Phrase' });
const phraseButton = screen.getByRole('button', { name: 'Exact phrase' });
await user.click(phraseButton);
// Wait for search to be called with new mode
@ -183,6 +216,23 @@ describe('SearchPage', () => {
);
});
});
test('displays simplified search mode labels', 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.getByRole('button', { name: 'Smart' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Exact phrase' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Similar words' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Advanced' })).toBeInTheDocument();
});
});
test('handles search suggestions click', async () => {
const user = userEvent.setup();
@ -195,7 +245,7 @@ describe('SearchPage', () => {
});
await waitFor(() => {
expect(screen.getByText('\"test\"')).toBeInTheDocument();
expect(screen.getByText('Related searches:')).toBeInTheDocument();
});
const suggestionChip = screen.getByText('\"test\"');
@ -272,12 +322,12 @@ describe('SearchPage', () => {
});
});
test('displays loading state during search', async () => {
test('displays enhanced loading state with progress during search', async () => {
const user = userEvent.setup();
// Mock a delayed response
documentService.enhancedSearch.mockImplementation(() =>
new Promise(resolve => setTimeout(() => resolve(mockSearchResponse), 100))
new Promise(resolve => setTimeout(() => resolve(mockSearchResponse), 200))
);
renderWithRouter(<SearchPage />);
@ -285,15 +335,15 @@ describe('SearchPage', () => {
const searchInput = screen.getByPlaceholderText(/Search documents by content, filename, or tags/);
await act(async () => {
await user.type(searchInput, 'test');
await user.type(searchInput, 't');
});
// Should show loading indicator
expect(screen.getByRole('progressbar')).toBeInTheDocument();
// Should show loading indicators
expect(screen.getAllByRole('progressbar').length).toBeGreaterThan(0);
await waitFor(() => {
expect(screen.getByText('test.pdf')).toBeInTheDocument();
});
}, { timeout: 3000 });
});
test('handles search error gracefully', async () => {
@ -445,16 +495,106 @@ describe('SearchPage', () => {
});
});
// 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);
});
// New functionality tests
describe('Enhanced Search Features', () => {
test('shows typing indicator while user is typing', async () => {
const user = userEvent.setup();
renderWithRouter(<SearchPage />);
const searchInput = screen.getByPlaceholderText(/Search documents by content, filename, or tags/);
// Start typing without completing
await act(async () => {
await user.type(searchInput, 't', { delay: 50 });
});
test('formats dates correctly', () => {
// These would test utility functions if they were exported
expect(true).toBe(true);
// Should show typing indicator
expect(screen.getAllByRole('progressbar').length).toBeGreaterThan(0);
});
test('shows improved no results state with suggestions', async () => {
const user = userEvent.setup();
// Mock empty response
documentService.enhancedSearch.mockResolvedValue({
data: {
documents: [],
total: 0,
query_time_ms: 10,
suggestions: []
}
});
renderWithRouter(<SearchPage />);
const searchInput = screen.getByPlaceholderText(/Search documents by content, filename, or tags/);
await act(async () => {
await user.type(searchInput, 'nonexistent');
});
await waitFor(() => {
expect(screen.getByText(/No results found for "nonexistent"/)).toBeInTheDocument();
expect(screen.getByText('Suggestions:')).toBeInTheDocument();
expect(screen.getByText('• Try simpler or more general terms')).toBeInTheDocument();
});
});
test('clickable example chips in empty state work correctly', async () => {
const user = userEvent.setup();
renderWithRouter(<SearchPage />);
const invoiceChip = screen.getByText('Try: invoice');
await user.click(invoiceChip);
const searchInput = screen.getByPlaceholderText(/Search documents by content, filename, or tags/);
expect(searchInput.value).toBe('invoice');
});
test('search guidance example click works', async () => {
const user = userEvent.setup();
renderWithRouter(<SearchPage />);
const settingsButton = screen.getByRole('button', { name: /settings/i });
await user.click(settingsButton);
const guidanceExample = screen.getByText('Mock Guidance Example');
await user.click(guidanceExample);
const searchInput = screen.getByPlaceholderText(/Search documents by content, filename, or tags/);
expect(searchInput.value).toBe('test query');
});
test('mobile filter toggle works', async () => {
const user = userEvent.setup();
// Mock mobile viewport
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 500,
});
renderWithRouter(<SearchPage />);
// Mobile filter button should be visible
const mobileFilterButton = screen.getByTestId('FilterIcon');
expect(mobileFilterButton).toBeInTheDocument();
});
test('search results have enhanced CSS classes for styling', 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(() => {
const resultCard = screen.getByText('test.pdf').closest('[class*="search-result-card"]');
expect(resultCard).toBeInTheDocument();
});
});
});

View File

@ -2,7 +2,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
use utoipa::ToSchema;
use utoipa::{ToSchema, IntoParams};
#[derive(Debug, Serialize, Deserialize, FromRow, ToSchema)]
pub struct User {
@ -68,7 +68,7 @@ pub struct DocumentResponse {
pub has_ocr_text: bool,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Serialize, Deserialize, ToSchema, IntoParams)]
pub struct SearchRequest {
pub query: String,
pub tags: Option<Vec<String>>,

View File

@ -6,7 +6,6 @@ use axum::{
Router,
};
use std::sync::Arc;
use utoipa::path;
use crate::{
auth::{create_jwt, AuthUser},

View File

@ -7,7 +7,7 @@ use axum::{
};
use serde::Deserialize;
use std::sync::Arc;
use utoipa::{path, ToSchema};
use utoipa::ToSchema;
use crate::{
auth::AuthUser,

View File

@ -2,7 +2,7 @@ use axum::{
extract::State,
http::StatusCode,
response::Json,
routing::get,
routing::{get, post},
Router,
};
use std::sync::Arc;
@ -15,6 +15,18 @@ pub fn router() -> Router<Arc<AppState>> {
.route("/requeue-failed", post(requeue_failed))
}
#[utoipa::path(
get,
path = "/api/queue/stats",
tag = "queue",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "OCR queue statistics"),
(status = 401, description = "Unauthorized")
)
)]
async fn get_queue_stats(
State(state): State<Arc<AppState>>,
_auth_user: AuthUser, // Require authentication
@ -40,8 +52,18 @@ async fn get_queue_stats(
})))
}
use axum::routing::post;
#[utoipa::path(
post,
path = "/api/queue/requeue-failed",
tag = "queue",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "Failed items requeued successfully"),
(status = 401, description = "Unauthorized")
)
)]
async fn requeue_failed(
State(state): State<Arc<AppState>>,
_auth_user: AuthUser, // Require authentication

View File

@ -6,7 +6,6 @@ use axum::{
Router,
};
use std::sync::Arc;
use utoipa::path;
use crate::{
auth::AuthUser,

View File

@ -6,7 +6,6 @@ use axum::{
Router,
};
use std::sync::Arc;
use utoipa::path;
use crate::{
auth::AuthUser,
@ -19,6 +18,18 @@ pub fn router() -> Router<Arc<AppState>> {
.route("/", get(get_settings).put(update_settings))
}
#[utoipa::path(
get,
path = "/api/settings",
tag = "settings",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "User settings", body = SettingsResponse),
(status = 401, description = "Unauthorized")
)
)]
async fn get_settings(
auth_user: AuthUser,
State(state): State<Arc<AppState>>,
@ -57,6 +68,20 @@ async fn get_settings(
Ok(Json(response))
}
#[utoipa::path(
put,
path = "/api/settings",
tag = "settings",
security(
("bearer_auth" = [])
),
request_body = UpdateSettings,
responses(
(status = 200, description = "Settings updated successfully", body = SettingsResponse),
(status = 400, description = "Bad request - invalid settings data"),
(status = 401, description = "Unauthorized")
)
)]
async fn update_settings(
auth_user: AuthUser,
State(state): State<Arc<AppState>>,

View File

@ -2,7 +2,7 @@ use axum::{
extract::{Path, State},
http::StatusCode,
response::Json,
routing::get,
routing::{get, post, put, delete},
Router,
};
use std::sync::Arc;
@ -20,6 +20,18 @@ pub fn router() -> Router<Arc<AppState>> {
.route("/:id", get(get_user).put(update_user).delete(delete_user))
}
#[utoipa::path(
get,
path = "/api/users",
tag = "users",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "List of all users", body = Vec<UserResponse>),
(status = 401, description = "Unauthorized")
)
)]
async fn list_users(
_auth_user: AuthUser,
State(state): State<Arc<AppState>>,
@ -34,6 +46,22 @@ async fn list_users(
Ok(Json(user_responses))
}
#[utoipa::path(
get,
path = "/api/users/{id}",
tag = "users",
security(
("bearer_auth" = [])
),
params(
("id" = Uuid, Path, description = "User ID")
),
responses(
(status = 200, description = "User information", body = UserResponse),
(status = 404, description = "User not found"),
(status = 401, description = "Unauthorized")
)
)]
async fn get_user(
_auth_user: AuthUser,
State(state): State<Arc<AppState>>,
@ -49,6 +77,20 @@ async fn get_user(
Ok(Json(user.into()))
}
#[utoipa::path(
post,
path = "/api/users",
tag = "users",
security(
("bearer_auth" = [])
),
request_body = CreateUser,
responses(
(status = 200, description = "User created successfully", body = UserResponse),
(status = 400, description = "Bad request - invalid user data"),
(status = 401, description = "Unauthorized")
)
)]
async fn create_user(
_auth_user: AuthUser,
State(state): State<Arc<AppState>>,
@ -63,6 +105,23 @@ async fn create_user(
Ok(Json(user.into()))
}
#[utoipa::path(
put,
path = "/api/users/{id}",
tag = "users",
security(
("bearer_auth" = [])
),
params(
("id" = Uuid, Path, description = "User ID")
),
request_body = UpdateUser,
responses(
(status = 200, description = "User updated successfully", body = UserResponse),
(status = 400, description = "Bad request - invalid user data"),
(status = 401, description = "Unauthorized")
)
)]
async fn update_user(
_auth_user: AuthUser,
State(state): State<Arc<AppState>>,
@ -78,6 +137,22 @@ async fn update_user(
Ok(Json(user.into()))
}
#[utoipa::path(
delete,
path = "/api/users/{id}",
tag = "users",
security(
("bearer_auth" = [])
),
params(
("id" = Uuid, Path, description = "User ID")
),
responses(
(status = 204, description = "User deleted successfully"),
(status = 403, description = "Forbidden - cannot delete yourself"),
(status = 401, description = "Unauthorized")
)
)]
async fn delete_user(
auth_user: AuthUser,
State(state): State<Arc<AppState>>,

View File

@ -1,4 +1,5 @@
use utoipa::OpenApi;
use utoipa::{OpenApi, Modify};
use utoipa::openapi::security::{SecurityScheme, HttpAuthScheme, Http};
use utoipa_swagger_ui::SwaggerUi;
use axum::Router;
use std::sync::Arc;
@ -25,16 +26,19 @@ use crate::{
crate::routes::documents::download_document,
// Search endpoints
crate::routes::search::search_documents,
crate::routes::search::enhanced_search_documents,
// Settings endpoints
crate::routes::settings::get_settings,
crate::routes::settings::update_settings,
// User endpoints
crate::routes::users::list_users,
crate::routes::users::create_user,
crate::routes::users::get_user,
crate::routes::users::update_user,
crate::routes::users::delete_user,
// Queue endpoints
crate::routes::queue::get_queue_status,
crate::routes::queue::get_queue_stats,
crate::routes::queue::requeue_failed,
),
components(
schemas(
@ -51,6 +55,7 @@ use crate::{
(name = "users", description = "User management endpoints"),
(name = "queue", description = "OCR queue management endpoints"),
),
modifiers(&SecurityAddon),
info(
title = "Readur API",
version = "0.1.0",
@ -66,7 +71,21 @@ use crate::{
)]
pub struct ApiDoc;
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() {
components.add_security_scheme(
"bearer_auth",
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer))
)
}
}
}
pub fn create_swagger_router() -> Router<Arc<AppState>> {
SwaggerUi::new("/swagger-ui")
.url("/api-docs/openapi.json", ApiDoc::openapi())
.into()
}

View File

@ -407,12 +407,12 @@ mod tests {
assert!(total_highlights >= 2);
}
// Test search suggestions functionality
// Test search suggestions functionality - enhanced version
fn generate_search_suggestions(query: &str) -> Vec<String> {
// Copy of the function from search.rs for testing
// Enhanced copy of the function from search.rs for testing
let mut suggestions = Vec::new();
if query.len() > 3 {
if query.len() > 2 { // Reduced minimum length for faster suggestions
// Common search variations
suggestions.push(format!("\"{}\"", query)); // Exact phrase
@ -421,16 +421,39 @@ mod tests {
suggestions.push(format!("{}*", query));
}
// Add tag search suggestion
if !query.starts_with("tag:") {
suggestions.push(format!("tag:{}", query));
}
// Add similar terms (this would typically come from a thesaurus or ML model)
// Use case-insensitive matching for replacements
let query_lower = query.to_lowercase();
if query_lower.contains("document") {
suggestions.push(query.replace("document", "file").replace("Document", "file"));
suggestions.push(query.replace("document", "paper").replace("Document", "paper"));
}
// Add Boolean operator suggestions for longer queries
if query.len() > 5 && !query.contains(" AND ") && !query.contains(" OR ") {
let words: Vec<&str> = query.split_whitespace().collect();
if words.len() >= 2 {
suggestions.push(format!("{} AND {}", words[0], words[1]));
suggestions.push(format!("{} OR {}", words[0], words[1]));
}
}
// Add content type suggestions
if query_lower.contains("invoice") {
suggestions.push("receipt".to_string());
suggestions.push("billing".to_string());
}
if query_lower.contains("contract") {
suggestions.push("agreement".to_string());
suggestions.push("legal".to_string());
}
}
suggestions.into_iter().take(5).collect() // Increase limit to accommodate replacements
suggestions.into_iter().take(6).collect() // Increased limit for enhanced suggestions
}
#[test]
@ -449,6 +472,46 @@ mod tests {
// Should not generate suggestions for very short queries
assert!(suggestions.is_empty());
}
#[test]
fn test_search_suggestions_enhanced_features() {
let suggestions = generate_search_suggestions("invoice payment");
assert!(!suggestions.is_empty());
assert!(suggestions.contains(&"\"invoice payment\"".to_string()));
assert!(suggestions.contains(&"invoice payment*".to_string()));
assert!(suggestions.contains(&"tag:invoice payment".to_string()));
assert!(suggestions.contains(&"invoice AND payment".to_string()));
assert!(suggestions.contains(&"invoice OR payment".to_string()));
}
#[test]
fn test_search_suggestions_content_specific() {
let invoice_suggestions = generate_search_suggestions("invoice");
assert!(invoice_suggestions.contains(&"receipt".to_string()));
assert!(invoice_suggestions.contains(&"billing".to_string()));
let contract_suggestions = generate_search_suggestions("contract");
assert!(contract_suggestions.contains(&"agreement".to_string()));
assert!(contract_suggestions.contains(&"legal".to_string()));
}
#[test]
fn test_search_suggestions_tag_prefix() {
let suggestions = generate_search_suggestions("tag:important");
// Should not add tag: prefix if already present
assert!(!suggestions.iter().any(|s| s.starts_with("tag:tag:")));
}
#[test]
fn test_search_suggestions_boolean_operators() {
let suggestions = generate_search_suggestions("document AND file");
// Should not add Boolean operators if already present
// Fixed: Check for suggestions that contain multiple AND operators
assert!(!suggestions.iter().any(|s| s.matches(" AND ").count() > 1));
}
#[test]
fn test_search_suggestions_document_replacement() {
@ -472,8 +535,8 @@ mod tests {
fn test_search_suggestions_limit() {
let suggestions = generate_search_suggestions("document test example");
// Should limit to 5 suggestions
assert!(suggestions.len() <= 5);
// Should limit to 6 suggestions (updated limit)
assert!(suggestions.len() <= 6);
}
#[test]