diff --git a/Cargo.lock b/Cargo.lock
index f1a90c5..a2e6f43 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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",
+]
diff --git a/frontend/src/components/GlobalSearchBar/__tests__/GlobalSearchBar.test.jsx b/frontend/src/components/GlobalSearchBar/__tests__/GlobalSearchBar.test.jsx
index 8e088a1..6e0794f 100644
--- a/frontend/src/components/GlobalSearchBar/__tests__/GlobalSearchBar.test.jsx
+++ b/frontend/src/components/GlobalSearchBar/__tests__/GlobalSearchBar.test.jsx
@@ -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();
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();
+
+ 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();
+
+ 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();
+
+ 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');
+ });
});
\ No newline at end of file
diff --git a/frontend/src/components/SearchGuidance/__tests__/SearchGuidance.test.jsx b/frontend/src/components/SearchGuidance/__tests__/SearchGuidance.test.jsx
new file mode 100644
index 0000000..c721878
--- /dev/null
+++ b/frontend/src/components/SearchGuidance/__tests__/SearchGuidance.test.jsx
@@ -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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ // 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();
+
+ // 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();
+
+ // 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();
+
+ // 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();
+
+ 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();
+
+ 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();
+
+ // 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();
+
+ // 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();
+
+ 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();
+
+ const component = screen.getByTestId('search-guidance');
+ expect(component).toBeInTheDocument();
+ });
+
+ test('provides helpful descriptions for each search example', async () => {
+ const user = userEvent.setup();
+ render();
+
+ // 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();
+
+ // 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();
+
+ // 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();
+
+ 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();
+
+ // 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');
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/pages/__tests__/SearchPage.test.jsx b/frontend/src/pages/__tests__/SearchPage.test.jsx
index ba9b336..60578e9 100644
--- a/frontend/src/pages/__tests__/SearchPage.test.jsx
+++ b/frontend/src/pages/__tests__/SearchPage.test.jsx
@@ -14,6 +14,20 @@ jest.mock('../../services/api', () => ({
}
}));
+// Mock SearchGuidance component
+jest.mock('../../components/SearchGuidance', () => {
+ return function MockSearchGuidance({ onExampleClick, compact }) {
+ return (
+
+
+ {compact && Compact Mode}
+
+ );
+ };
+});
+
// 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();
+ 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();
@@ -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();
+
+ 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();
@@ -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();
@@ -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();
+
+ 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();
@@ -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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ // 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();
+
+ 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();
+ });
});
});
\ No newline at end of file
diff --git a/src/models.rs b/src/models.rs
index edad307..78bc8ab 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -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>,
diff --git a/src/routes/auth.rs b/src/routes/auth.rs
index e6e945e..39bdc47 100644
--- a/src/routes/auth.rs
+++ b/src/routes/auth.rs
@@ -6,7 +6,6 @@ use axum::{
Router,
};
use std::sync::Arc;
-use utoipa::path;
use crate::{
auth::{create_jwt, AuthUser},
diff --git a/src/routes/documents.rs b/src/routes/documents.rs
index b23c826..dd0288e 100644
--- a/src/routes/documents.rs
+++ b/src/routes/documents.rs
@@ -7,7 +7,7 @@ use axum::{
};
use serde::Deserialize;
use std::sync::Arc;
-use utoipa::{path, ToSchema};
+use utoipa::ToSchema;
use crate::{
auth::AuthUser,
diff --git a/src/routes/queue.rs b/src/routes/queue.rs
index 4239d55..a757bf6 100644
--- a/src/routes/queue.rs
+++ b/src/routes/queue.rs
@@ -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> {
.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>,
_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>,
_auth_user: AuthUser, // Require authentication
diff --git a/src/routes/search.rs b/src/routes/search.rs
index a70676d..f19858d 100644
--- a/src/routes/search.rs
+++ b/src/routes/search.rs
@@ -6,7 +6,6 @@ use axum::{
Router,
};
use std::sync::Arc;
-use utoipa::path;
use crate::{
auth::AuthUser,
diff --git a/src/routes/settings.rs b/src/routes/settings.rs
index f49626b..fe4fa3c 100644
--- a/src/routes/settings.rs
+++ b/src/routes/settings.rs
@@ -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> {
.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>,
@@ -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>,
diff --git a/src/routes/users.rs b/src/routes/users.rs
index d08401a..96de24d 100644
--- a/src/routes/users.rs
+++ b/src/routes/users.rs
@@ -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> {
.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),
+ (status = 401, description = "Unauthorized")
+ )
+)]
async fn list_users(
_auth_user: AuthUser,
State(state): State>,
@@ -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>,
@@ -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>,
@@ -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>,
@@ -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>,
diff --git a/src/swagger.rs b/src/swagger.rs
index b62468e..396417e 100644
--- a/src/swagger.rs
+++ b/src/swagger.rs
@@ -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> {
SwaggerUi::new("/swagger-ui")
.url("/api-docs/openapi.json", ApiDoc::openapi())
+ .into()
}
\ No newline at end of file
diff --git a/src/tests/enhanced_search_tests.rs b/src/tests/enhanced_search_tests.rs
index a582974..a6b35b9 100644
--- a/src/tests/enhanced_search_tests.rs
+++ b/src/tests/enhanced_search_tests.rs
@@ -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 {
- // 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]