feat(dev): let's just implement an entire error system while we're at it, since the tests are already broken

This commit is contained in:
perf3ct 2025-07-20 03:38:13 +00:00
parent 45ec99a031
commit aac32ea64a
28 changed files with 4068 additions and 103 deletions

618
docs/dev/ERROR_SYSTEM.md Normal file
View File

@ -0,0 +1,618 @@
# Error System Documentation
This document describes the comprehensive error handling system implemented in readur for consistent, maintainable, and user-friendly error responses.
## Overview
The error system provides structured, typed error handling across all API endpoints with automatic integration into the existing error management and monitoring infrastructure.
## Architecture
### Core Components
1. **`src/errors/mod.rs`** - Central error module with shared traits and utilities
2. **Entity-specific error modules** - Dedicated error types for each domain:
- `src/errors/user.rs` - User management errors
- `src/errors/source.rs` - File source operation errors
- `src/errors/label.rs` - Label management errors
- `src/errors/settings.rs` - Settings configuration errors
- `src/errors/search.rs` - Search operation errors
### AppError Trait
All custom errors implement the `AppError` trait which provides:
```rust
pub trait AppError: std::error::Error + Send + Sync + 'static {
fn status_code(&self) -> StatusCode; // HTTP status code
fn user_message(&self) -> String; // User-friendly message
fn error_code(&self) -> &'static str; // Frontend error code
fn error_category(&self) -> ErrorCategory; // Error categorization
fn error_severity(&self) -> ErrorSeverity; // Severity level
fn suppression_key(&self) -> Option<String>; // For repeated error handling
fn suggested_action(&self) -> Option<String>; // Recovery suggestions
}
```
## Error Type Reference
The following table provides a comprehensive overview of all error types, when to use them, and their characteristics:
| Error Type | Module | When to Use | Common Scenarios | Example Error Codes | HTTP Status |
|------------|--------|-------------|------------------|-------------------|-------------|
| **ApiError** | `errors::mod` | Generic API errors when specific types don't apply | Rate limiting, payload validation, generic server errors | `BAD_REQUEST`, `NOT_FOUND`, `UNAUTHORIZED`, `INTERNAL_SERVER_ERROR` | 400, 401, 404, 500 |
| **UserError** | `errors::user` | User management and authentication operations | Registration, login, profile updates, permissions | `USER_NOT_FOUND`, `USER_DUPLICATE_USERNAME`, `USER_PERMISSION_DENIED` | 400, 401, 403, 404, 409 |
| **SourceError** | `errors::source` | File source operations (WebDAV, S3, Local Folder) | Source configuration, sync operations, connection issues | `SOURCE_CONNECTION_FAILED`, `SOURCE_AUTH_FAILED`, `SOURCE_SYNC_IN_PROGRESS` | 400, 401, 404, 409, 503 |
| **LabelError** | `errors::label` | Document labeling and label management | Creating/editing labels, label assignment, system labels | `LABEL_DUPLICATE_NAME`, `LABEL_IN_USE`, `LABEL_SYSTEM_MODIFICATION` | 400, 403, 404, 409 |
| **SettingsError** | `errors::settings` | Application and user settings validation | OCR configuration, preferences, validation | `SETTINGS_INVALID_LANGUAGE`, `SETTINGS_VALUE_OUT_OF_RANGE`, `SETTINGS_READ_ONLY` | 400, 403, 404 |
| **SearchError** | `errors::search` | Document search and indexing operations | Search queries, index operations, result processing | `SEARCH_QUERY_TOO_SHORT`, `SEARCH_INDEX_UNAVAILABLE`, `SEARCH_TOO_MANY_RESULTS` | 400, 503, 429 |
| **OcrError** | `ocr::error` | OCR processing operations | Tesseract operations, image processing, language detection | `OCR_NOT_INSTALLED`, `OCR_LANG_MISSING`, `OCR_TIMEOUT` | 400, 500, 503 |
| **DocumentError** | `routes::documents::crud` | Document CRUD operations | File upload, download, metadata operations | `DOCUMENT_NOT_FOUND`, `DOCUMENT_TOO_LARGE` | 400, 404, 413, 500 |
### Error Type Usage Guidelines
#### **ApiError** - Generic API Errors
- **Use when**: No specific error type applies
- **Best for**: Cross-cutting concerns, middleware errors, generic validation
- **Avoid when**: Domain-specific operations have dedicated error types
- **Example**: Request rate limiting, malformed JSON, generic server failures
#### **UserError** - User Management
- **Use when**: Operations involve user accounts, authentication, or authorization
- **Best for**: Registration, login, profile management, role/permission checks
- **Covers**: Account creation, credential validation, access control
- **Example**: Duplicate usernames, invalid passwords, insufficient permissions
#### **SourceError** - File Sources
- **Use when**: Operations involve external file sources (WebDAV, S3, etc.)
- **Best for**: Source configuration, sync operations, connection management
- **Covers**: Authentication to external systems, network connectivity, sync status
- **Example**: WebDAV connection failures, S3 credential issues, sync conflicts
#### **LabelError** - Label Management
- **Use when**: Operations involve document labels or label management
- **Best for**: Label CRUD operations, label assignment to documents
- **Covers**: Label validation, system label protection, usage tracking
- **Example**: Duplicate label names, modifying system labels, deleting used labels
#### **SettingsError** - Configuration & Settings
- **Use when**: Operations involve application or user settings
- **Best for**: Configuration validation, preference management, OCR settings
- **Covers**: Value validation, constraint checking, read-only setting protection
- **Example**: Invalid OCR languages, out-of-range values, conflicting settings
#### **SearchError** - Search Operations
- **Use when**: Operations involve document search or search index management
- **Best for**: Query validation, index operations, result processing
- **Covers**: Query syntax, performance limits, index availability
- **Example**: Short queries, syntax errors, index rebuilding, too many results
#### **OcrError** - OCR Processing
- **Use when**: Operations involve OCR processing with Tesseract
- **Best for**: OCR-specific failures, Tesseract configuration issues
- **Covers**: Installation checks, language data, processing errors
- **Example**: Missing Tesseract, language data not found, processing timeouts
#### **DocumentError** - Document Operations
- **Use when**: Operations involve document CRUD operations
- **Best for**: File upload/download, document metadata, storage operations
- **Covers**: File validation, storage limits, document lifecycle
- **Example**: File too large, unsupported format, document not found
### Error Severity Mapping
| Error Type | Typical Severity | Reasoning |
|------------|------------------|-----------|
| **ApiError** | Minor to Critical | Depends on specific error - server errors are Critical, validation is Minor |
| **UserError** | Minor to Important | Auth failures are Important, validation errors are Minor |
| **SourceError** | Minor to Important | Connection issues are Important, config errors are Minor |
| **LabelError** | Minor | Usually user input validation, rarely system-critical |
| **SettingsError** | Minor | Configuration errors, typically user-correctable |
| **SearchError** | Minor to Important | Index unavailable is Important, query errors are Minor |
| **OcrError** | Minor to Critical | Missing installation is Critical, processing errors are Minor |
| **DocumentError** | Minor to Important | Storage failures are Important, validation is Minor |
### Error Type Decision Tree
Use this decision tree to choose the appropriate error type:
```
Is this a user account/authentication operation?
├─ YES → UserError
└─ NO → Is this a file source operation (WebDAV, S3, Local)?
├─ YES → SourceError
└─ NO → Is this a search/indexing operation?
├─ YES → SearchError
└─ NO → Is this an OCR/Tesseract operation?
├─ YES → OcrError
└─ NO → Is this a document upload/download/CRUD operation?
├─ YES → DocumentError
└─ NO → Is this a label management operation?
├─ YES → LabelError
└─ NO → Is this a settings/configuration operation?
├─ YES → SettingsError
└─ NO → Use ApiError for generic cases
```
### Quick Reference Checklist
**Before choosing an error type, ask:**
- [ ] Does the operation primarily involve user accounts, roles, or authentication? → **UserError**
- [ ] Does the operation connect to external file sources? → **SourceError**
- [ ] Does the operation search documents or manage search index? → **SearchError**
- [ ] Does the operation use Tesseract for OCR processing? → **OcrError**
- [ ] Does the operation upload, download, or manage document files? → **DocumentError**
- [ ] Does the operation create, modify, or assign labels? → **LabelError**
- [ ] Does the operation validate or modify application settings? → **SettingsError**
- [ ] None of the above apply? → **ApiError**
### Common Error Mapping Patterns
| Operation Type | Recommended Error Type | Example Operations |
|----------------|----------------------|-------------------|
| User registration/login | UserError | `/api/auth/register`, `/api/auth/login` |
| File source sync | SourceError | `/api/sources/{id}/sync`, `/api/sources/{id}/test` |
| Document search | SearchError | `/api/search`, `/api/documents/search` |
| OCR processing | OcrError | `/api/documents/{id}/ocr`, `/api/ocr/languages` |
| Document management | DocumentError | `/api/documents`, `/api/documents/{id}` |
| Label operations | LabelError | `/api/labels`, `/api/documents/{id}/labels` |
| Settings management | SettingsError | `/api/settings`, `/api/users/{id}/settings` |
| Generic API operations | ApiError | Rate limiting, payload validation, CORS |
## Error Types
### UserError
Handles user management operations:
```rust
// Examples
UserError::NotFound
UserError::DuplicateUsername { username: "john_doe" }
UserError::PermissionDenied { reason: "Admin access required" }
UserError::InvalidCredentials
UserError::DeleteRestricted { id, reason: "Cannot delete your own account" }
```
**Error Codes**: `USER_NOT_FOUND`, `USER_DUPLICATE_USERNAME`, `USER_PERMISSION_DENIED`, etc.
### SourceError
Handles file source operations (WebDAV, Local Folder, S3):
```rust
// Examples
SourceError::ConnectionFailed { details: "Network timeout" }
SourceError::InvalidPath { path: "/invalid/path" }
SourceError::AuthenticationFailed { name: "my-webdav", reason: "Invalid credentials" }
SourceError::SyncInProgress { name: "backup-source" }
SourceError::ConfigurationInvalid { details: "Missing server URL" }
```
**Error Codes**: `SOURCE_CONNECTION_FAILED`, `SOURCE_AUTH_FAILED`, `SOURCE_CONFIG_INVALID`, etc.
### LabelError
Handles label management:
```rust
// Examples
LabelError::DuplicateName { name: "Important" }
LabelError::SystemLabelModification { name: "system-label" }
LabelError::InvalidColor { color: "#gggggg" }
LabelError::LabelInUse { document_count: 42 }
LabelError::MaxLabelsReached { max_labels: 100 }
```
**Error Codes**: `LABEL_DUPLICATE_NAME`, `LABEL_SYSTEM_MODIFICATION`, `LABEL_IN_USE`, etc.
### SettingsError
Handles settings validation and management:
```rust
// Examples
SettingsError::InvalidLanguage { language: "xx", available_languages: "en,es,fr" }
SettingsError::ValueOutOfRange { setting_name: "timeout", value: 3600, min: 1, max: 300 }
SettingsError::InvalidOcrConfiguration { details: "DPI too high" }
SettingsError::ConflictingSettings { setting1: "auto_detect", setting2: "fixed_language" }
```
**Error Codes**: `SETTINGS_INVALID_LANGUAGE`, `SETTINGS_VALUE_OUT_OF_RANGE`, etc.
### SearchError
Handles search operations:
```rust
// Examples
SearchError::QueryTooShort { length: 1, min_length: 2 }
SearchError::TooManyResults { result_count: 15000, max_results: 10000 }
SearchError::IndexUnavailable { reason: "Rebuilding index" }
SearchError::InvalidPagination { offset: -1, limit: 0 }
```
**Error Codes**: `SEARCH_QUERY_TOO_SHORT`, `SEARCH_TOO_MANY_RESULTS`, etc.
## Integration Features
### Error Management System
All errors automatically integrate with the sophisticated error management system in `src/monitoring/error_management.rs`:
- **Categorization**: Errors are categorized (Auth, Database, Network, etc.)
- **Severity Levels**: Critical, Important, Minor, Expected
- **Intelligent Suppression**: Prevents spam from repeated errors
- **Structured Logging**: Consistent log format with context
### HTTP Response Format
All errors return consistent JSON responses:
```json
{
"error": "User-friendly error message",
"code": "ERROR_CODE_FOR_FRONTEND",
"status": 400
}
```
### Frontend Error Codes
Each error provides a stable error code for frontend handling:
```typescript
// Frontend can handle specific errors
switch (error.code) {
case 'USER_DUPLICATE_USERNAME':
showUsernameAlreadyExistsMessage();
break;
case 'SOURCE_CONNECTION_FAILED':
showNetworkErrorDialog();
break;
case 'SEARCH_QUERY_TOO_SHORT':
highlightSearchInput();
break;
}
```
## Usage Examples
### In Route Handlers
```rust
use crate::errors::user::UserError;
async fn create_user(
auth_user: AuthUser,
State(state): State<Arc<AppState>>,
Json(user_data): Json<CreateUser>,
) -> Result<Json<UserResponse>, UserError> {
require_admin(&auth_user)?;
let user = state
.db
.create_user(user_data)
.await
.map_err(|e| {
let error_msg = e.to_string();
if error_msg.contains("username") && error_msg.contains("unique") {
UserError::duplicate_username(&user_data.username)
} else if error_msg.contains("email") && error_msg.contains("unique") {
UserError::duplicate_email(&user_data.email)
} else {
UserError::internal_server_error(format!("Database error: {}", e))
}
})?;
Ok(Json(user.into()))
}
```
### Convenience Methods
All error types provide convenience constructors:
```rust
// Instead of verbose enum construction
UserError::DuplicateUsername { username: username.clone() }
// Use convenience methods
UserError::duplicate_username(&username)
UserError::permission_denied("Admin access required")
UserError::not_found_by_id(user_id)
```
### Error Context and Suggestions
Errors include contextual information and recovery suggestions:
```rust
LabelError::invalid_color("#gggggg")
// Returns: "Invalid color format - use hex format like #0969da"
// Suggestion: "Use a valid hex color format like #0969da or #ff5722"
SourceError::rate_limit_exceeded("my-source", 300)
// Returns: "Rate limit exceeded, try again in 300 seconds"
// Suggestion: "Wait 300 seconds before retrying"
```
## Best Practices
### 1. Use Specific Error Types
```rust
// Good
return Err(UserError::duplicate_username(&username));
// Avoid
return Err(UserError::BadRequest { message: "Username exists".to_string() });
```
### 2. Provide Context
```rust
// Good
SourceError::connection_failed(format!("Failed to connect to {}: {}", url, e))
// Less helpful
SourceError::connection_failed("Connection failed")
```
### 3. Handle Database Errors Thoughtfully
```rust
.map_err(|e| {
let error_msg = e.to_string();
if error_msg.contains("unique constraint") {
UserError::duplicate_username(&username)
} else if error_msg.contains("not found") {
UserError::not_found()
} else {
UserError::internal_server_error(format!("Database error: {}", e))
}
})?
```
### 4. Use Suppression Keys Wisely
```rust
impl AppError for SearchError {
fn suppression_key(&self) -> Option<String> {
match self {
// Suppress repeated "no results" errors
SearchError::NoResults => Some("search_no_results".to_string()),
// Don't suppress validation errors - users need to see them
SearchError::QueryTooShort { .. } => None,
// Suppress by specific source for connection errors
SearchError::IndexUnavailable { .. } => Some("search_index_unavailable".to_string()),
_ => None,
}
}
}
```
## Migration from Generic Errors
When updating existing endpoints:
1. **Add error type import**:
```rust
use crate::errors::user::UserError;
```
2. **Update function signature**:
```rust
// Before
async fn my_handler() -> Result<Json<Response>, StatusCode>
// After
async fn my_handler() -> Result<Json<Response>, UserError>
```
3. **Replace generic error mapping**:
```rust
// Before
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
// After
.map_err(|e| UserError::internal_server_error(format!("Operation failed: {}", e)))?
```
4. **Update tests** to expect new error format:
```rust
// Before
assert_eq!(response.status(), StatusCode::CONFLICT);
// After - check JSON response
let error: serde_json::Value = response.json().await;
assert_eq!(error["code"], "USER_DELETE_RESTRICTED");
```
## Testing
The error system includes comprehensive testing to ensure:
- Correct HTTP status codes
- Proper JSON response format
- Error code consistency
- Integration with error management
- Suppression behavior
Run error-specific tests:
```bash
cargo test user_error
cargo test source_error
cargo test error_integration
```
## Error Code Conventions
All error types follow consistent naming conventions for error codes:
### Naming Pattern
```
{TYPE}_{SPECIFIC_ERROR}
```
### Examples by Type
| Error Type | Prefix | Example Codes |
|------------|--------|---------------|
| **ApiError** | None | `BAD_REQUEST`, `NOT_FOUND`, `UNAUTHORIZED` |
| **UserError** | `USER_` | `USER_NOT_FOUND`, `USER_DUPLICATE_USERNAME`, `USER_PERMISSION_DENIED` |
| **SourceError** | `SOURCE_` | `SOURCE_CONNECTION_FAILED`, `SOURCE_AUTH_FAILED`, `SOURCE_CONFIG_INVALID` |
| **LabelError** | `LABEL_` | `LABEL_DUPLICATE_NAME`, `LABEL_IN_USE`, `LABEL_SYSTEM_MODIFICATION` |
| **SettingsError** | `SETTINGS_` | `SETTINGS_INVALID_LANGUAGE`, `SETTINGS_VALUE_OUT_OF_RANGE` |
| **SearchError** | `SEARCH_` | `SEARCH_QUERY_TOO_SHORT`, `SEARCH_INDEX_UNAVAILABLE` |
| **OcrError** | `OCR_` | `OCR_NOT_INSTALLED`, `OCR_LANG_MISSING`, `OCR_TIMEOUT` |
| **DocumentError** | `DOCUMENT_` | `DOCUMENT_NOT_FOUND`, `DOCUMENT_TOO_LARGE` |
### Code Style Guidelines
- Use `SCREAMING_SNAKE_CASE` for all error codes
- Start with error type prefix (except ApiError)
- Be descriptive but concise
- Avoid abbreviations unless commonly understood
- Group related errors with consistent sub-prefixes
## Practical Examples
### Complete Implementation Examples
#### User Registration Endpoint
```rust
use crate::errors::user::UserError;
async fn register_user(
State(state): State<Arc<AppState>>,
Json(user_data): Json<CreateUser>,
) -> Result<Json<UserResponse>, UserError> {
// Validate input
if user_data.username.len() < 3 {
return Err(UserError::invalid_username(
&user_data.username,
"Username must be at least 3 characters"
));
}
// Create user
let user = state.db.create_user(user_data).await
.map_err(|e| {
let error_msg = e.to_string();
if error_msg.contains("username") && error_msg.contains("unique") {
UserError::duplicate_username(&user_data.username)
} else if error_msg.contains("email") && error_msg.contains("unique") {
UserError::duplicate_email(&user_data.email)
} else {
UserError::internal_server_error(format!("Database error: {}", e))
}
})?;
Ok(Json(user.into()))
}
```
#### WebDAV Source Test Endpoint
```rust
use crate::errors::source::SourceError;
async fn test_webdav_connection(
State(state): State<Arc<AppState>>,
Path(source_id): Path<Uuid>,
auth_user: AuthUser,
) -> Result<Json<ConnectionTestResponse>, SourceError> {
let source = state.db.get_source(source_id).await
.map_err(|_| SourceError::not_found_by_id(source_id))?;
// Check ownership
if source.user_id != auth_user.user.id {
return Err(SourceError::permission_denied(
"You can only test your own sources"
));
}
// Test connection
match test_webdav_connection_internal(&source).await {
Ok(()) => Ok(Json(ConnectionTestResponse { success: true })),
Err(e) if e.to_string().contains("authentication") => {
Err(SourceError::authentication_failed(&source.name, &e.to_string()))
},
Err(e) if e.to_string().contains("timeout") => {
Err(SourceError::network_timeout(&source.url, 30))
},
Err(e) => {
Err(SourceError::connection_failed(format!("Connection test failed: {}", e)))
}
}
}
```
#### Search Query Endpoint
```rust
use crate::errors::search::SearchError;
async fn search_documents(
State(state): State<Arc<AppState>>,
Query(params): Query<SearchParams>,
auth_user: AuthUser,
) -> Result<Json<SearchResponse>, SearchError> {
// Validate query length
if params.query.len() < 2 {
return Err(SearchError::query_too_short(params.query.len(), 2));
}
if params.query.len() > 500 {
return Err(SearchError::query_too_long(params.query.len(), 500));
}
// Check pagination
if params.limit > 1000 {
return Err(SearchError::invalid_pagination(params.offset, params.limit));
}
// Perform search
let results = state.search_service.search(&params, auth_user.user.id).await
.map_err(|e| match e {
SearchServiceError::IndexUnavailable => {
SearchError::index_unavailable("Search index is being rebuilt")
},
SearchServiceError::TooManyResults(count) => {
SearchError::too_many_results(count, 10000)
},
SearchServiceError::InvalidSyntax(details) => {
SearchError::invalid_syntax(details)
},
_ => SearchError::internal_error(format!("Search failed: {}", e)),
})?;
Ok(Json(results))
}
```
## Monitoring
Error metrics are automatically tracked:
- **Error rates** by type and endpoint
- **Suppression statistics** for repeated errors
- **Severity distribution** across the application
- **Recovery suggestions** utilization
View error dashboards in Grafana or check Prometheus metrics at `/metrics`.
## Future Enhancements
Planned improvements to the error system:
1. **Internationalization** - Multi-language error messages
2. **Error Analytics** - Advanced error pattern detection
3. **Auto-Recovery** - Suggested API retry strategies
4. **Enhanced Suppression** - Time-based and pattern-based suppression
5. **Error Documentation** - Auto-generated API error documentation
## References
- [Error Management Documentation](./ERROR_MANAGEMENT.md)
- [API Error Response Standards](../api-reference.md#error-responses)
- [Frontend Error Handling Guide](../../frontend/ERROR_HANDLING.md)
- [Monitoring and Observability](./MONITORING.md)

View File

@ -26,7 +26,7 @@ import { useAuth } from '../../contexts/AuthContext';
import { useNavigate } from 'react-router-dom';
import { useTheme } from '../../contexts/ThemeContext';
import { useTheme as useMuiTheme } from '@mui/material/styles';
import { api } from '../../services/api';
import { api, ErrorHelper, ErrorCodes } from '../../services/api';
interface LoginFormData {
username: string;
@ -56,7 +56,27 @@ const Login: React.FC = () => {
await login(data.username, data.password);
navigate('/dashboard');
} catch (err) {
setError('Failed to log in. Please check your credentials.');
console.error('Login failed:', err);
const errorInfo = ErrorHelper.formatErrorForDisplay(err, true);
// Handle specific login errors
if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_INVALID_CREDENTIALS)) {
setError('Invalid username or password. Please check your credentials and try again.');
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_ACCOUNT_DISABLED)) {
setError('Your account has been disabled. Please contact an administrator for assistance.');
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_NOT_FOUND)) {
setError('No account found with this username. Please check your username or contact support.');
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_SESSION_EXPIRED) ||
ErrorHelper.isErrorCode(err, ErrorCodes.USER_TOKEN_EXPIRED)) {
setError('Your session has expired. Please try logging in again.');
} else if (errorInfo.category === 'network') {
setError('Network error. Please check your connection and try again.');
} else if (errorInfo.category === 'server') {
setError('Server error. Please try again later or contact support if the problem persists.');
} else {
setError(errorInfo.message || 'Failed to log in. Please check your credentials.');
}
} finally {
setLoading(false);
}
@ -73,7 +93,20 @@ const Login: React.FC = () => {
// Redirect to OIDC login endpoint
window.location.href = '/api/auth/oidc/login';
} catch (err) {
setError('Failed to initiate OIDC login. Please try again.');
console.error('OIDC login failed:', err);
const errorInfo = ErrorHelper.formatErrorForDisplay(err, true);
// Handle specific OIDC errors
if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_OIDC_AUTH_FAILED)) {
setError('OIDC authentication failed. Please check with your administrator.');
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_AUTH_PROVIDER_NOT_CONFIGURED)) {
setError('OIDC is not configured on this server. Please use username/password login.');
} else if (errorInfo.category === 'network') {
setError('Network error. Please check your connection and try again.');
} else {
setError(errorInfo.message || 'Failed to initiate OIDC login. Please try again.');
}
setOidcLoading(false);
}
};

View File

@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Box, CircularProgress, Typography, Alert, Container } from '@mui/material';
import { useAuth } from '../../contexts/AuthContext';
import { api } from '../../services/api';
import { api, ErrorHelper, ErrorCodes } from '../../services/api';
const OidcCallback: React.FC = () => {
const [searchParams] = useSearchParams();
@ -46,11 +46,29 @@ const OidcCallback: React.FC = () => {
}
} catch (err: any) {
console.error('OIDC callback error:', err);
setError(
err.response?.data?.error ||
err.message ||
'Failed to complete authentication'
);
const errorInfo = ErrorHelper.formatErrorForDisplay(err, true);
// Handle specific OIDC callback errors
if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_OIDC_AUTH_FAILED)) {
setError('OIDC authentication failed. Please try logging in again or contact your administrator.');
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_AUTH_PROVIDER_NOT_CONFIGURED)) {
setError('OIDC is not configured on this server. Please use username/password login.');
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_INVALID_CREDENTIALS)) {
setError('Authentication failed. Your OIDC credentials may be invalid or expired.');
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_ACCOUNT_DISABLED)) {
setError('Your account has been disabled. Please contact an administrator for assistance.');
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_SESSION_EXPIRED) ||
ErrorHelper.isErrorCode(err, ErrorCodes.USER_TOKEN_EXPIRED)) {
setError('Authentication session expired. Please try logging in again.');
} else if (errorInfo.category === 'network') {
setError('Network error during authentication. Please check your connection and try again.');
} else if (errorInfo.category === 'server') {
setError('Server error during authentication. Please try again later or contact support.');
} else {
setError(errorInfo.message || 'Failed to complete authentication. Please try again.');
}
setProcessing(false);
}
};

View File

@ -32,7 +32,7 @@ import {
Assessment as AssessmentIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material';
import { documentService, BulkOcrRetryRequest, OcrRetryFilter, BulkOcrRetryResponse } from '../services/api';
import { documentService, BulkOcrRetryRequest, OcrRetryFilter, BulkOcrRetryResponse, ErrorHelper, ErrorCodes } from '../services/api';
interface BulkRetryModalProps {
open: boolean;
@ -146,7 +146,26 @@ export const BulkRetryModal: React.FC<BulkRetryModalProps> = ({
const response = await documentService.bulkRetryOcr(request);
setPreviewResult(response.data);
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to preview retry operation');
const errorInfo = ErrorHelper.formatErrorForDisplay(err, true);
let errorMessage = 'Failed to preview retry operation';
// Handle specific bulk retry preview errors
if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_SESSION_EXPIRED) ||
ErrorHelper.isErrorCode(err, ErrorCodes.USER_TOKEN_EXPIRED)) {
errorMessage = 'Your session has expired. Please refresh the page and log in again.';
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_PERMISSION_DENIED)) {
errorMessage = 'You do not have permission to preview retry operations.';
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.DOCUMENT_NOT_FOUND)) {
errorMessage = 'No documents found matching the specified criteria.';
} else if (errorInfo.category === 'server') {
errorMessage = 'Server error. Please try again later.';
} else if (errorInfo.category === 'network') {
errorMessage = 'Network error. Please check your connection and try again.';
} else {
errorMessage = errorInfo.message || 'Failed to preview retry operation';
}
setError(errorMessage);
setPreviewResult(null);
} finally {
setLoading(false);
@ -162,7 +181,28 @@ export const BulkRetryModal: React.FC<BulkRetryModalProps> = ({
onSuccess(response.data);
onClose();
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to execute retry operation');
const errorInfo = ErrorHelper.formatErrorForDisplay(err, true);
let errorMessage = 'Failed to execute retry operation';
// Handle specific bulk retry execution errors
if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_SESSION_EXPIRED) ||
ErrorHelper.isErrorCode(err, ErrorCodes.USER_TOKEN_EXPIRED)) {
errorMessage = 'Your session has expired. Please refresh the page and log in again.';
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_PERMISSION_DENIED)) {
errorMessage = 'You do not have permission to execute retry operations.';
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.DOCUMENT_NOT_FOUND)) {
errorMessage = 'No documents found matching the specified criteria.';
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.DOCUMENT_PROCESSING_FAILED)) {
errorMessage = 'Some documents cannot be retried due to processing issues.';
} else if (errorInfo.category === 'server') {
errorMessage = 'Server error. Please try again later or contact support.';
} else if (errorInfo.category === 'network') {
errorMessage = 'Network error. Please check your connection and try again.';
} else {
errorMessage = errorInfo.message || 'Failed to execute retry operation';
}
setError(errorMessage);
} finally {
setLoading(false);
}

View File

@ -14,7 +14,7 @@ import {
import { Refresh as RefreshIcon, Language as LanguageIcon } from '@mui/icons-material';
import OcrLanguageSelector from '../OcrLanguageSelector';
import LanguageSelector from '../LanguageSelector';
import { ocrService } from '../../services/api';
import { ocrService, ErrorHelper, ErrorCodes } from '../../services/api';
interface OcrRetryDialogProps {
open: boolean;
@ -139,9 +139,35 @@ const OcrRetryDialog: React.FC<OcrRetryDialogProps> = ({
}
} catch (error: any) {
console.error('Failed to retry OCR:', error);
onRetryError(
error.response?.data?.message || 'Failed to retry OCR processing'
);
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
let errorMessage = 'Failed to retry OCR processing';
// Handle specific OCR retry errors
if (ErrorHelper.isErrorCode(error, ErrorCodes.DOCUMENT_NOT_FOUND)) {
errorMessage = 'Document not found. It may have been deleted or processed already.';
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.DOCUMENT_PROCESSING_FAILED)) {
errorMessage = 'Document cannot be retried due to processing issues. Please check the document format.';
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.OCR_NOT_INSTALLED)) {
errorMessage = 'OCR engine is not installed or configured. Please contact your administrator.';
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.OCR_LANG_MISSING)) {
errorMessage = 'Selected OCR language is not available. Please choose a different language or contact your administrator.';
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.OCR_TIMEOUT)) {
errorMessage = 'OCR processing timed out. The document may be too large or complex.';
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_SESSION_EXPIRED) ||
ErrorHelper.isErrorCode(error, ErrorCodes.USER_TOKEN_EXPIRED)) {
errorMessage = 'Your session has expired. Please refresh the page and log in again.';
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_PERMISSION_DENIED)) {
errorMessage = 'You do not have permission to retry OCR processing.';
} else if (errorInfo.category === 'server') {
errorMessage = 'Server error. Please try again later or contact support.';
} else if (errorInfo.category === 'network') {
errorMessage = 'Network error. Please check your connection and try again.';
} else {
errorMessage = errorInfo.message || 'Failed to retry OCR processing';
}
onRetryError(errorMessage);
} finally {
setRetrying(false);
}

View File

@ -29,7 +29,7 @@ import {
} from '@mui/icons-material';
import { useDropzone, FileRejection, DropzoneOptions } from 'react-dropzone';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { api, ErrorHelper, ErrorCodes } from '../../services/api';
import { useNotifications } from '../../contexts/NotificationContext';
import LabelSelector from '../Labels/LabelSelector';
import { type LabelData } from '../Labels/Label';
@ -88,6 +88,21 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
}
} catch (error) {
console.error('Failed to fetch labels:', error);
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
// Handle specific label fetch errors
if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_SESSION_EXPIRED) ||
ErrorHelper.isErrorCode(error, ErrorCodes.USER_TOKEN_EXPIRED)) {
setError('Your session has expired. Please refresh the page and log in again.');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_PERMISSION_DENIED)) {
setError('You do not have permission to access labels.');
} else if (errorInfo.category === 'network') {
setError('Network error loading labels. Please check your connection.');
} else {
// Don't show error for label loading failures as it's not critical
console.warn('Label loading failed:', errorInfo.message);
}
} finally {
setLabelsLoading(false);
}
@ -101,7 +116,21 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
return newLabel;
} catch (error) {
console.error('Failed to create label:', error);
throw error;
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
// Handle specific label creation errors
if (ErrorHelper.isErrorCode(error, ErrorCodes.LABEL_DUPLICATE_NAME)) {
throw new Error('A label with this name already exists. Please choose a different name.');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.LABEL_INVALID_NAME)) {
throw new Error('Label name contains invalid characters. Please use only letters, numbers, and basic punctuation.');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.LABEL_INVALID_COLOR)) {
throw new Error('Invalid color format. Please use a valid hex color like #0969da.');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.LABEL_MAX_LABELS_REACHED)) {
throw new Error('Maximum number of labels reached. Please delete some labels before creating new ones.');
} else {
throw new Error(errorInfo.message || 'Failed to create label');
}
}
};
@ -206,12 +235,35 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
onUploadComplete(response.data);
}
} catch (error: any) {
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
let errorMessage = 'Upload failed';
// Handle specific document upload errors
if (ErrorHelper.isErrorCode(error, ErrorCodes.DOCUMENT_TOO_LARGE)) {
errorMessage = 'File is too large. Maximum size is 50MB.';
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.DOCUMENT_INVALID_FORMAT)) {
errorMessage = 'Unsupported file format. Please use PDF, images, text, or Word documents.';
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.DOCUMENT_PROCESSING_FAILED)) {
errorMessage = 'Failed to process document. Please try again or contact support.';
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_SESSION_EXPIRED) ||
ErrorHelper.isErrorCode(error, ErrorCodes.USER_TOKEN_EXPIRED)) {
errorMessage = 'Session expired. Please refresh and log in again.';
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_PERMISSION_DENIED)) {
errorMessage = 'You do not have permission to upload documents.';
} else if (errorInfo.category === 'network') {
errorMessage = 'Network error. Please check your connection and try again.';
} else if (errorInfo.category === 'server') {
errorMessage = 'Server error. Please try again later.';
} else {
errorMessage = errorInfo.message || 'Upload failed';
}
setFiles(prev => prev.map(f =>
f.id === fileItem.id
? {
...f,
status: 'error' as FileStatus,
error: error.response?.data?.message || 'Upload failed',
error: errorMessage,
progress: 0,
}
: f

View File

@ -56,7 +56,7 @@ import {
History as HistoryIcon,
} from '@mui/icons-material';
import { format } from 'date-fns';
import { api, documentService, queueService, BulkOcrRetryResponse } from '../services/api';
import { api, documentService, queueService, BulkOcrRetryResponse, ErrorHelper, ErrorCodes } from '../services/api';
import DocumentViewer from '../components/DocumentViewer';
import FailedDocumentViewer from '../components/FailedDocumentViewer';
import MetadataDisplay from '../components/MetadataDisplay';
@ -257,9 +257,29 @@ const DocumentManagementPage: React.FC = () => {
}
} catch (error) {
console.error('Failed to fetch failed documents:', error);
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
let errorMessage = 'Failed to load failed documents';
// Handle specific document management errors
if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_SESSION_EXPIRED) ||
ErrorHelper.isErrorCode(error, ErrorCodes.USER_TOKEN_EXPIRED)) {
errorMessage = 'Your session has expired. Please refresh the page and log in again.';
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_PERMISSION_DENIED)) {
errorMessage = 'You do not have permission to view failed documents.';
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.DOCUMENT_NOT_FOUND)) {
errorMessage = 'No failed documents found or they may have been processed.';
} else if (errorInfo.category === 'network') {
errorMessage = 'Network error. Please check your connection and try again.';
} else if (errorInfo.category === 'server') {
errorMessage = 'Server error. Please try again later.';
} else {
errorMessage = errorInfo.message || 'Failed to load failed documents';
}
setSnackbar({
open: true,
message: 'Failed to load failed documents',
message: errorMessage,
severity: 'error'
});
} finally {
@ -282,9 +302,27 @@ const DocumentManagementPage: React.FC = () => {
}
} catch (error) {
console.error('Failed to fetch duplicates:', error);
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
let errorMessage = 'Failed to load duplicate documents';
// Handle specific duplicate fetch errors
if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_SESSION_EXPIRED) ||
ErrorHelper.isErrorCode(error, ErrorCodes.USER_TOKEN_EXPIRED)) {
errorMessage = 'Your session has expired. Please refresh the page and log in again.';
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_PERMISSION_DENIED)) {
errorMessage = 'You do not have permission to view duplicate documents.';
} else if (errorInfo.category === 'network') {
errorMessage = 'Network error. Please check your connection and try again.';
} else if (errorInfo.category === 'server') {
errorMessage = 'Server error. Please try again later.';
} else {
errorMessage = errorInfo.message || 'Failed to load duplicate documents';
}
setSnackbar({
open: true,
message: 'Failed to load duplicate documents',
message: errorMessage,
severity: 'error'
});
} finally {
@ -350,9 +388,31 @@ const DocumentManagementPage: React.FC = () => {
}
} catch (error) {
console.error('Failed to retry OCR:', error);
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
let errorMessage = 'Failed to retry OCR processing';
// Handle specific OCR retry errors
if (ErrorHelper.isErrorCode(error, ErrorCodes.DOCUMENT_NOT_FOUND)) {
errorMessage = 'Document not found. It may have been deleted or processed already.';
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.DOCUMENT_PROCESSING_FAILED)) {
errorMessage = 'Document cannot be retried due to processing issues. Please check the document format.';
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_SESSION_EXPIRED) ||
ErrorHelper.isErrorCode(error, ErrorCodes.USER_TOKEN_EXPIRED)) {
errorMessage = 'Your session has expired. Please refresh the page and log in again.';
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_PERMISSION_DENIED)) {
errorMessage = 'You do not have permission to retry OCR processing.';
} else if (errorInfo.category === 'server') {
errorMessage = 'Server error. Please try again later or contact support.';
} else if (errorInfo.category === 'network') {
errorMessage = 'Network error. Please check your connection and try again.';
} else {
errorMessage = errorInfo.message || 'Failed to retry OCR processing';
}
setSnackbar({
open: true,
message: 'Failed to retry OCR processing',
message: errorMessage,
severity: 'error'
});
} finally {
@ -512,9 +572,27 @@ const DocumentManagementPage: React.FC = () => {
}
} catch (error) {
console.error('Failed to fetch ignored files:', error);
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
let errorMessage = 'Failed to load ignored files';
// Handle specific ignored files errors
if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_SESSION_EXPIRED) ||
ErrorHelper.isErrorCode(error, ErrorCodes.USER_TOKEN_EXPIRED)) {
errorMessage = 'Your session has expired. Please refresh the page and log in again.';
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_PERMISSION_DENIED)) {
errorMessage = 'You do not have permission to view ignored files.';
} else if (errorInfo.category === 'network') {
errorMessage = 'Network error. Please check your connection and try again.';
} else if (errorInfo.category === 'server') {
errorMessage = 'Server error. Please try again later.';
} else {
errorMessage = errorInfo.message || 'Failed to load ignored files';
}
setSnackbar({
open: true,
message: 'Failed to load ignored files',
message: errorMessage,
severity: 'error'
});
} finally {

View File

@ -32,6 +32,7 @@ import { useNavigate } from 'react-router-dom';
import Label, { type LabelData } from '../components/Labels/Label';
import LabelCreateDialog from '../components/Labels/LabelCreateDialog';
import { useApi } from '../hooks/useApi';
import { ErrorHelper, ErrorCodes } from '../services/api';
const LabelsPage: React.FC = () => {
const navigate = useNavigate();
@ -69,15 +70,21 @@ const LabelsPage: React.FC = () => {
} catch (error: any) {
console.error('Failed to fetch labels:', error);
// Handle different types of errors more specifically
if (error?.response?.status === 401) {
setError('Authentication required. Please log in again.');
} else if (error?.response?.status === 403) {
setError('Access denied. You do not have permission to view labels.');
} else if (error?.response?.status >= 500) {
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
// Handle specific label errors
if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_SESSION_EXPIRED) ||
ErrorHelper.isErrorCode(error, ErrorCodes.USER_TOKEN_EXPIRED)) {
setError('Your session has expired. Please log in again.');
// Could trigger a redirect to login here
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_PERMISSION_DENIED)) {
setError('You do not have permission to view labels.');
} else if (errorInfo.category === 'server') {
setError('Server error. Please try again later.');
} else if (errorInfo.category === 'network') {
setError('Network error. Please check your connection and try again.');
} else {
setError('Failed to load labels. Please check your connection.');
setError(errorInfo.message || 'Failed to load labels. Please check your connection.');
}
setLabels([]); // Reset to empty array to prevent filter errors
@ -108,7 +115,21 @@ const LabelsPage: React.FC = () => {
await fetchLabels(); // Refresh the list
} catch (error) {
console.error('Failed to create label:', error);
throw error;
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
// Handle specific label creation errors
if (ErrorHelper.isErrorCode(error, ErrorCodes.LABEL_DUPLICATE_NAME)) {
throw new Error('A label with this name already exists. Please choose a different name.');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.LABEL_INVALID_NAME)) {
throw new Error('Label name contains invalid characters. Please use only letters, numbers, and basic punctuation.');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.LABEL_INVALID_COLOR)) {
throw new Error('Invalid color format. Please use a valid hex color like #0969da.');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.LABEL_MAX_LABELS_REACHED)) {
throw new Error('Maximum number of labels reached. Please delete some labels before creating new ones.');
} else {
throw new Error(errorInfo.message || 'Failed to create label');
}
}
};
@ -121,7 +142,23 @@ const LabelsPage: React.FC = () => {
setEditingLabel(null);
} catch (error) {
console.error('Failed to update label:', error);
throw error;
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
// Handle specific label update errors
if (ErrorHelper.isErrorCode(error, ErrorCodes.LABEL_NOT_FOUND)) {
throw new Error('Label not found. It may have been deleted by another user.');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.LABEL_DUPLICATE_NAME)) {
throw new Error('A label with this name already exists. Please choose a different name.');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.LABEL_SYSTEM_MODIFICATION)) {
throw new Error('System labels cannot be modified. Only user-created labels can be edited.');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.LABEL_INVALID_NAME)) {
throw new Error('Label name contains invalid characters. Please use only letters, numbers, and basic punctuation.');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.LABEL_INVALID_COLOR)) {
throw new Error('Invalid color format. Please use a valid hex color like #0969da.');
} else {
throw new Error(errorInfo.message || 'Failed to update label');
}
}
};
@ -133,7 +170,22 @@ const LabelsPage: React.FC = () => {
setLabelToDelete(null);
} catch (error) {
console.error('Failed to delete label:', error);
setError('Failed to delete label');
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
// Handle specific label deletion errors
if (ErrorHelper.isErrorCode(error, ErrorCodes.LABEL_NOT_FOUND)) {
setError('Label not found. It may have already been deleted.');
await fetchLabels(); // Refresh the list to sync state
setDeleteDialogOpen(false);
setLabelToDelete(null);
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.LABEL_IN_USE)) {
setError('Cannot delete label because it is currently assigned to documents. Please remove the label from all documents first.');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.LABEL_SYSTEM_MODIFICATION)) {
setError('System labels cannot be deleted. Only user-created labels can be removed.');
} else {
setError(errorInfo.message || 'Failed to delete label');
}
}
};

View File

@ -41,7 +41,7 @@ import { Edit as EditIcon, Delete as DeleteIcon, Add as AddIcon,
Assessment as AssessmentIcon, PlayArrow as PlayArrowIcon,
Pause as PauseIcon, Stop as StopIcon } from '@mui/icons-material';
import { useAuth } from '../contexts/AuthContext';
import api, { queueService } from '../services/api';
import api, { queueService, ErrorHelper, ErrorCodes } from '../services/api';
import OcrLanguageSelector from '../components/OcrLanguageSelector';
import LanguageSelector from '../components/LanguageSelector';
@ -359,7 +359,19 @@ const SettingsPage: React.FC = () => {
}
} catch (error) {
console.error('Error updating settings:', error);
showSnackbar('Failed to update settings', 'error');
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
// Handle specific settings errors
if (ErrorHelper.isErrorCode(error, ErrorCodes.SETTINGS_INVALID_LANGUAGE)) {
showSnackbar('Invalid language selected. Please choose from available languages.', 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SETTINGS_VALUE_OUT_OF_RANGE)) {
showSnackbar(`${errorInfo.message}. ${errorInfo.suggestedAction || ''}`, 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SETTINGS_CONFLICTING_SETTINGS)) {
showSnackbar('Conflicting settings detected. Please review your configuration.', 'warning');
} else {
showSnackbar(errorInfo.message || 'Failed to update settings', 'error');
}
}
};
@ -382,7 +394,25 @@ const SettingsPage: React.FC = () => {
handleCloseUserDialog();
} catch (error: any) {
console.error('Error saving user:', error);
showSnackbar(error.response?.data?.message || 'Failed to save user', 'error');
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
// Handle specific user errors with better messages
if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_DUPLICATE_USERNAME)) {
showSnackbar('This username is already taken. Please choose a different username.', 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_DUPLICATE_EMAIL)) {
showSnackbar('This email address is already in use. Please use a different email.', 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_INVALID_PASSWORD)) {
showSnackbar('Password must be at least 8 characters with uppercase, lowercase, and numbers.', 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_INVALID_EMAIL)) {
showSnackbar('Please enter a valid email address.', 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_INVALID_USERNAME)) {
showSnackbar('Username contains invalid characters. Please use only letters, numbers, and underscores.', 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_PERMISSION_DENIED)) {
showSnackbar('You do not have permission to perform this action.', 'error');
} else {
showSnackbar(errorInfo.message || 'Failed to save user', 'error');
}
} finally {
setLoading(false);
}
@ -402,7 +432,20 @@ const SettingsPage: React.FC = () => {
fetchUsers();
} catch (error) {
console.error('Error deleting user:', error);
showSnackbar('Failed to delete user', 'error');
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
// Handle specific delete errors
if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_DELETE_RESTRICTED)) {
showSnackbar('Cannot delete this user: They may have associated data or be the last admin.', 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_NOT_FOUND)) {
showSnackbar('User not found. They may have already been deleted.', 'warning');
fetchUsers(); // Refresh the list
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_PERMISSION_DENIED)) {
showSnackbar('You do not have permission to delete users.', 'error');
} else {
showSnackbar(errorInfo.message || 'Failed to delete user', 'error');
}
} finally {
setLoading(false);
}

View File

@ -76,7 +76,7 @@ import {
Error as CriticalIcon,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import api, { queueService, sourcesService } from '../services/api';
import api, { queueService, sourcesService, ErrorHelper, ErrorCodes } from '../services/api';
import { formatDistanceToNow } from 'date-fns';
import { useAuth } from '../contexts/AuthContext';
@ -404,7 +404,23 @@ const SourcesPage: React.FC = () => {
loadSources();
} catch (error) {
console.error('Failed to save source:', error);
showSnackbar('Failed to save source', 'error');
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
// Handle specific source errors
if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_DUPLICATE_NAME)) {
showSnackbar('A source with this name already exists. Please choose a different name.', 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_CONFIG_INVALID)) {
showSnackbar('Source configuration is invalid. Please check your settings and try again.', 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_AUTH_FAILED)) {
showSnackbar('Authentication failed. Please verify your credentials.', 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_CONNECTION_FAILED)) {
showSnackbar('Cannot connect to the source. Please check your network and server settings.', 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_INVALID_PATH)) {
showSnackbar('Invalid path specified. Please check your folder paths and try again.', 'error');
} else {
showSnackbar(errorInfo.message || 'Failed to save source', 'error');
}
}
};
@ -430,7 +446,19 @@ const SourcesPage: React.FC = () => {
handleDeleteCancel();
} catch (error) {
console.error('Failed to delete source:', error);
showSnackbar('Failed to delete source', 'error');
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
// Handle specific delete errors
if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_NOT_FOUND)) {
showSnackbar('Source not found. It may have already been deleted.', 'warning');
loadSources(); // Refresh the list
handleDeleteCancel();
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_SYNC_IN_PROGRESS)) {
showSnackbar('Cannot delete source while sync is in progress. Please stop the sync first.', 'error');
} else {
showSnackbar(errorInfo.message || 'Failed to delete source', 'error');
}
setDeleteLoading(false);
}
};
@ -482,8 +510,23 @@ const SourcesPage: React.FC = () => {
}
} catch (error: any) {
console.error('Failed to test connection:', error);
const errorMessage = error.response?.data?.message || error.message || 'Failed to test connection';
showSnackbar(errorMessage, 'error');
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
// Handle specific connection test errors
if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_CONNECTION_FAILED)) {
showSnackbar('Connection failed. Please check your server URL and network connectivity.', 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_AUTH_FAILED)) {
showSnackbar('Authentication failed. Please verify your username and password.', 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_INVALID_PATH)) {
showSnackbar('Invalid path specified. Please check your folder paths.', 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_CONFIG_INVALID)) {
showSnackbar('Configuration is invalid. Please review your settings.', 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_NETWORK_TIMEOUT)) {
showSnackbar('Connection timed out. Please check your network and try again.', 'error');
} else {
showSnackbar(errorInfo.message || 'Failed to test connection', 'error');
}
} finally {
setTestingConnection(false);
}
@ -512,10 +555,21 @@ const SourcesPage: React.FC = () => {
setTimeout(loadSources, 1000);
} catch (error: any) {
console.error('Failed to trigger sync:', error);
if (error.response?.status === 409) {
showSnackbar('Source is already syncing', 'warning');
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
// Handle specific sync errors
if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_SYNC_IN_PROGRESS)) {
showSnackbar('Source is already syncing. Please wait for the current sync to complete.', 'warning');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_CONNECTION_FAILED)) {
showSnackbar('Cannot connect to source. Please check your connection and try again.', 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_AUTH_FAILED)) {
showSnackbar('Authentication failed. Please verify your source credentials.', 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_NOT_FOUND)) {
showSnackbar('Source not found. It may have been deleted.', 'error');
loadSources(); // Refresh the sources list
} else {
showSnackbar('Failed to start sync', 'error');
showSnackbar(errorInfo.message || 'Failed to start sync', 'error');
}
} finally {
setSyncingSource(null);

View File

@ -0,0 +1,657 @@
import { describe, test, expect, vi } from 'vitest';
import {
ErrorHelper,
ErrorCodes,
type ApiErrorResponse,
type AxiosErrorWithCode,
type ErrorCode
} from '../errors';
describe('ErrorHelper', () => {
describe('getErrorInfo', () => {
test('should handle structured API error response', () => {
const structuredError = {
response: {
data: {
error: 'User not found',
code: 'USER_NOT_FOUND',
status: 404
},
status: 404
}
};
const result = ErrorHelper.getErrorInfo(structuredError);
expect(result).toEqual({
message: 'User not found',
code: 'USER_NOT_FOUND',
status: 404
});
});
test('should handle legacy error format with message', () => {
const legacyError = {
response: {
data: {
message: 'Legacy error message'
},
status: 500
}
};
const result = ErrorHelper.getErrorInfo(legacyError);
expect(result).toEqual({
message: 'Legacy error message',
status: 500
});
});
test('should handle axios errors without structured data', () => {
const axiosError = {
message: 'Network Error',
response: {
status: 500
}
};
const result = ErrorHelper.getErrorInfo(axiosError);
expect(result).toEqual({
message: 'Network Error',
status: 500
});
});
test('should handle Error objects', () => {
const error = new Error('Something went wrong');
const result = ErrorHelper.getErrorInfo(error);
expect(result).toEqual({
message: 'Something went wrong'
});
});
test('should handle string errors', () => {
const result = ErrorHelper.getErrorInfo('Simple error string');
expect(result).toEqual({
message: 'An unknown error occurred'
});
});
test('should handle null/undefined errors', () => {
expect(ErrorHelper.getErrorInfo(null)).toEqual({
message: 'An unknown error occurred'
});
expect(ErrorHelper.getErrorInfo(undefined)).toEqual({
message: 'An unknown error occurred'
});
});
test('should handle empty response data', () => {
const emptyError = {
response: {
data: {},
status: 400
},
message: 'Bad Request'
};
const result = ErrorHelper.getErrorInfo(emptyError);
expect(result).toEqual({
message: 'Bad Request',
status: 400
});
});
});
describe('isErrorCode', () => {
test('should return true for matching structured error code', () => {
const error = {
response: {
data: {
error: 'User not found',
code: 'USER_NOT_FOUND',
status: 404
}
}
};
expect(ErrorHelper.isErrorCode(error, ErrorCodes.USER_NOT_FOUND)).toBe(true);
});
test('should return false for non-matching structured error code', () => {
const error = {
response: {
data: {
error: 'User not found',
code: 'USER_NOT_FOUND',
status: 404
}
}
};
expect(ErrorHelper.isErrorCode(error, ErrorCodes.USER_DUPLICATE_USERNAME)).toBe(false);
});
test('should return false for legacy error without code', () => {
const error = {
response: {
data: {
message: 'Legacy error'
}
}
};
expect(ErrorHelper.isErrorCode(error, ErrorCodes.USER_NOT_FOUND)).toBe(false);
});
test('should return false for errors without response', () => {
const error = new Error('Network Error');
expect(ErrorHelper.isErrorCode(error, ErrorCodes.USER_NOT_FOUND)).toBe(false);
});
test('should handle null/undefined errors', () => {
expect(ErrorHelper.isErrorCode(null, ErrorCodes.USER_NOT_FOUND)).toBe(false);
expect(ErrorHelper.isErrorCode(undefined, ErrorCodes.USER_NOT_FOUND)).toBe(false);
});
});
describe('getUserMessage', () => {
test('should return error message for structured error', () => {
const error = {
response: {
data: {
error: 'Custom error message',
code: 'USER_NOT_FOUND',
status: 404
}
}
};
expect(ErrorHelper.getUserMessage(error)).toBe('Custom error message');
});
test('should return fallback message when provided', () => {
const error = null;
expect(ErrorHelper.getUserMessage(error, 'Custom fallback')).toBe('Custom fallback');
});
test('should return default fallback when no message', () => {
const error = null;
expect(ErrorHelper.getUserMessage(error)).toBe('An error occurred');
});
});
describe('getSuggestedAction', () => {
test('should return specific action for user duplicate username', () => {
const error = {
response: {
data: {
error: 'Username already exists',
code: 'USER_DUPLICATE_USERNAME',
status: 409
}
}
};
expect(ErrorHelper.getSuggestedAction(error)).toBe('Please choose a different username');
});
test('should return specific action for invalid credentials', () => {
const error = {
response: {
data: {
error: 'Invalid login',
code: 'USER_INVALID_CREDENTIALS',
status: 401
}
}
};
expect(ErrorHelper.getSuggestedAction(error)).toBe('Please check your username and password');
});
test('should return null for unknown error codes', () => {
const error = {
response: {
data: {
error: 'Unknown error',
code: 'UNKNOWN_ERROR_CODE',
status: 500
}
}
};
expect(ErrorHelper.getSuggestedAction(error)).toBe(null);
});
test('should return null for errors without codes', () => {
const error = new Error('Generic error');
expect(ErrorHelper.getSuggestedAction(error)).toBe(null);
});
});
describe('shouldShowRetry', () => {
test('should return true for retryable error codes', () => {
const error = {
response: {
data: {
error: 'Connection failed',
code: 'SOURCE_CONNECTION_FAILED',
status: 503
}
}
};
expect(ErrorHelper.shouldShowRetry(error)).toBe(true);
});
test('should return true for 5xx server errors', () => {
const error = {
response: {
data: {
message: 'Internal server error'
},
status: 500
}
};
expect(ErrorHelper.shouldShowRetry(error)).toBe(true);
});
test('should return false for client errors', () => {
const error = {
response: {
data: {
error: 'Bad request',
code: 'USER_INVALID_CREDENTIALS',
status: 400
}
}
};
expect(ErrorHelper.shouldShowRetry(error)).toBe(false);
});
});
describe('getErrorCategory', () => {
test('should categorize user auth errors correctly', () => {
const authCodes = [
'USER_INVALID_CREDENTIALS',
'USER_TOKEN_EXPIRED',
'USER_SESSION_EXPIRED'
];
authCodes.forEach(code => {
const error = {
response: {
data: { error: 'Test', code, status: 401 }
}
};
expect(ErrorHelper.getErrorCategory(error)).toBe('auth');
});
});
test('should categorize user validation errors correctly', () => {
const validationCodes = [
'USER_INVALID_PASSWORD',
'USER_INVALID_EMAIL',
'USER_INVALID_USERNAME'
];
validationCodes.forEach(code => {
const error = {
response: {
data: { error: 'Test', code, status: 400 }
}
};
expect(ErrorHelper.getErrorCategory(error)).toBe('validation');
});
});
test('should categorize network errors correctly', () => {
const error = {
response: {
data: {
error: 'Connection failed',
code: 'SOURCE_CONNECTION_FAILED',
status: 503
}
}
};
expect(ErrorHelper.getErrorCategory(error)).toBe('network');
});
test('should categorize by HTTP status for errors without specific codes', () => {
const statusTests = [
{ status: 400, expectedCategory: 'validation' },
{ status: 401, expectedCategory: 'auth' },
{ status: 403, expectedCategory: 'auth' },
{ status: 422, expectedCategory: 'validation' },
{ status: 500, expectedCategory: 'server' },
{ status: 502, expectedCategory: 'server' },
{ status: 503, expectedCategory: 'server' }
];
statusTests.forEach(({ status, expectedCategory }) => {
const error = {
response: {
data: { message: 'Test error' },
status
}
};
expect(ErrorHelper.getErrorCategory(error)).toBe(expectedCategory);
});
});
test('should return unknown for unclassified errors', () => {
const error = new Error('Generic error');
expect(ErrorHelper.getErrorCategory(error)).toBe('unknown');
});
});
describe('getErrorIcon', () => {
test('should return appropriate icons for error categories', () => {
const iconTests = [
{ category: 'auth', expectedIcon: '🔒' },
{ category: 'validation', expectedIcon: '⚠️' },
{ category: 'network', expectedIcon: '🌐' },
{ category: 'server', expectedIcon: '🔧' },
{ category: 'unknown', expectedIcon: '❌' }
];
iconTests.forEach(({ category, expectedIcon }) => {
// Create an error that will categorize to the desired category
let error;
switch (category) {
case 'auth':
error = { response: { data: { code: 'USER_INVALID_CREDENTIALS' }, status: 401 } };
break;
case 'validation':
error = { response: { data: { code: 'USER_INVALID_PASSWORD' }, status: 400 } };
break;
case 'network':
error = { response: { data: { code: 'SOURCE_CONNECTION_FAILED' }, status: 503 } };
break;
case 'server':
error = { response: { data: { message: 'Server error' }, status: 500 } };
break;
default:
error = new Error('Unknown error');
}
expect(ErrorHelper.getErrorIcon(error)).toBe(expectedIcon);
});
});
});
describe('formatErrorForDisplay', () => {
test('should format error with actions included', () => {
const error = {
response: {
data: {
error: 'Username already exists',
code: 'USER_DUPLICATE_USERNAME',
status: 409
}
}
};
const result = ErrorHelper.formatErrorForDisplay(error, true);
expect(result.message).toBe('Username already exists');
expect(result.code).toBe('USER_DUPLICATE_USERNAME');
expect(result.status).toBe(409);
expect(result.suggestedAction).toBe('Please choose a different username');
expect(result.category).toBe('validation');
expect(result.icon).toBe('⚠️');
expect(result.severity).toBe('warning');
expect(typeof result.shouldShowRetry).toBe('boolean');
});
test('should format error without actions', () => {
const error = {
response: {
data: {
error: 'Username already exists',
code: 'USER_DUPLICATE_USERNAME',
status: 409
}
}
};
const result = ErrorHelper.formatErrorForDisplay(error, false);
expect(result.message).toBe('Username already exists');
expect(result.suggestedAction).toBe(null);
expect(result.shouldShowRetry).toBe(false);
});
test('should set correct severity based on category', () => {
const severityTests = [
{ code: 'USER_INVALID_PASSWORD', expectedSeverity: 'warning' }, // validation
{ code: 'USER_INVALID_CREDENTIALS', expectedSeverity: 'info' }, // auth
{ code: 'SOURCE_CONNECTION_FAILED', expectedSeverity: 'error' } // network
];
severityTests.forEach(({ code, expectedSeverity }) => {
const error = {
response: {
data: { error: 'Test', code, status: 400 }
}
};
const result = ErrorHelper.formatErrorForDisplay(error, true);
expect(result.severity).toBe(expectedSeverity);
});
});
});
describe('handleSpecificError', () => {
test('should handle session expired errors with login callback', () => {
const error = {
response: {
data: {
error: 'Session expired',
code: 'USER_SESSION_EXPIRED',
status: 401
}
}
};
const onLogin = vi.fn();
const result = ErrorHelper.handleSpecificError(error, undefined, onLogin);
expect(result).toBe(true);
expect(onLogin).toHaveBeenCalled();
});
test('should handle retryable errors with retry callback', async () => {
const error = {
response: {
data: {
error: 'Connection failed',
code: 'SOURCE_CONNECTION_FAILED',
status: 503
}
}
};
const onRetry = vi.fn();
const result = ErrorHelper.handleSpecificError(error, onRetry);
expect(result).toBe(true);
// Wait for the timeout to trigger
await new Promise(resolve => setTimeout(resolve, 2100));
expect(onRetry).toHaveBeenCalled();
});
test('should not handle non-specific errors', () => {
const error = {
response: {
data: {
error: 'Generic error',
code: 'SOME_OTHER_ERROR',
status: 400
}
}
};
const result = ErrorHelper.handleSpecificError(error);
expect(result).toBe(false);
});
test('should not handle session expired without login callback', () => {
const error = {
response: {
data: {
error: 'Session expired',
code: 'USER_SESSION_EXPIRED',
status: 401
}
}
};
const result = ErrorHelper.handleSpecificError(error);
expect(result).toBe(false);
});
});
describe('ErrorCodes constants', () => {
test('should have all required user error codes', () => {
expect(ErrorCodes.USER_NOT_FOUND).toBe('USER_NOT_FOUND');
expect(ErrorCodes.USER_DUPLICATE_USERNAME).toBe('USER_DUPLICATE_USERNAME');
expect(ErrorCodes.USER_DUPLICATE_EMAIL).toBe('USER_DUPLICATE_EMAIL');
expect(ErrorCodes.USER_INVALID_CREDENTIALS).toBe('USER_INVALID_CREDENTIALS');
expect(ErrorCodes.USER_SESSION_EXPIRED).toBe('USER_SESSION_EXPIRED');
expect(ErrorCodes.USER_TOKEN_EXPIRED).toBe('USER_TOKEN_EXPIRED');
expect(ErrorCodes.USER_PERMISSION_DENIED).toBe('USER_PERMISSION_DENIED');
expect(ErrorCodes.USER_ACCOUNT_DISABLED).toBe('USER_ACCOUNT_DISABLED');
});
test('should have all required source error codes', () => {
expect(ErrorCodes.SOURCE_NOT_FOUND).toBe('SOURCE_NOT_FOUND');
expect(ErrorCodes.SOURCE_CONNECTION_FAILED).toBe('SOURCE_CONNECTION_FAILED');
expect(ErrorCodes.SOURCE_AUTH_FAILED).toBe('SOURCE_AUTH_FAILED');
expect(ErrorCodes.SOURCE_CONFIG_INVALID).toBe('SOURCE_CONFIG_INVALID');
expect(ErrorCodes.SOURCE_SYNC_IN_PROGRESS).toBe('SOURCE_SYNC_IN_PROGRESS');
});
test('should have all required label error codes', () => {
expect(ErrorCodes.LABEL_NOT_FOUND).toBe('LABEL_NOT_FOUND');
expect(ErrorCodes.LABEL_DUPLICATE_NAME).toBe('LABEL_DUPLICATE_NAME');
expect(ErrorCodes.LABEL_INVALID_NAME).toBe('LABEL_INVALID_NAME');
expect(ErrorCodes.LABEL_INVALID_COLOR).toBe('LABEL_INVALID_COLOR');
expect(ErrorCodes.LABEL_IN_USE).toBe('LABEL_IN_USE');
expect(ErrorCodes.LABEL_SYSTEM_MODIFICATION).toBe('LABEL_SYSTEM_MODIFICATION');
expect(ErrorCodes.LABEL_MAX_LABELS_REACHED).toBe('LABEL_MAX_LABELS_REACHED');
});
});
describe('Edge cases and robustness', () => {
test('should handle malformed error objects', () => {
const malformedErrors = [
{ response: null },
{ response: { data: null } },
{ response: { data: { error: null, code: null } } },
{ response: { status: 'not-a-number' } },
{ code: 123 }, // numeric code instead of string
{ message: { nested: 'object' } } // object instead of string
];
malformedErrors.forEach(error => {
expect(() => ErrorHelper.getErrorInfo(error)).not.toThrow();
expect(() => ErrorHelper.formatErrorForDisplay(error, true)).not.toThrow();
expect(() => ErrorHelper.isErrorCode(error, ErrorCodes.USER_NOT_FOUND)).not.toThrow();
expect(() => ErrorHelper.getErrorCategory(error)).not.toThrow();
expect(() => ErrorHelper.getErrorIcon(error)).not.toThrow();
});
});
test('should handle very long error messages', () => {
const longMessage = 'x'.repeat(10000);
const error = {
response: {
data: {
error: longMessage,
code: 'USER_NOT_FOUND',
status: 404
}
}
};
const result = ErrorHelper.getErrorInfo(error);
expect(result.message).toBe(longMessage);
expect(result.code).toBe('USER_NOT_FOUND');
});
test('should handle non-string error codes gracefully', () => {
const error = {
response: {
data: {
error: 'Test error',
code: 12345, // numeric code
status: 400
}
}
};
// Should not crash and should handle it as if no code was provided
expect(() => ErrorHelper.getErrorInfo(error)).not.toThrow();
expect(() => ErrorHelper.isErrorCode(error, ErrorCodes.USER_NOT_FOUND)).not.toThrow();
});
});
describe('Type safety', () => {
test('should handle ApiErrorResponse interface correctly', () => {
const apiError: ApiErrorResponse = {
error: 'Test error message',
code: 'USER_NOT_FOUND',
status: 404
};
const error = {
response: {
data: apiError,
status: 404
}
};
const result = ErrorHelper.getErrorInfo(error);
expect(result.message).toBe('Test error message');
expect(result.code).toBe('USER_NOT_FOUND');
expect(result.status).toBe(404);
});
test('should handle AxiosErrorWithCode interface correctly', () => {
const axiosError: AxiosErrorWithCode = {
response: {
data: {
error: 'Axios error',
code: 'SOURCE_CONNECTION_FAILED',
status: 503
},
status: 503,
statusText: 'Service Unavailable',
headers: {}
},
message: 'Request failed',
name: 'AxiosError'
};
const result = ErrorHelper.getErrorInfo(axiosError);
expect(result.message).toBe('Axios error');
expect(result.code).toBe('SOURCE_CONNECTION_FAILED');
expect(result.status).toBe(503);
});
});
});

View File

@ -10,6 +10,10 @@ const api = axios.create({
export { api }
export default api
// Re-export error handling utilities for convenience
export { ErrorHelper, ErrorCodes } from './errors'
export type { ApiErrorResponse, AxiosErrorWithCode, ErrorCode } from './errors'
export interface Document {
id: string
filename: string

View File

@ -0,0 +1,320 @@
import axios, { AxiosError } from 'axios'
// Error Response Interfaces
export interface ApiErrorResponse {
error: string
code: string
status: number
}
export interface AxiosErrorWithCode extends AxiosError {
response?: {
data: ApiErrorResponse
status: number
statusText: string
headers: any
}
}
// Error Code Constants
export const ErrorCodes = {
// User Errors
USER_NOT_FOUND: 'USER_NOT_FOUND',
USER_NOT_FOUND_BY_ID: 'USER_NOT_FOUND_BY_ID',
USER_DUPLICATE_USERNAME: 'USER_DUPLICATE_USERNAME',
USER_DUPLICATE_EMAIL: 'USER_DUPLICATE_EMAIL',
USER_INVALID_ROLE: 'USER_INVALID_ROLE',
USER_PERMISSION_DENIED: 'USER_PERMISSION_DENIED',
USER_INVALID_CREDENTIALS: 'USER_INVALID_CREDENTIALS',
USER_ACCOUNT_DISABLED: 'USER_ACCOUNT_DISABLED',
USER_INVALID_PASSWORD: 'USER_INVALID_PASSWORD',
USER_INVALID_USERNAME: 'USER_INVALID_USERNAME',
USER_INVALID_EMAIL: 'USER_INVALID_EMAIL',
USER_DELETE_RESTRICTED: 'USER_DELETE_RESTRICTED',
USER_OIDC_AUTH_FAILED: 'USER_OIDC_AUTH_FAILED',
USER_AUTH_PROVIDER_NOT_CONFIGURED: 'USER_AUTH_PROVIDER_NOT_CONFIGURED',
USER_TOKEN_EXPIRED: 'USER_TOKEN_EXPIRED',
USER_INVALID_TOKEN: 'USER_INVALID_TOKEN',
USER_SESSION_EXPIRED: 'USER_SESSION_EXPIRED',
USER_INTERNAL_SERVER_ERROR: 'USER_INTERNAL_SERVER_ERROR',
// Source Errors
SOURCE_NOT_FOUND: 'SOURCE_NOT_FOUND',
SOURCE_DUPLICATE_NAME: 'SOURCE_DUPLICATE_NAME',
SOURCE_INVALID_NAME: 'SOURCE_INVALID_NAME',
SOURCE_INVALID_PATH: 'SOURCE_INVALID_PATH',
SOURCE_CONNECTION_FAILED: 'SOURCE_CONNECTION_FAILED',
SOURCE_AUTH_FAILED: 'SOURCE_AUTH_FAILED',
SOURCE_PERMISSION_DENIED: 'SOURCE_PERMISSION_DENIED',
SOURCE_QUOTA_EXCEEDED: 'SOURCE_QUOTA_EXCEEDED',
SOURCE_RATE_LIMIT_EXCEEDED: 'SOURCE_RATE_LIMIT_EXCEEDED',
SOURCE_CONFIG_INVALID: 'SOURCE_CONFIG_INVALID',
SOURCE_SYNC_IN_PROGRESS: 'SOURCE_SYNC_IN_PROGRESS',
SOURCE_UNSUPPORTED_OPERATION: 'SOURCE_UNSUPPORTED_OPERATION',
SOURCE_NETWORK_TIMEOUT: 'SOURCE_NETWORK_TIMEOUT',
// Label Errors
LABEL_NOT_FOUND: 'LABEL_NOT_FOUND',
LABEL_DUPLICATE_NAME: 'LABEL_DUPLICATE_NAME',
LABEL_INVALID_NAME: 'LABEL_INVALID_NAME',
LABEL_INVALID_COLOR: 'LABEL_INVALID_COLOR',
LABEL_SYSTEM_MODIFICATION: 'LABEL_SYSTEM_MODIFICATION',
LABEL_IN_USE: 'LABEL_IN_USE',
LABEL_MAX_LABELS_REACHED: 'LABEL_MAX_LABELS_REACHED',
// Settings Errors
SETTINGS_INVALID_LANGUAGE: 'SETTINGS_INVALID_LANGUAGE',
SETTINGS_VALUE_OUT_OF_RANGE: 'SETTINGS_VALUE_OUT_OF_RANGE',
SETTINGS_INVALID_VALUE: 'SETTINGS_INVALID_VALUE',
SETTINGS_INVALID_OCR_CONFIG: 'SETTINGS_INVALID_OCR_CONFIG',
SETTINGS_CONFLICTING_SETTINGS: 'SETTINGS_CONFLICTING_SETTINGS',
// Search Errors
SEARCH_QUERY_TOO_SHORT: 'SEARCH_QUERY_TOO_SHORT',
SEARCH_TOO_MANY_RESULTS: 'SEARCH_TOO_MANY_RESULTS',
SEARCH_INDEX_UNAVAILABLE: 'SEARCH_INDEX_UNAVAILABLE',
SEARCH_INVALID_PAGINATION: 'SEARCH_INVALID_PAGINATION',
SEARCH_NO_RESULTS: 'SEARCH_NO_RESULTS',
// Document Errors
DOCUMENT_NOT_FOUND: 'DOCUMENT_NOT_FOUND',
DOCUMENT_UPLOAD_FAILED: 'DOCUMENT_UPLOAD_FAILED',
DOCUMENT_INVALID_FORMAT: 'DOCUMENT_INVALID_FORMAT',
DOCUMENT_TOO_LARGE: 'DOCUMENT_TOO_LARGE',
DOCUMENT_OCR_FAILED: 'DOCUMENT_OCR_FAILED',
} as const
export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes]
// Error Helper Functions
export const ErrorHelper = {
/**
* Extract error information from an axios error
*/
getErrorInfo: (error: unknown): { message: string; code?: string; status?: number } => {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosErrorWithCode
// Check if it's our new structured error format
if (axiosError.response?.data?.error && axiosError.response?.data?.code) {
return {
message: axiosError.response.data.error,
code: axiosError.response.data.code,
status: axiosError.response.data.status || axiosError.response.status,
}
}
// Fallback to legacy error handling
if (axiosError.response?.data?.message) {
return {
message: axiosError.response.data.message,
status: axiosError.response.status,
}
}
// Default axios error handling
return {
message: axiosError.message || 'An error occurred',
status: axiosError.response?.status,
}
}
// Handle non-axios errors
if (error instanceof Error) {
return { message: error.message }
}
return { message: 'An unknown error occurred' }
},
/**
* Check if error is a specific error code
*/
isErrorCode: (error: unknown, code: ErrorCode): boolean => {
const errorInfo = ErrorHelper.getErrorInfo(error)
return errorInfo.code === code
},
/**
* Get user-friendly error message with fallback
*/
getUserMessage: (error: unknown, fallback?: string): string => {
const errorInfo = ErrorHelper.getErrorInfo(error)
return errorInfo.message || fallback || 'An error occurred'
},
/**
* Get suggested action based on error code
*/
getSuggestedAction: (error: unknown): string | null => {
const errorInfo = ErrorHelper.getErrorInfo(error)
switch (errorInfo.code) {
case ErrorCodes.USER_DUPLICATE_USERNAME:
return 'Please choose a different username'
case ErrorCodes.USER_DUPLICATE_EMAIL:
return 'Please use a different email address'
case ErrorCodes.USER_INVALID_PASSWORD:
return 'Password must be at least 8 characters with uppercase, lowercase, and numbers'
case ErrorCodes.USER_INVALID_CREDENTIALS:
return 'Please check your username and password'
case ErrorCodes.USER_SESSION_EXPIRED:
case ErrorCodes.USER_TOKEN_EXPIRED:
return 'Please login again'
case ErrorCodes.USER_ACCOUNT_DISABLED:
return 'Please contact an administrator'
case ErrorCodes.SOURCE_CONNECTION_FAILED:
return 'Check your network connection and server settings'
case ErrorCodes.SOURCE_AUTH_FAILED:
return 'Verify your credentials and try again'
case ErrorCodes.SOURCE_CONFIG_INVALID:
return 'Check your source configuration settings'
case ErrorCodes.LABEL_DUPLICATE_NAME:
return 'Please choose a different label name'
case ErrorCodes.LABEL_INVALID_COLOR:
return 'Use a valid hex color format like #0969da'
case ErrorCodes.SEARCH_QUERY_TOO_SHORT:
return 'Please enter at least 2 characters'
case ErrorCodes.DOCUMENT_TOO_LARGE:
return 'Please select a smaller file'
case ErrorCodes.SETTINGS_INVALID_LANGUAGE:
return 'Please select a valid language from the available options'
case ErrorCodes.SETTINGS_VALUE_OUT_OF_RANGE:
return 'Please enter a value within the allowed range'
case ErrorCodes.SETTINGS_INVALID_OCR_CONFIG:
return 'Please check your OCR configuration settings'
case ErrorCodes.SETTINGS_CONFLICTING_SETTINGS:
return 'Please resolve conflicting settings before saving'
default:
return null
}
},
/**
* Check if error should show retry option
*/
shouldShowRetry: (error: unknown): boolean => {
const errorInfo = ErrorHelper.getErrorInfo(error)
const retryableCodes = [
ErrorCodes.SOURCE_CONNECTION_FAILED,
ErrorCodes.SOURCE_NETWORK_TIMEOUT,
ErrorCodes.SOURCE_RATE_LIMIT_EXCEEDED,
ErrorCodes.SEARCH_INDEX_UNAVAILABLE,
ErrorCodes.DOCUMENT_UPLOAD_FAILED,
ErrorCodes.DOCUMENT_OCR_FAILED,
]
return retryableCodes.includes(errorInfo.code as ErrorCode) ||
(errorInfo.status && errorInfo.status >= 500)
},
/**
* Get error category for styling/icons
*/
getErrorCategory: (error: unknown): 'auth' | 'validation' | 'network' | 'server' | 'unknown' => {
const errorInfo = ErrorHelper.getErrorInfo(error)
if (errorInfo.code?.startsWith('USER_')) {
if (['USER_INVALID_CREDENTIALS', 'USER_TOKEN_EXPIRED', 'USER_SESSION_EXPIRED'].includes(errorInfo.code)) {
return 'auth'
}
if (['USER_INVALID_PASSWORD', 'USER_INVALID_EMAIL', 'USER_INVALID_USERNAME'].includes(errorInfo.code)) {
return 'validation'
}
}
if (errorInfo.code?.includes('CONNECTION_FAILED') || errorInfo.code?.includes('NETWORK_TIMEOUT')) {
return 'network'
}
if (errorInfo.code?.includes('INVALID') || errorInfo.code?.includes('OUT_OF_RANGE')) {
return 'validation'
}
if (errorInfo.status && errorInfo.status >= 500) {
return 'server'
}
if (errorInfo.status === 401 || errorInfo.status === 403) {
return 'auth'
}
if (errorInfo.status === 400 || errorInfo.status === 422) {
return 'validation'
}
return 'unknown'
},
/**
* Get appropriate error icon based on category
*/
getErrorIcon: (error: unknown): string => {
const category = ErrorHelper.getErrorCategory(error)
switch (category) {
case 'auth':
return '🔒'
case 'validation':
return '⚠️'
case 'network':
return '🌐'
case 'server':
return '🔧'
default:
return '❌'
}
},
/**
* Format error for display in UI components
*/
formatErrorForDisplay: (error: unknown, includeActions?: boolean) => {
const errorInfo = ErrorHelper.getErrorInfo(error)
const suggestedAction = ErrorHelper.getSuggestedAction(error)
const shouldRetry = ErrorHelper.shouldShowRetry(error)
const category = ErrorHelper.getErrorCategory(error)
const icon = ErrorHelper.getErrorIcon(error)
return {
message: errorInfo.message,
code: errorInfo.code,
status: errorInfo.status,
suggestedAction: includeActions ? suggestedAction : null,
shouldShowRetry: includeActions ? shouldRetry : false,
category,
icon,
severity: category === 'validation' ? 'warning' :
category === 'auth' ? 'info' : 'error'
}
},
/**
* Handle specific error codes with custom logic
*/
handleSpecificError: (error: unknown, onRetry?: () => void, onLogin?: () => void) => {
const errorInfo = ErrorHelper.getErrorInfo(error)
switch (errorInfo.code) {
case ErrorCodes.USER_SESSION_EXPIRED:
case ErrorCodes.USER_TOKEN_EXPIRED:
if (onLogin) {
onLogin()
return true // Handled
}
break
case ErrorCodes.SOURCE_CONNECTION_FAILED:
case ErrorCodes.SOURCE_NETWORK_TIMEOUT:
if (onRetry && ErrorHelper.shouldShowRetry(error)) {
// Could automatically retry after a delay
setTimeout(onRetry, 2000)
return true // Handled
}
break
}
return false // Not handled
}
}

228
src/errors/label.rs Normal file
View File

@ -0,0 +1,228 @@
use axum::http::StatusCode;
use thiserror::Error;
use uuid::Uuid;
use super::{AppError, ErrorCategory, ErrorSeverity, impl_into_response};
/// Errors related to label management operations
#[derive(Error, Debug)]
pub enum LabelError {
#[error("Label not found")]
NotFound,
#[error("Label with ID {id} not found")]
NotFoundById { id: Uuid },
#[error("Label with name '{name}' already exists")]
DuplicateName { name: String },
#[error("Cannot modify system label '{name}'")]
SystemLabelModification { name: String },
#[error("Invalid color format '{color}'. Use hex format like #0969da")]
InvalidColor { color: String },
#[error("Label name '{name}' is invalid: {reason}")]
InvalidName { name: String, reason: String },
#[error("Label is in use by {document_count} documents and cannot be deleted")]
LabelInUse { document_count: i64 },
#[error("Icon '{icon}' is not supported. Supported icons: {supported_icons}")]
InvalidIcon { icon: String, supported_icons: String },
#[error("Maximum number of labels ({max_labels}) reached")]
MaxLabelsReached { max_labels: i32 },
#[error("Permission denied: {reason}")]
PermissionDenied { reason: String },
#[error("Background color '{color}' conflicts with text color '{text_color}'")]
ColorConflict { color: String, text_color: String },
#[error("Label description too long: {length} characters (max: {max_length})")]
DescriptionTooLong { length: usize, max_length: usize },
#[error("Cannot delete label: {reason}")]
DeleteRestricted { reason: String },
#[error("Invalid label assignment to document {document_id}: {reason}")]
InvalidAssignment { document_id: Uuid, reason: String },
#[error("Label '{name}' is reserved and cannot be created")]
ReservedName { name: String },
}
impl AppError for LabelError {
fn status_code(&self) -> StatusCode {
match self {
LabelError::NotFound | LabelError::NotFoundById { .. } => StatusCode::NOT_FOUND,
LabelError::DuplicateName { .. } => StatusCode::CONFLICT,
LabelError::SystemLabelModification { .. } => StatusCode::FORBIDDEN,
LabelError::InvalidColor { .. } => StatusCode::BAD_REQUEST,
LabelError::InvalidName { .. } => StatusCode::BAD_REQUEST,
LabelError::LabelInUse { .. } => StatusCode::CONFLICT,
LabelError::InvalidIcon { .. } => StatusCode::BAD_REQUEST,
LabelError::MaxLabelsReached { .. } => StatusCode::CONFLICT,
LabelError::PermissionDenied { .. } => StatusCode::FORBIDDEN,
LabelError::ColorConflict { .. } => StatusCode::BAD_REQUEST,
LabelError::DescriptionTooLong { .. } => StatusCode::BAD_REQUEST,
LabelError::DeleteRestricted { .. } => StatusCode::CONFLICT,
LabelError::InvalidAssignment { .. } => StatusCode::BAD_REQUEST,
LabelError::ReservedName { .. } => StatusCode::CONFLICT,
}
}
fn user_message(&self) -> String {
match self {
LabelError::NotFound | LabelError::NotFoundById { .. } => "Label not found".to_string(),
LabelError::DuplicateName { .. } => "A label with this name already exists".to_string(),
LabelError::SystemLabelModification { .. } => "System labels cannot be modified".to_string(),
LabelError::InvalidColor { .. } => "Invalid color format - use hex format like #0969da".to_string(),
LabelError::InvalidName { reason, .. } => format!("Invalid label name: {}", reason),
LabelError::LabelInUse { document_count } => format!("Label is in use by {} documents and cannot be deleted", document_count),
LabelError::InvalidIcon { .. } => "Invalid icon specified".to_string(),
LabelError::MaxLabelsReached { max_labels } => format!("Maximum number of labels ({}) reached", max_labels),
LabelError::PermissionDenied { reason } => format!("Permission denied: {}", reason),
LabelError::ColorConflict { .. } => "Color combination provides poor contrast".to_string(),
LabelError::DescriptionTooLong { max_length, .. } => format!("Description too long (max {} characters)", max_length),
LabelError::DeleteRestricted { reason } => format!("Cannot delete label: {}", reason),
LabelError::InvalidAssignment { reason, .. } => format!("Invalid label assignment: {}", reason),
LabelError::ReservedName { .. } => "Label name is reserved and cannot be used".to_string(),
}
}
fn error_code(&self) -> &'static str {
match self {
LabelError::NotFound => "LABEL_NOT_FOUND",
LabelError::NotFoundById { .. } => "LABEL_NOT_FOUND_BY_ID",
LabelError::DuplicateName { .. } => "LABEL_DUPLICATE_NAME",
LabelError::SystemLabelModification { .. } => "LABEL_SYSTEM_MODIFICATION",
LabelError::InvalidColor { .. } => "LABEL_INVALID_COLOR",
LabelError::InvalidName { .. } => "LABEL_INVALID_NAME",
LabelError::LabelInUse { .. } => "LABEL_IN_USE",
LabelError::InvalidIcon { .. } => "LABEL_INVALID_ICON",
LabelError::MaxLabelsReached { .. } => "LABEL_MAX_REACHED",
LabelError::PermissionDenied { .. } => "LABEL_PERMISSION_DENIED",
LabelError::ColorConflict { .. } => "LABEL_COLOR_CONFLICT",
LabelError::DescriptionTooLong { .. } => "LABEL_DESCRIPTION_TOO_LONG",
LabelError::DeleteRestricted { .. } => "LABEL_DELETE_RESTRICTED",
LabelError::InvalidAssignment { .. } => "LABEL_INVALID_ASSIGNMENT",
LabelError::ReservedName { .. } => "LABEL_RESERVED_NAME",
}
}
fn error_category(&self) -> ErrorCategory {
match self {
LabelError::PermissionDenied { .. }
| LabelError::SystemLabelModification { .. } => ErrorCategory::Auth,
_ => ErrorCategory::Database, // Most label operations are database-related
}
}
fn error_severity(&self) -> ErrorSeverity {
match self {
LabelError::SystemLabelModification { .. }
| LabelError::PermissionDenied { .. } => ErrorSeverity::Important,
LabelError::NotFound
| LabelError::DuplicateName { .. }
| LabelError::LabelInUse { .. } => ErrorSeverity::Expected,
_ => ErrorSeverity::Minor,
}
}
fn suppression_key(&self) -> Option<String> {
match self {
LabelError::NotFound => Some("label_not_found".to_string()),
LabelError::DuplicateName { name } => Some(format!("label_duplicate_{}", name)),
_ => None,
}
}
fn suggested_action(&self) -> Option<String> {
match self {
LabelError::DuplicateName { .. } => Some("Please choose a different name for the label".to_string()),
LabelError::InvalidColor { .. } => Some("Use a valid hex color format like #0969da or #ff5722".to_string()),
LabelError::LabelInUse { .. } => Some("Remove the label from all documents first, then try deleting".to_string()),
LabelError::MaxLabelsReached { .. } => Some("Delete unused labels or contact administrator for limit increase".to_string()),
LabelError::ColorConflict { .. } => Some("Choose colors with better contrast for readability".to_string()),
LabelError::DescriptionTooLong { max_length, .. } => Some(format!("Shorten description to {} characters or less", max_length)),
LabelError::ReservedName { .. } => Some("Choose a different name that is not reserved by the system".to_string()),
LabelError::InvalidIcon { supported_icons, .. } => Some(format!("Use one of the supported icons: {}", supported_icons)),
_ => None,
}
}
}
impl_into_response!(LabelError);
/// Convenience methods for creating common label errors
impl LabelError {
pub fn not_found_by_id(id: Uuid) -> Self {
Self::NotFoundById { id }
}
pub fn duplicate_name<S: Into<String>>(name: S) -> Self {
Self::DuplicateName { name: name.into() }
}
pub fn system_label_modification<S: Into<String>>(name: S) -> Self {
Self::SystemLabelModification { name: name.into() }
}
pub fn invalid_color<S: Into<String>>(color: S) -> Self {
Self::InvalidColor { color: color.into() }
}
pub fn invalid_name<S: Into<String>>(name: S, reason: S) -> Self {
Self::InvalidName {
name: name.into(),
reason: reason.into()
}
}
pub fn label_in_use(document_count: i64) -> Self {
Self::LabelInUse { document_count }
}
pub fn invalid_icon<S: Into<String>>(icon: S, supported_icons: S) -> Self {
Self::InvalidIcon {
icon: icon.into(),
supported_icons: supported_icons.into()
}
}
pub fn max_labels_reached(max_labels: i32) -> Self {
Self::MaxLabelsReached { max_labels }
}
pub fn permission_denied<S: Into<String>>(reason: S) -> Self {
Self::PermissionDenied { reason: reason.into() }
}
pub fn color_conflict<S: Into<String>>(color: S, text_color: S) -> Self {
Self::ColorConflict {
color: color.into(),
text_color: text_color.into()
}
}
pub fn description_too_long(length: usize, max_length: usize) -> Self {
Self::DescriptionTooLong { length, max_length }
}
pub fn delete_restricted<S: Into<String>>(reason: S) -> Self {
Self::DeleteRestricted { reason: reason.into() }
}
pub fn invalid_assignment<S: Into<String>>(document_id: Uuid, reason: S) -> Self {
Self::InvalidAssignment {
document_id,
reason: reason.into()
}
}
pub fn reserved_name<S: Into<String>>(name: S) -> Self {
Self::ReservedName { name: name.into() }
}
}

208
src/errors/mod.rs Normal file
View File

@ -0,0 +1,208 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Json, Response},
};
use serde_json::json;
use thiserror::Error;
use uuid::Uuid;
use crate::monitoring::error_management::{
ErrorCategory, ErrorSeverity, ManagedError, get_error_manager,
};
/// Common trait for all custom error types in the application
pub trait AppError: std::error::Error + Send + Sync + 'static {
/// Get the HTTP status code for this error
fn status_code(&self) -> StatusCode;
/// Get a user-friendly error message
fn user_message(&self) -> String;
/// Get the error code for frontend handling
fn error_code(&self) -> &'static str;
/// Get the error category for the error management system
fn error_category(&self) -> ErrorCategory;
/// Get the error severity for the error management system
fn error_severity(&self) -> ErrorSeverity;
/// Get an optional suppression key for repeated error handling
fn suppression_key(&self) -> Option<String> {
None
}
/// Get optional suggested action for the user
fn suggested_action(&self) -> Option<String> {
None
}
/// Convert to a ManagedError for the error management system
fn to_managed_error(&self) -> ManagedError {
ManagedError {
category: self.error_category(),
severity: self.error_severity(),
code: self.error_code().to_string(),
user_message: self.user_message(),
technical_details: self.to_string(),
suggested_action: self.suggested_action(),
suppression_key: self.suppression_key(),
}
}
}
/// Macro to implement IntoResponse for all AppError types
/// This provides consistent HTTP response formatting
macro_rules! impl_into_response {
($error_type:ty) => {
impl axum::response::IntoResponse for $error_type {
fn into_response(self) -> axum::response::Response {
use crate::errors::AppError;
use crate::monitoring::error_management::get_error_manager;
use axum::{http::StatusCode, response::Json};
use serde_json::json;
// Send error to management system
let error_manager = get_error_manager();
let managed_error = self.to_managed_error();
tokio::spawn(async move {
error_manager.handle_error(managed_error).await;
});
// Create HTTP response
let status = self.status_code();
let body = Json(json!({
"error": self.user_message(),
"code": self.error_code(),
"status": status.as_u16()
}));
(status, body).into_response()
}
}
};
}
// Re-export the macro for use in other modules
pub(crate) use impl_into_response;
/// Generic API error for cases where specific error types don't apply
#[derive(Error, Debug)]
pub enum ApiError {
#[error("Bad request: {message}")]
BadRequest { message: String },
#[error("Resource not found")]
NotFound,
#[error("Conflict: {message}")]
Conflict { message: String },
#[error("Unauthorized access")]
Unauthorized,
#[error("Forbidden: {message}")]
Forbidden { message: String },
#[error("Payload too large: {message}")]
PayloadTooLarge { message: String },
#[error("Internal server error: {message}")]
InternalServerError { message: String },
#[error("Service unavailable: {message}")]
ServiceUnavailable { message: String },
}
impl AppError for ApiError {
fn status_code(&self) -> StatusCode {
match self {
ApiError::BadRequest { .. } => StatusCode::BAD_REQUEST,
ApiError::NotFound => StatusCode::NOT_FOUND,
ApiError::Conflict { .. } => StatusCode::CONFLICT,
ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
ApiError::Forbidden { .. } => StatusCode::FORBIDDEN,
ApiError::PayloadTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
ApiError::InternalServerError { .. } => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::ServiceUnavailable { .. } => StatusCode::SERVICE_UNAVAILABLE,
}
}
fn user_message(&self) -> String {
match self {
ApiError::BadRequest { message } => message.clone(),
ApiError::NotFound => "Resource not found".to_string(),
ApiError::Conflict { message } => message.clone(),
ApiError::Unauthorized => "Authentication required".to_string(),
ApiError::Forbidden { message } => message.clone(),
ApiError::PayloadTooLarge { message } => message.clone(),
ApiError::InternalServerError { .. } => "An internal error occurred".to_string(),
ApiError::ServiceUnavailable { message } => message.clone(),
}
}
fn error_code(&self) -> &'static str {
match self {
ApiError::BadRequest { .. } => "BAD_REQUEST",
ApiError::NotFound => "NOT_FOUND",
ApiError::Conflict { .. } => "CONFLICT",
ApiError::Unauthorized => "UNAUTHORIZED",
ApiError::Forbidden { .. } => "FORBIDDEN",
ApiError::PayloadTooLarge { .. } => "PAYLOAD_TOO_LARGE",
ApiError::InternalServerError { .. } => "INTERNAL_SERVER_ERROR",
ApiError::ServiceUnavailable { .. } => "SERVICE_UNAVAILABLE",
}
}
fn error_category(&self) -> ErrorCategory {
ErrorCategory::Network // Default for generic API errors
}
fn error_severity(&self) -> ErrorSeverity {
match self {
ApiError::InternalServerError { .. } => ErrorSeverity::Critical,
ApiError::ServiceUnavailable { .. } => ErrorSeverity::Important,
ApiError::Unauthorized | ApiError::Forbidden { .. } => ErrorSeverity::Important,
_ => ErrorSeverity::Minor,
}
}
}
impl_into_response!(ApiError);
/// Utility functions for common error creation patterns
impl ApiError {
pub fn bad_request<S: Into<String>>(message: S) -> Self {
Self::BadRequest { message: message.into() }
}
pub fn conflict<S: Into<String>>(message: S) -> Self {
Self::Conflict { message: message.into() }
}
pub fn forbidden<S: Into<String>>(message: S) -> Self {
Self::Forbidden { message: message.into() }
}
pub fn payload_too_large<S: Into<String>>(message: S) -> Self {
Self::PayloadTooLarge { message: message.into() }
}
pub fn internal_server_error<S: Into<String>>(message: S) -> Self {
Self::InternalServerError { message: message.into() }
}
pub fn service_unavailable<S: Into<String>>(message: S) -> Self {
Self::ServiceUnavailable { message: message.into() }
}
}
// Re-export commonly used types (already imported above)
// pub use crate::monitoring::error_management::{ErrorCategory, ErrorSeverity};
// Submodules for entity-specific errors
pub mod user;
pub mod source;
pub mod label;
pub mod settings;
pub mod search;

264
src/errors/search.rs Normal file
View File

@ -0,0 +1,264 @@
use axum::http::StatusCode;
use thiserror::Error;
use super::{AppError, ErrorCategory, ErrorSeverity, impl_into_response};
/// Errors related to search operations
#[derive(Error, Debug)]
pub enum SearchError {
#[error("Search query is too short: {length} characters (minimum: {min_length})")]
QueryTooShort { length: usize, min_length: usize },
#[error("Search query is too long: {length} characters (maximum: {max_length})")]
QueryTooLong { length: usize, max_length: usize },
#[error("Search index is unavailable: {reason}")]
IndexUnavailable { reason: String },
#[error("Invalid search syntax: {details}")]
InvalidSyntax { details: String },
#[error("Too many search results: {result_count} (maximum: {max_results})")]
TooManyResults { result_count: i64, max_results: i64 },
#[error("Search timeout after {timeout_seconds} seconds")]
SearchTimeout { timeout_seconds: u64 },
#[error("Invalid search mode '{mode}'. Valid modes: simple, phrase, fuzzy, boolean")]
InvalidSearchMode { mode: String },
#[error("Invalid MIME type filter '{mime_type}'")]
InvalidMimeType { mime_type: String },
#[error("Invalid pagination parameters: offset {offset}, limit {limit}")]
InvalidPagination { offset: i64, limit: i64 },
#[error("Boolean search syntax error: {details}")]
BooleanSyntaxError { details: String },
#[error("Fuzzy search threshold {threshold} is invalid. Valid range: 0.0 - 1.0")]
InvalidFuzzyThreshold { threshold: f32 },
#[error("Search index is rebuilding, try again in a few minutes")]
IndexRebuilding,
#[error("Search operation cancelled by user")]
SearchCancelled,
#[error("No search results found")]
NoResults,
#[error("Invalid snippet length {length}. Valid range: {min_length} - {max_length}")]
InvalidSnippetLength { length: i32, min_length: i32, max_length: i32 },
#[error("Search quota exceeded: {queries_today} queries today (limit: {daily_limit})")]
QuotaExceeded { queries_today: i64, daily_limit: i64 },
#[error("Invalid tag filter '{tag}'")]
InvalidTagFilter { tag: String },
#[error("Search index corruption detected: {details}")]
IndexCorruption { details: String },
#[error("Permission denied: cannot search documents belonging to other users")]
PermissionDenied,
#[error("Search feature is disabled")]
SearchDisabled,
}
impl AppError for SearchError {
fn status_code(&self) -> StatusCode {
match self {
SearchError::QueryTooShort { .. } => StatusCode::BAD_REQUEST,
SearchError::QueryTooLong { .. } => StatusCode::BAD_REQUEST,
SearchError::IndexUnavailable { .. } => StatusCode::SERVICE_UNAVAILABLE,
SearchError::InvalidSyntax { .. } => StatusCode::BAD_REQUEST,
SearchError::TooManyResults { .. } => StatusCode::PAYLOAD_TOO_LARGE,
SearchError::SearchTimeout { .. } => StatusCode::REQUEST_TIMEOUT,
SearchError::InvalidSearchMode { .. } => StatusCode::BAD_REQUEST,
SearchError::InvalidMimeType { .. } => StatusCode::BAD_REQUEST,
SearchError::InvalidPagination { .. } => StatusCode::BAD_REQUEST,
SearchError::BooleanSyntaxError { .. } => StatusCode::BAD_REQUEST,
SearchError::InvalidFuzzyThreshold { .. } => StatusCode::BAD_REQUEST,
SearchError::IndexRebuilding => StatusCode::SERVICE_UNAVAILABLE,
SearchError::SearchCancelled => StatusCode::REQUEST_TIMEOUT,
SearchError::NoResults => StatusCode::NOT_FOUND,
SearchError::InvalidSnippetLength { .. } => StatusCode::BAD_REQUEST,
SearchError::QuotaExceeded { .. } => StatusCode::TOO_MANY_REQUESTS,
SearchError::InvalidTagFilter { .. } => StatusCode::BAD_REQUEST,
SearchError::IndexCorruption { .. } => StatusCode::INTERNAL_SERVER_ERROR,
SearchError::PermissionDenied => StatusCode::FORBIDDEN,
SearchError::SearchDisabled => StatusCode::SERVICE_UNAVAILABLE,
}
}
fn user_message(&self) -> String {
match self {
SearchError::QueryTooShort { min_length, .. } => format!("Search query must be at least {} characters", min_length),
SearchError::QueryTooLong { max_length, .. } => format!("Search query must be less than {} characters", max_length),
SearchError::IndexUnavailable { .. } => "Search is temporarily unavailable".to_string(),
SearchError::InvalidSyntax { .. } => "Invalid search syntax".to_string(),
SearchError::TooManyResults { max_results, .. } => format!("Too many results. Please refine your search (limit: {})", max_results),
SearchError::SearchTimeout { .. } => "Search timed out. Please try a more specific query".to_string(),
SearchError::InvalidSearchMode { .. } => "Invalid search mode. Use: simple, phrase, fuzzy, or boolean".to_string(),
SearchError::InvalidMimeType { .. } => "Invalid file type filter".to_string(),
SearchError::InvalidPagination { .. } => "Invalid pagination parameters".to_string(),
SearchError::BooleanSyntaxError { details } => format!("Boolean search syntax error: {}", details),
SearchError::InvalidFuzzyThreshold { .. } => "Fuzzy search threshold must be between 0.0 and 1.0".to_string(),
SearchError::IndexRebuilding => "Search index is being rebuilt. Please try again in a few minutes".to_string(),
SearchError::SearchCancelled => "Search was cancelled".to_string(),
SearchError::NoResults => "No results found for your search".to_string(),
SearchError::InvalidSnippetLength { min_length, max_length, .. } => format!("Snippet length must be between {} and {} characters", min_length, max_length),
SearchError::QuotaExceeded { daily_limit, .. } => format!("Daily search limit of {} queries exceeded", daily_limit),
SearchError::InvalidTagFilter { .. } => "Invalid tag filter specified".to_string(),
SearchError::IndexCorruption { .. } => "Search index error. Please contact support".to_string(),
SearchError::PermissionDenied => "Permission denied for search operation".to_string(),
SearchError::SearchDisabled => "Search feature is currently disabled".to_string(),
}
}
fn error_code(&self) -> &'static str {
match self {
SearchError::QueryTooShort { .. } => "SEARCH_QUERY_TOO_SHORT",
SearchError::QueryTooLong { .. } => "SEARCH_QUERY_TOO_LONG",
SearchError::IndexUnavailable { .. } => "SEARCH_INDEX_UNAVAILABLE",
SearchError::InvalidSyntax { .. } => "SEARCH_INVALID_SYNTAX",
SearchError::TooManyResults { .. } => "SEARCH_TOO_MANY_RESULTS",
SearchError::SearchTimeout { .. } => "SEARCH_TIMEOUT",
SearchError::InvalidSearchMode { .. } => "SEARCH_INVALID_MODE",
SearchError::InvalidMimeType { .. } => "SEARCH_INVALID_MIME_TYPE",
SearchError::InvalidPagination { .. } => "SEARCH_INVALID_PAGINATION",
SearchError::BooleanSyntaxError { .. } => "SEARCH_BOOLEAN_SYNTAX_ERROR",
SearchError::InvalidFuzzyThreshold { .. } => "SEARCH_INVALID_FUZZY_THRESHOLD",
SearchError::IndexRebuilding => "SEARCH_INDEX_REBUILDING",
SearchError::SearchCancelled => "SEARCH_CANCELLED",
SearchError::NoResults => "SEARCH_NO_RESULTS",
SearchError::InvalidSnippetLength { .. } => "SEARCH_INVALID_SNIPPET_LENGTH",
SearchError::QuotaExceeded { .. } => "SEARCH_QUOTA_EXCEEDED",
SearchError::InvalidTagFilter { .. } => "SEARCH_INVALID_TAG_FILTER",
SearchError::IndexCorruption { .. } => "SEARCH_INDEX_CORRUPTION",
SearchError::PermissionDenied => "SEARCH_PERMISSION_DENIED",
SearchError::SearchDisabled => "SEARCH_DISABLED",
}
}
fn error_category(&self) -> ErrorCategory {
match self {
SearchError::PermissionDenied => ErrorCategory::Auth,
SearchError::IndexUnavailable { .. }
| SearchError::IndexRebuilding
| SearchError::IndexCorruption { .. } => ErrorCategory::Database,
SearchError::SearchTimeout { .. }
| SearchError::SearchCancelled => ErrorCategory::Network,
_ => ErrorCategory::Database, // Most search operations are database-related
}
}
fn error_severity(&self) -> ErrorSeverity {
match self {
SearchError::IndexCorruption { .. } => ErrorSeverity::Critical,
SearchError::IndexUnavailable { .. }
| SearchError::IndexRebuilding
| SearchError::SearchDisabled => ErrorSeverity::Important,
SearchError::NoResults
| SearchError::QueryTooShort { .. }
| SearchError::QueryTooLong { .. }
| SearchError::InvalidSyntax { .. } => ErrorSeverity::Expected,
_ => ErrorSeverity::Minor,
}
}
fn suppression_key(&self) -> Option<String> {
match self {
SearchError::IndexUnavailable { .. } => Some("search_index_unavailable".to_string()),
SearchError::IndexRebuilding => Some("search_index_rebuilding".to_string()),
SearchError::SearchTimeout { .. } => Some("search_timeout".to_string()),
SearchError::NoResults => Some("search_no_results".to_string()),
_ => None,
}
}
fn suggested_action(&self) -> Option<String> {
match self {
SearchError::QueryTooShort { min_length, .. } => Some(format!("Enter at least {} characters for your search", min_length)),
SearchError::QueryTooLong { max_length, .. } => Some(format!("Shorten your search to less than {} characters", max_length)),
SearchError::TooManyResults { .. } => Some("Use more specific search terms or apply filters".to_string()),
SearchError::SearchTimeout { .. } => Some("Try a more specific search query".to_string()),
SearchError::InvalidSearchMode { .. } => Some("Use one of: 'simple', 'phrase', 'fuzzy', or 'boolean'".to_string()),
SearchError::BooleanSyntaxError { .. } => Some("Check boolean operators (AND, OR, NOT) and parentheses".to_string()),
SearchError::InvalidFuzzyThreshold { .. } => Some("Set fuzzy threshold between 0.0 (loose) and 1.0 (exact)".to_string()),
SearchError::IndexRebuilding => Some("Wait a few minutes for index rebuild to complete".to_string()),
SearchError::NoResults => Some("Try different keywords or check spelling".to_string()),
SearchError::InvalidSnippetLength { min_length, max_length, .. } => Some(format!("Set snippet length between {} and {}", min_length, max_length)),
SearchError::QuotaExceeded { .. } => Some("Wait until tomorrow or contact administrator for limit increase".to_string()),
SearchError::SearchDisabled => Some("Contact administrator to enable search functionality".to_string()),
_ => None,
}
}
}
impl_into_response!(SearchError);
/// Convenience methods for creating common search errors
impl SearchError {
pub fn query_too_short(length: usize, min_length: usize) -> Self {
Self::QueryTooShort { length, min_length }
}
pub fn query_too_long(length: usize, max_length: usize) -> Self {
Self::QueryTooLong { length, max_length }
}
pub fn index_unavailable<S: Into<String>>(reason: S) -> Self {
Self::IndexUnavailable { reason: reason.into() }
}
pub fn invalid_syntax<S: Into<String>>(details: S) -> Self {
Self::InvalidSyntax { details: details.into() }
}
pub fn too_many_results(result_count: i64, max_results: i64) -> Self {
Self::TooManyResults { result_count, max_results }
}
pub fn search_timeout(timeout_seconds: u64) -> Self {
Self::SearchTimeout { timeout_seconds }
}
pub fn invalid_search_mode<S: Into<String>>(mode: S) -> Self {
Self::InvalidSearchMode { mode: mode.into() }
}
pub fn invalid_mime_type<S: Into<String>>(mime_type: S) -> Self {
Self::InvalidMimeType { mime_type: mime_type.into() }
}
pub fn invalid_pagination(offset: i64, limit: i64) -> Self {
Self::InvalidPagination { offset, limit }
}
pub fn boolean_syntax_error<S: Into<String>>(details: S) -> Self {
Self::BooleanSyntaxError { details: details.into() }
}
pub fn invalid_fuzzy_threshold(threshold: f32) -> Self {
Self::InvalidFuzzyThreshold { threshold }
}
pub fn invalid_snippet_length(length: i32, min_length: i32, max_length: i32) -> Self {
Self::InvalidSnippetLength { length, min_length, max_length }
}
pub fn quota_exceeded(queries_today: i64, daily_limit: i64) -> Self {
Self::QuotaExceeded { queries_today, daily_limit }
}
pub fn invalid_tag_filter<S: Into<String>>(tag: S) -> Self {
Self::InvalidTagFilter { tag: tag.into() }
}
pub fn index_corruption<S: Into<String>>(details: S) -> Self {
Self::IndexCorruption { details: details.into() }
}
}

290
src/errors/settings.rs Normal file
View File

@ -0,0 +1,290 @@
use axum::http::StatusCode;
use thiserror::Error;
use uuid::Uuid;
use super::{AppError, ErrorCategory, ErrorSeverity, impl_into_response};
/// Errors related to settings management operations
#[derive(Error, Debug)]
pub enum SettingsError {
#[error("Settings not found for user")]
NotFound,
#[error("Settings not found for user {user_id}")]
NotFoundForUser { user_id: Uuid },
#[error("Invalid language '{language}'. Available languages: {available_languages}")]
InvalidLanguage { language: String, available_languages: String },
#[error("Invalid value for setting '{setting_name}': {value}. {constraint}")]
InvalidValue { setting_name: String, value: String, constraint: String },
#[error("Setting '{setting_name}' is read-only and cannot be modified")]
ReadOnlySetting { setting_name: String },
#[error("Validation failed for setting '{setting_name}': {reason}")]
ValidationFailed { setting_name: String, reason: String },
#[error("Invalid OCR configuration: {details}")]
InvalidOcrConfiguration { details: String },
#[error("Invalid file type '{file_type}'. Supported types: {supported_types}")]
InvalidFileType { file_type: String, supported_types: String },
#[error("Value {value} is out of range for '{setting_name}'. Valid range: {min} - {max}")]
ValueOutOfRange { setting_name: String, value: i32, min: i32, max: i32 },
#[error("Invalid CPU priority '{priority}'. Valid options: low, normal, high")]
InvalidCpuPriority { priority: String },
#[error("Memory limit {memory_mb}MB is too low. Minimum: {min_memory_mb}MB")]
MemoryLimitTooLow { memory_mb: i32, min_memory_mb: i32 },
#[error("Memory limit {memory_mb}MB exceeds system maximum: {max_memory_mb}MB")]
MemoryLimitTooHigh { memory_mb: i32, max_memory_mb: i32 },
#[error("Invalid timeout value {timeout_seconds}s. Valid range: {min_seconds}s - {max_seconds}s")]
InvalidTimeout { timeout_seconds: i32, min_seconds: i32, max_seconds: i32 },
#[error("DPI value {dpi} is invalid. Valid range: {min_dpi} - {max_dpi}")]
InvalidDpi { dpi: i32, min_dpi: i32, max_dpi: i32 },
#[error("Confidence threshold {confidence} is invalid. Valid range: 0.0 - 1.0")]
InvalidConfidenceThreshold { confidence: f32 },
#[error("Invalid character list for '{list_type}': {details}")]
InvalidCharacterList { list_type: String, details: String },
#[error("Conflicting settings: {setting1} and {setting2} cannot both be enabled")]
ConflictingSettings { setting1: String, setting2: String },
#[error("Permission denied: {reason}")]
PermissionDenied { reason: String },
#[error("Cannot reset system-wide settings")]
SystemSettingsReset,
#[error("Invalid search configuration: {details}")]
InvalidSearchConfiguration { details: String },
}
impl AppError for SettingsError {
fn status_code(&self) -> StatusCode {
match self {
SettingsError::NotFound | SettingsError::NotFoundForUser { .. } => StatusCode::NOT_FOUND,
SettingsError::InvalidLanguage { .. } => StatusCode::BAD_REQUEST,
SettingsError::InvalidValue { .. } => StatusCode::BAD_REQUEST,
SettingsError::ReadOnlySetting { .. } => StatusCode::FORBIDDEN,
SettingsError::ValidationFailed { .. } => StatusCode::BAD_REQUEST,
SettingsError::InvalidOcrConfiguration { .. } => StatusCode::BAD_REQUEST,
SettingsError::InvalidFileType { .. } => StatusCode::BAD_REQUEST,
SettingsError::ValueOutOfRange { .. } => StatusCode::BAD_REQUEST,
SettingsError::InvalidCpuPriority { .. } => StatusCode::BAD_REQUEST,
SettingsError::MemoryLimitTooLow { .. } => StatusCode::BAD_REQUEST,
SettingsError::MemoryLimitTooHigh { .. } => StatusCode::BAD_REQUEST,
SettingsError::InvalidTimeout { .. } => StatusCode::BAD_REQUEST,
SettingsError::InvalidDpi { .. } => StatusCode::BAD_REQUEST,
SettingsError::InvalidConfidenceThreshold { .. } => StatusCode::BAD_REQUEST,
SettingsError::InvalidCharacterList { .. } => StatusCode::BAD_REQUEST,
SettingsError::ConflictingSettings { .. } => StatusCode::CONFLICT,
SettingsError::PermissionDenied { .. } => StatusCode::FORBIDDEN,
SettingsError::SystemSettingsReset => StatusCode::FORBIDDEN,
SettingsError::InvalidSearchConfiguration { .. } => StatusCode::BAD_REQUEST,
}
}
fn user_message(&self) -> String {
match self {
SettingsError::NotFound | SettingsError::NotFoundForUser { .. } => "Settings not found".to_string(),
SettingsError::InvalidLanguage { .. } => "Invalid language specified".to_string(),
SettingsError::InvalidValue { setting_name, .. } => format!("Invalid value for {}", setting_name),
SettingsError::ReadOnlySetting { setting_name } => format!("Setting '{}' cannot be modified", setting_name),
SettingsError::ValidationFailed { setting_name, reason } => format!("Validation failed for {}: {}", setting_name, reason),
SettingsError::InvalidOcrConfiguration { .. } => "Invalid OCR configuration".to_string(),
SettingsError::InvalidFileType { .. } => "Invalid file type specified".to_string(),
SettingsError::ValueOutOfRange { setting_name, min, max, .. } => format!("{} must be between {} and {}", setting_name, min, max),
SettingsError::InvalidCpuPriority { .. } => "Invalid CPU priority. Use: low, normal, or high".to_string(),
SettingsError::MemoryLimitTooLow { min_memory_mb, .. } => format!("Memory limit too low. Minimum: {}MB", min_memory_mb),
SettingsError::MemoryLimitTooHigh { max_memory_mb, .. } => format!("Memory limit too high. Maximum: {}MB", max_memory_mb),
SettingsError::InvalidTimeout { min_seconds, max_seconds, .. } => format!("Timeout must be between {}s and {}s", min_seconds, max_seconds),
SettingsError::InvalidDpi { min_dpi, max_dpi, .. } => format!("DPI must be between {} and {}", min_dpi, max_dpi),
SettingsError::InvalidConfidenceThreshold { .. } => "Confidence threshold must be between 0.0 and 1.0".to_string(),
SettingsError::InvalidCharacterList { list_type, .. } => format!("Invalid {} character list", list_type),
SettingsError::ConflictingSettings { setting1, setting2 } => format!("Settings '{}' and '{}' conflict", setting1, setting2),
SettingsError::PermissionDenied { reason } => format!("Permission denied: {}", reason),
SettingsError::SystemSettingsReset => "System settings cannot be reset".to_string(),
SettingsError::InvalidSearchConfiguration { .. } => "Invalid search configuration".to_string(),
}
}
fn error_code(&self) -> &'static str {
match self {
SettingsError::NotFound => "SETTINGS_NOT_FOUND",
SettingsError::NotFoundForUser { .. } => "SETTINGS_NOT_FOUND_FOR_USER",
SettingsError::InvalidLanguage { .. } => "SETTINGS_INVALID_LANGUAGE",
SettingsError::InvalidValue { .. } => "SETTINGS_INVALID_VALUE",
SettingsError::ReadOnlySetting { .. } => "SETTINGS_READ_ONLY",
SettingsError::ValidationFailed { .. } => "SETTINGS_VALIDATION_FAILED",
SettingsError::InvalidOcrConfiguration { .. } => "SETTINGS_INVALID_OCR_CONFIG",
SettingsError::InvalidFileType { .. } => "SETTINGS_INVALID_FILE_TYPE",
SettingsError::ValueOutOfRange { .. } => "SETTINGS_VALUE_OUT_OF_RANGE",
SettingsError::InvalidCpuPriority { .. } => "SETTINGS_INVALID_CPU_PRIORITY",
SettingsError::MemoryLimitTooLow { .. } => "SETTINGS_MEMORY_LIMIT_TOO_LOW",
SettingsError::MemoryLimitTooHigh { .. } => "SETTINGS_MEMORY_LIMIT_TOO_HIGH",
SettingsError::InvalidTimeout { .. } => "SETTINGS_INVALID_TIMEOUT",
SettingsError::InvalidDpi { .. } => "SETTINGS_INVALID_DPI",
SettingsError::InvalidConfidenceThreshold { .. } => "SETTINGS_INVALID_CONFIDENCE",
SettingsError::InvalidCharacterList { .. } => "SETTINGS_INVALID_CHARACTER_LIST",
SettingsError::ConflictingSettings { .. } => "SETTINGS_CONFLICTING",
SettingsError::PermissionDenied { .. } => "SETTINGS_PERMISSION_DENIED",
SettingsError::SystemSettingsReset => "SETTINGS_SYSTEM_RESET_DENIED",
SettingsError::InvalidSearchConfiguration { .. } => "SETTINGS_INVALID_SEARCH_CONFIG",
}
}
fn error_category(&self) -> ErrorCategory {
match self {
SettingsError::PermissionDenied { .. } | SettingsError::SystemSettingsReset => ErrorCategory::Auth,
SettingsError::InvalidOcrConfiguration { .. } => ErrorCategory::OcrProcessing,
_ => ErrorCategory::Config,
}
}
fn error_severity(&self) -> ErrorSeverity {
match self {
SettingsError::ReadOnlySetting { .. }
| SettingsError::PermissionDenied { .. }
| SettingsError::SystemSettingsReset => ErrorSeverity::Important,
SettingsError::InvalidOcrConfiguration { .. }
| SettingsError::ConflictingSettings { .. } => ErrorSeverity::Important,
SettingsError::NotFound | SettingsError::NotFoundForUser { .. } => ErrorSeverity::Expected,
_ => ErrorSeverity::Minor,
}
}
fn suppression_key(&self) -> Option<String> {
match self {
SettingsError::NotFound => Some("settings_not_found".to_string()),
SettingsError::InvalidLanguage { language, .. } => Some(format!("settings_invalid_language_{}", language)),
_ => None,
}
}
fn suggested_action(&self) -> Option<String> {
match self {
SettingsError::InvalidLanguage { available_languages, .. } => Some(format!("Choose from available languages: {}", available_languages)),
SettingsError::InvalidFileType { supported_types, .. } => Some(format!("Use supported file types: {}", supported_types)),
SettingsError::ValueOutOfRange { min, max, .. } => Some(format!("Enter a value between {} and {}", min, max)),
SettingsError::InvalidCpuPriority { .. } => Some("Use 'low', 'normal', or 'high' for CPU priority".to_string()),
SettingsError::MemoryLimitTooLow { min_memory_mb, .. } => Some(format!("Set memory limit to at least {}MB", min_memory_mb)),
SettingsError::MemoryLimitTooHigh { max_memory_mb, .. } => Some(format!("Set memory limit to at most {}MB", max_memory_mb)),
SettingsError::InvalidTimeout { min_seconds, max_seconds, .. } => Some(format!("Set timeout between {}s and {}s", min_seconds, max_seconds)),
SettingsError::InvalidDpi { min_dpi, max_dpi, .. } => Some(format!("Set DPI between {} and {}", min_dpi, max_dpi)),
SettingsError::InvalidConfidenceThreshold { .. } => Some("Set confidence threshold between 0.0 and 1.0".to_string()),
SettingsError::ConflictingSettings { setting1, setting2 } => Some(format!("Disable either '{}' or '{}' to resolve the conflict", setting1, setting2)),
SettingsError::ReadOnlySetting { .. } => Some("This setting cannot be modified through the API".to_string()),
_ => None,
}
}
}
impl_into_response!(SettingsError);
/// Convenience methods for creating common settings errors
impl SettingsError {
pub fn not_found_for_user(user_id: Uuid) -> Self {
Self::NotFoundForUser { user_id }
}
pub fn invalid_language<S: Into<String>>(language: S, available_languages: S) -> Self {
Self::InvalidLanguage {
language: language.into(),
available_languages: available_languages.into()
}
}
pub fn invalid_value<S: Into<String>>(setting_name: S, value: S, constraint: S) -> Self {
Self::InvalidValue {
setting_name: setting_name.into(),
value: value.into(),
constraint: constraint.into()
}
}
pub fn read_only_setting<S: Into<String>>(setting_name: S) -> Self {
Self::ReadOnlySetting { setting_name: setting_name.into() }
}
pub fn validation_failed<S: Into<String>>(setting_name: S, reason: S) -> Self {
Self::ValidationFailed {
setting_name: setting_name.into(),
reason: reason.into()
}
}
pub fn invalid_ocr_configuration<S: Into<String>>(details: S) -> Self {
Self::InvalidOcrConfiguration { details: details.into() }
}
pub fn invalid_file_type<S: Into<String>>(file_type: S, supported_types: S) -> Self {
Self::InvalidFileType {
file_type: file_type.into(),
supported_types: supported_types.into()
}
}
pub fn value_out_of_range<S: Into<String>>(setting_name: S, value: i32, min: i32, max: i32) -> Self {
Self::ValueOutOfRange {
setting_name: setting_name.into(),
value,
min,
max
}
}
pub fn invalid_cpu_priority<S: Into<String>>(priority: S) -> Self {
Self::InvalidCpuPriority { priority: priority.into() }
}
pub fn memory_limit_too_low(memory_mb: i32, min_memory_mb: i32) -> Self {
Self::MemoryLimitTooLow { memory_mb, min_memory_mb }
}
pub fn memory_limit_too_high(memory_mb: i32, max_memory_mb: i32) -> Self {
Self::MemoryLimitTooHigh { memory_mb, max_memory_mb }
}
pub fn invalid_timeout(timeout_seconds: i32, min_seconds: i32, max_seconds: i32) -> Self {
Self::InvalidTimeout { timeout_seconds, min_seconds, max_seconds }
}
pub fn invalid_dpi(dpi: i32, min_dpi: i32, max_dpi: i32) -> Self {
Self::InvalidDpi { dpi, min_dpi, max_dpi }
}
pub fn invalid_confidence_threshold(confidence: f32) -> Self {
Self::InvalidConfidenceThreshold { confidence }
}
pub fn invalid_character_list<S: Into<String>>(list_type: S, details: S) -> Self {
Self::InvalidCharacterList {
list_type: list_type.into(),
details: details.into()
}
}
pub fn conflicting_settings<S: Into<String>>(setting1: S, setting2: S) -> Self {
Self::ConflictingSettings {
setting1: setting1.into(),
setting2: setting2.into()
}
}
pub fn permission_denied<S: Into<String>>(reason: S) -> Self {
Self::PermissionDenied { reason: reason.into() }
}
pub fn invalid_search_configuration<S: Into<String>>(details: S) -> Self {
Self::InvalidSearchConfiguration { details: details.into() }
}
}

325
src/errors/source.rs Normal file
View File

@ -0,0 +1,325 @@
use axum::http::StatusCode;
use thiserror::Error;
use uuid::Uuid;
use super::{AppError, ErrorCategory, ErrorSeverity, impl_into_response};
/// Errors related to file source operations (WebDAV, Local Folder, S3)
#[derive(Error, Debug)]
pub enum SourceError {
#[error("Source not found")]
NotFound,
#[error("Source with ID {id} not found")]
NotFoundById { id: Uuid },
#[error("Source name '{name}' already exists")]
DuplicateName { name: String },
#[error("Invalid source path: {path}")]
InvalidPath { path: String },
#[error("Connection failed: {details}")]
ConnectionFailed { details: String },
#[error("Authentication failed for source '{name}': {reason}")]
AuthenticationFailed { name: String, reason: String },
#[error("Sync operation already in progress for source '{name}'")]
SyncInProgress { name: String },
#[error("Invalid source configuration: {details}")]
ConfigurationInvalid { details: String },
#[error("Access denied to path '{path}': {reason}")]
AccessDenied { path: String, reason: String },
#[error("Source '{name}' is disabled")]
SourceDisabled { name: String },
#[error("Invalid source type '{source_type}'. Valid types are: webdav, local_folder, s3")]
InvalidSourceType { source_type: String },
#[error("Network timeout connecting to '{url}' after {timeout_seconds} seconds")]
NetworkTimeout { url: String, timeout_seconds: u64 },
#[error("Source capacity exceeded: {details}")]
CapacityExceeded { details: String },
#[error("Server error from '{server}': {error_code} - {message}")]
ServerError { server: String, error_code: u16, message: String },
#[error("SSL/TLS certificate error for '{server}': {details}")]
CertificateError { server: String, details: String },
#[error("Unsupported server version '{version}' for source type '{source_type}'")]
UnsupportedServerVersion { version: String, source_type: String },
#[error("Rate limit exceeded for source '{name}': {retry_after_seconds} seconds until retry")]
RateLimitExceeded { name: String, retry_after_seconds: u64 },
#[error("File not found: {path}")]
FileNotFound { path: String },
#[error("Directory not found: {path}")]
DirectoryNotFound { path: String },
#[error("Validation failed: {issues}")]
ValidationFailed { issues: String },
#[error("Sync operation failed: {reason}")]
SyncFailed { reason: String },
#[error("Cannot delete source '{name}': {reason}")]
DeleteRestricted { name: String, reason: String },
}
impl AppError for SourceError {
fn status_code(&self) -> StatusCode {
match self {
SourceError::NotFound | SourceError::NotFoundById { .. } => StatusCode::NOT_FOUND,
SourceError::DuplicateName { .. } => StatusCode::CONFLICT,
SourceError::InvalidPath { .. } => StatusCode::BAD_REQUEST,
SourceError::ConnectionFailed { .. } => StatusCode::BAD_GATEWAY,
SourceError::AuthenticationFailed { .. } => StatusCode::UNAUTHORIZED,
SourceError::SyncInProgress { .. } => StatusCode::CONFLICT,
SourceError::ConfigurationInvalid { .. } => StatusCode::BAD_REQUEST,
SourceError::AccessDenied { .. } => StatusCode::FORBIDDEN,
SourceError::SourceDisabled { .. } => StatusCode::FORBIDDEN,
SourceError::InvalidSourceType { .. } => StatusCode::BAD_REQUEST,
SourceError::NetworkTimeout { .. } => StatusCode::GATEWAY_TIMEOUT,
SourceError::CapacityExceeded { .. } => StatusCode::INSUFFICIENT_STORAGE,
SourceError::ServerError { .. } => StatusCode::BAD_GATEWAY,
SourceError::CertificateError { .. } => StatusCode::BAD_GATEWAY,
SourceError::UnsupportedServerVersion { .. } => StatusCode::NOT_IMPLEMENTED,
SourceError::RateLimitExceeded { .. } => StatusCode::TOO_MANY_REQUESTS,
SourceError::FileNotFound { .. } => StatusCode::NOT_FOUND,
SourceError::DirectoryNotFound { .. } => StatusCode::NOT_FOUND,
SourceError::ValidationFailed { .. } => StatusCode::BAD_REQUEST,
SourceError::SyncFailed { .. } => StatusCode::INTERNAL_SERVER_ERROR,
SourceError::DeleteRestricted { .. } => StatusCode::CONFLICT,
}
}
fn user_message(&self) -> String {
match self {
SourceError::NotFound | SourceError::NotFoundById { .. } => "Source not found".to_string(),
SourceError::DuplicateName { .. } => "A source with this name already exists".to_string(),
SourceError::InvalidPath { .. } => "Invalid file path specified".to_string(),
SourceError::ConnectionFailed { .. } => "Unable to connect to the source".to_string(),
SourceError::AuthenticationFailed { .. } => "Authentication failed - please check credentials".to_string(),
SourceError::SyncInProgress { .. } => "Sync operation is already running".to_string(),
SourceError::ConfigurationInvalid { details } => format!("Invalid configuration: {}", details),
SourceError::AccessDenied { .. } => "Access denied to the specified path".to_string(),
SourceError::SourceDisabled { .. } => "Source is currently disabled".to_string(),
SourceError::InvalidSourceType { .. } => "Invalid source type specified".to_string(),
SourceError::NetworkTimeout { .. } => "Connection timed out".to_string(),
SourceError::CapacityExceeded { details } => format!("Capacity exceeded: {}", details),
SourceError::ServerError { .. } => "Server returned an error".to_string(),
SourceError::CertificateError { .. } => "SSL certificate error".to_string(),
SourceError::UnsupportedServerVersion { .. } => "Unsupported server version".to_string(),
SourceError::RateLimitExceeded { retry_after_seconds, .. } => format!("Rate limit exceeded, try again in {} seconds", retry_after_seconds),
SourceError::FileNotFound { .. } => "File not found".to_string(),
SourceError::DirectoryNotFound { .. } => "Directory not found".to_string(),
SourceError::ValidationFailed { issues } => format!("Validation failed: {}", issues),
SourceError::SyncFailed { reason } => format!("Sync failed: {}", reason),
SourceError::DeleteRestricted { reason, .. } => format!("Cannot delete source: {}", reason),
}
}
fn error_code(&self) -> &'static str {
match self {
SourceError::NotFound => "SOURCE_NOT_FOUND",
SourceError::NotFoundById { .. } => "SOURCE_NOT_FOUND_BY_ID",
SourceError::DuplicateName { .. } => "SOURCE_DUPLICATE_NAME",
SourceError::InvalidPath { .. } => "SOURCE_INVALID_PATH",
SourceError::ConnectionFailed { .. } => "SOURCE_CONNECTION_FAILED",
SourceError::AuthenticationFailed { .. } => "SOURCE_AUTH_FAILED",
SourceError::SyncInProgress { .. } => "SOURCE_SYNC_IN_PROGRESS",
SourceError::ConfigurationInvalid { .. } => "SOURCE_CONFIG_INVALID",
SourceError::AccessDenied { .. } => "SOURCE_ACCESS_DENIED",
SourceError::SourceDisabled { .. } => "SOURCE_DISABLED",
SourceError::InvalidSourceType { .. } => "SOURCE_INVALID_TYPE",
SourceError::NetworkTimeout { .. } => "SOURCE_NETWORK_TIMEOUT",
SourceError::CapacityExceeded { .. } => "SOURCE_CAPACITY_EXCEEDED",
SourceError::ServerError { .. } => "SOURCE_SERVER_ERROR",
SourceError::CertificateError { .. } => "SOURCE_CERTIFICATE_ERROR",
SourceError::UnsupportedServerVersion { .. } => "SOURCE_UNSUPPORTED_VERSION",
SourceError::RateLimitExceeded { .. } => "SOURCE_RATE_LIMITED",
SourceError::FileNotFound { .. } => "SOURCE_FILE_NOT_FOUND",
SourceError::DirectoryNotFound { .. } => "SOURCE_DIRECTORY_NOT_FOUND",
SourceError::ValidationFailed { .. } => "SOURCE_VALIDATION_FAILED",
SourceError::SyncFailed { .. } => "SOURCE_SYNC_FAILED",
SourceError::DeleteRestricted { .. } => "SOURCE_DELETE_RESTRICTED",
}
}
fn error_category(&self) -> ErrorCategory {
match self {
SourceError::ConnectionFailed { .. }
| SourceError::NetworkTimeout { .. }
| SourceError::ServerError { .. }
| SourceError::CertificateError { .. } => ErrorCategory::Network,
SourceError::ConfigurationInvalid { .. }
| SourceError::InvalidSourceType { .. }
| SourceError::UnsupportedServerVersion { .. } => ErrorCategory::Config,
SourceError::AuthenticationFailed { .. }
| SourceError::AccessDenied { .. } => ErrorCategory::Auth,
SourceError::InvalidPath { .. }
| SourceError::FileNotFound { .. }
| SourceError::DirectoryNotFound { .. } => ErrorCategory::FileSystem,
_ => ErrorCategory::FileSystem, // Default for source-related operations
}
}
fn error_severity(&self) -> ErrorSeverity {
match self {
SourceError::ConfigurationInvalid { .. }
| SourceError::AuthenticationFailed { .. } => ErrorSeverity::Critical,
SourceError::ConnectionFailed { .. }
| SourceError::NetworkTimeout { .. }
| SourceError::ServerError { .. }
| SourceError::SyncFailed { .. } => ErrorSeverity::Important,
SourceError::SyncInProgress { .. }
| SourceError::RateLimitExceeded { .. } => ErrorSeverity::Expected,
_ => ErrorSeverity::Minor,
}
}
fn suppression_key(&self) -> Option<String> {
match self {
SourceError::ConnectionFailed { .. } => Some("source_connection_failed".to_string()),
SourceError::NetworkTimeout { .. } => Some("source_network_timeout".to_string()),
SourceError::RateLimitExceeded { name, .. } => Some(format!("source_rate_limit_{}", name)),
_ => None,
}
}
fn suggested_action(&self) -> Option<String> {
match self {
SourceError::DuplicateName { .. } => Some("Please choose a different name for the source".to_string()),
SourceError::ConnectionFailed { .. } => Some("Check network connectivity and server URL".to_string()),
SourceError::AuthenticationFailed { .. } => Some("Verify username and password are correct".to_string()),
SourceError::ConfigurationInvalid { .. } => Some("Review and correct the source configuration".to_string()),
SourceError::NetworkTimeout { .. } => Some("Check network connection and try again".to_string()),
SourceError::CertificateError { .. } => Some("Verify SSL certificate or contact server administrator".to_string()),
SourceError::RateLimitExceeded { retry_after_seconds, .. } => Some(format!("Wait {} seconds before retrying", retry_after_seconds)),
SourceError::SourceDisabled { .. } => Some("Enable the source to continue operations".to_string()),
_ => None,
}
}
}
impl_into_response!(SourceError);
/// Convenience methods for creating common source errors
impl SourceError {
pub fn not_found_by_id(id: Uuid) -> Self {
Self::NotFoundById { id }
}
pub fn duplicate_name<S: Into<String>>(name: S) -> Self {
Self::DuplicateName { name: name.into() }
}
pub fn invalid_path<S: Into<String>>(path: S) -> Self {
Self::InvalidPath { path: path.into() }
}
pub fn connection_failed<S: Into<String>>(details: S) -> Self {
Self::ConnectionFailed { details: details.into() }
}
pub fn authentication_failed<S: Into<String>>(name: S, reason: S) -> Self {
Self::AuthenticationFailed {
name: name.into(),
reason: reason.into()
}
}
pub fn sync_in_progress<S: Into<String>>(name: S) -> Self {
Self::SyncInProgress { name: name.into() }
}
pub fn configuration_invalid<S: Into<String>>(details: S) -> Self {
Self::ConfigurationInvalid { details: details.into() }
}
pub fn access_denied<S: Into<String>>(path: S, reason: S) -> Self {
Self::AccessDenied {
path: path.into(),
reason: reason.into()
}
}
pub fn source_disabled<S: Into<String>>(name: S) -> Self {
Self::SourceDisabled { name: name.into() }
}
pub fn invalid_source_type<S: Into<String>>(source_type: S) -> Self {
Self::InvalidSourceType { source_type: source_type.into() }
}
pub fn network_timeout<S: Into<String>>(url: S, timeout_seconds: u64) -> Self {
Self::NetworkTimeout {
url: url.into(),
timeout_seconds
}
}
pub fn capacity_exceeded<S: Into<String>>(details: S) -> Self {
Self::CapacityExceeded { details: details.into() }
}
pub fn server_error<S: Into<String>>(server: S, error_code: u16, message: S) -> Self {
Self::ServerError {
server: server.into(),
error_code,
message: message.into()
}
}
pub fn certificate_error<S: Into<String>>(server: S, details: S) -> Self {
Self::CertificateError {
server: server.into(),
details: details.into()
}
}
pub fn unsupported_server_version<S: Into<String>>(version: S, source_type: S) -> Self {
Self::UnsupportedServerVersion {
version: version.into(),
source_type: source_type.into()
}
}
pub fn rate_limit_exceeded<S: Into<String>>(name: S, retry_after_seconds: u64) -> Self {
Self::RateLimitExceeded {
name: name.into(),
retry_after_seconds
}
}
pub fn file_not_found<S: Into<String>>(path: S) -> Self {
Self::FileNotFound { path: path.into() }
}
pub fn directory_not_found<S: Into<String>>(path: S) -> Self {
Self::DirectoryNotFound { path: path.into() }
}
pub fn validation_failed<S: Into<String>>(issues: S) -> Self {
Self::ValidationFailed { issues: issues.into() }
}
pub fn sync_failed<S: Into<String>>(reason: S) -> Self {
Self::SyncFailed { reason: reason.into() }
}
pub fn delete_restricted<S: Into<String>>(name: S, reason: S) -> Self {
Self::DeleteRestricted {
name: name.into(),
reason: reason.into()
}
}
}

224
src/errors/user.rs Normal file
View File

@ -0,0 +1,224 @@
use axum::http::StatusCode;
use thiserror::Error;
use uuid::Uuid;
use super::{AppError, ErrorCategory, ErrorSeverity, impl_into_response};
/// Errors related to user management operations
#[derive(Error, Debug)]
pub enum UserError {
#[error("User not found")]
NotFound,
#[error("User with ID {id} not found")]
NotFoundById { id: Uuid },
#[error("Username '{username}' already exists")]
DuplicateUsername { username: String },
#[error("Email '{email}' already exists")]
DuplicateEmail { email: String },
#[error("Invalid role '{role}'. Valid roles are: admin, user")]
InvalidRole { role: String },
#[error("Permission denied: {reason}")]
PermissionDenied { reason: String },
#[error("Invalid credentials")]
InvalidCredentials,
#[error("Account is disabled")]
AccountDisabled,
#[error("Password does not meet requirements: {requirements}")]
InvalidPassword { requirements: String },
#[error("Username '{username}' is invalid: {reason}")]
InvalidUsername { username: String, reason: String },
#[error("Email '{email}' is invalid")]
InvalidEmail { email: String },
#[error("Cannot delete user with ID {id}: {reason}")]
DeleteRestricted { id: Uuid, reason: String },
#[error("OIDC authentication failed: {details}")]
OidcAuthenticationFailed { details: String },
#[error("Authentication provider '{provider}' is not configured")]
AuthProviderNotConfigured { provider: String },
#[error("Token has expired")]
TokenExpired,
#[error("Invalid token format")]
InvalidToken,
#[error("User session has expired, please login again")]
SessionExpired,
#[error("Internal server error: {message}")]
InternalServerError { message: String },
}
impl AppError for UserError {
fn status_code(&self) -> StatusCode {
match self {
UserError::NotFound | UserError::NotFoundById { .. } => StatusCode::NOT_FOUND,
UserError::DuplicateUsername { .. } | UserError::DuplicateEmail { .. } => StatusCode::CONFLICT,
UserError::InvalidRole { .. } => StatusCode::BAD_REQUEST,
UserError::PermissionDenied { .. } => StatusCode::FORBIDDEN,
UserError::InvalidCredentials => StatusCode::UNAUTHORIZED,
UserError::AccountDisabled => StatusCode::FORBIDDEN,
UserError::InvalidPassword { .. } => StatusCode::BAD_REQUEST,
UserError::InvalidUsername { .. } => StatusCode::BAD_REQUEST,
UserError::InvalidEmail { .. } => StatusCode::BAD_REQUEST,
UserError::DeleteRestricted { .. } => StatusCode::CONFLICT,
UserError::OidcAuthenticationFailed { .. } => StatusCode::UNAUTHORIZED,
UserError::AuthProviderNotConfigured { .. } => StatusCode::BAD_REQUEST,
UserError::TokenExpired => StatusCode::UNAUTHORIZED,
UserError::InvalidToken => StatusCode::UNAUTHORIZED,
UserError::SessionExpired => StatusCode::UNAUTHORIZED,
UserError::InternalServerError { .. } => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn user_message(&self) -> String {
match self {
UserError::NotFound | UserError::NotFoundById { .. } => "User not found".to_string(),
UserError::DuplicateUsername { .. } => "Username already exists".to_string(),
UserError::DuplicateEmail { .. } => "Email already exists".to_string(),
UserError::InvalidRole { .. } => "Invalid user role specified".to_string(),
UserError::PermissionDenied { reason } => format!("Permission denied: {}", reason),
UserError::InvalidCredentials => "Invalid username or password".to_string(),
UserError::AccountDisabled => "Account is disabled".to_string(),
UserError::InvalidPassword { requirements } => format!("Password does not meet requirements: {}", requirements),
UserError::InvalidUsername { reason, .. } => format!("Invalid username: {}", reason),
UserError::InvalidEmail { .. } => "Invalid email address".to_string(),
UserError::DeleteRestricted { reason, .. } => format!("Cannot delete user: {}", reason),
UserError::OidcAuthenticationFailed { .. } => "OIDC authentication failed".to_string(),
UserError::AuthProviderNotConfigured { .. } => "Authentication provider not configured".to_string(),
UserError::TokenExpired => "Token has expired".to_string(),
UserError::InvalidToken => "Invalid token".to_string(),
UserError::SessionExpired => "Session has expired, please login again".to_string(),
UserError::InternalServerError { .. } => "An internal error occurred".to_string(),
}
}
fn error_code(&self) -> &'static str {
match self {
UserError::NotFound => "USER_NOT_FOUND",
UserError::NotFoundById { .. } => "USER_NOT_FOUND_BY_ID",
UserError::DuplicateUsername { .. } => "USER_DUPLICATE_USERNAME",
UserError::DuplicateEmail { .. } => "USER_DUPLICATE_EMAIL",
UserError::InvalidRole { .. } => "USER_INVALID_ROLE",
UserError::PermissionDenied { .. } => "USER_PERMISSION_DENIED",
UserError::InvalidCredentials => "USER_INVALID_CREDENTIALS",
UserError::AccountDisabled => "USER_ACCOUNT_DISABLED",
UserError::InvalidPassword { .. } => "USER_INVALID_PASSWORD",
UserError::InvalidUsername { .. } => "USER_INVALID_USERNAME",
UserError::InvalidEmail { .. } => "USER_INVALID_EMAIL",
UserError::DeleteRestricted { .. } => "USER_DELETE_RESTRICTED",
UserError::OidcAuthenticationFailed { .. } => "USER_OIDC_AUTH_FAILED",
UserError::AuthProviderNotConfigured { .. } => "USER_AUTH_PROVIDER_NOT_CONFIGURED",
UserError::TokenExpired => "USER_TOKEN_EXPIRED",
UserError::InvalidToken => "USER_INVALID_TOKEN",
UserError::SessionExpired => "USER_SESSION_EXPIRED",
UserError::InternalServerError { .. } => "USER_INTERNAL_SERVER_ERROR",
}
}
fn error_category(&self) -> ErrorCategory {
ErrorCategory::Auth
}
fn error_severity(&self) -> ErrorSeverity {
match self {
UserError::PermissionDenied { .. } | UserError::DeleteRestricted { .. } => ErrorSeverity::Important,
UserError::OidcAuthenticationFailed { .. } | UserError::AuthProviderNotConfigured { .. } => ErrorSeverity::Critical,
UserError::InvalidCredentials | UserError::AccountDisabled => ErrorSeverity::Expected,
UserError::InternalServerError { .. } => ErrorSeverity::Critical,
_ => ErrorSeverity::Minor,
}
}
fn suppression_key(&self) -> Option<String> {
match self {
UserError::InvalidCredentials => Some("user_invalid_credentials".to_string()),
UserError::NotFound => Some("user_not_found".to_string()),
_ => None,
}
}
fn suggested_action(&self) -> Option<String> {
match self {
UserError::DuplicateUsername { .. } => Some("Please choose a different username".to_string()),
UserError::DuplicateEmail { .. } => Some("Please use a different email address".to_string()),
UserError::InvalidPassword { .. } => Some("Password must be at least 8 characters long and contain uppercase, lowercase, and numbers".to_string()),
UserError::InvalidCredentials => Some("Please check your username and password".to_string()),
UserError::SessionExpired | UserError::TokenExpired => Some("Please login again".to_string()),
UserError::AccountDisabled => Some("Please contact an administrator".to_string()),
_ => None,
}
}
}
impl_into_response!(UserError);
/// Convenience methods for creating common user errors
impl UserError {
pub fn not_found_by_id(id: Uuid) -> Self {
Self::NotFoundById { id }
}
pub fn duplicate_username<S: Into<String>>(username: S) -> Self {
Self::DuplicateUsername { username: username.into() }
}
pub fn duplicate_email<S: Into<String>>(email: S) -> Self {
Self::DuplicateEmail { email: email.into() }
}
pub fn invalid_role<S: Into<String>>(role: S) -> Self {
Self::InvalidRole { role: role.into() }
}
pub fn permission_denied<S: Into<String>>(reason: S) -> Self {
Self::PermissionDenied { reason: reason.into() }
}
pub fn invalid_password<S: Into<String>>(requirements: S) -> Self {
Self::InvalidPassword { requirements: requirements.into() }
}
pub fn invalid_username<S: Into<String>>(username: S, reason: S) -> Self {
Self::InvalidUsername {
username: username.into(),
reason: reason.into()
}
}
pub fn invalid_email<S: Into<String>>(email: S) -> Self {
Self::InvalidEmail { email: email.into() }
}
pub fn delete_restricted<S: Into<String>>(id: Uuid, reason: S) -> Self {
Self::DeleteRestricted {
id,
reason: reason.into()
}
}
pub fn oidc_authentication_failed<S: Into<String>>(details: S) -> Self {
Self::OidcAuthenticationFailed { details: details.into() }
}
pub fn auth_provider_not_configured<S: Into<String>>(provider: S) -> Self {
Self::AuthProviderNotConfigured { provider: provider.into() }
}
pub fn internal_server_error<S: Into<String>>(message: S) -> Self {
Self::InternalServerError { message: message.into() }
}
}

View File

@ -2,6 +2,7 @@ pub mod auth;
pub mod config;
pub mod db;
pub mod db_guardrails_simple;
pub mod errors;
pub mod ingestion;
pub mod metadata_extraction;
pub mod models;

View File

@ -12,7 +12,7 @@ use uuid::Uuid;
use chrono::{DateTime, Utc};
use sqlx::{FromRow, Row};
use crate::{auth::AuthUser, AppState};
use crate::{auth::AuthUser, errors::label::LabelError, AppState};
#[derive(Debug, Clone, Serialize, Deserialize, FromRow, ToSchema)]
pub struct Label {

View File

@ -9,6 +9,7 @@ use std::sync::Arc;
use crate::{
auth::AuthUser,
errors::search::SearchError,
models::{SearchRequest, SearchResponse, EnhancedDocumentResponse, SearchFacetsResponse},
AppState,
};
@ -41,14 +42,34 @@ async fn search_documents(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Query(search_request): Query<SearchRequest>,
) -> Result<Json<SearchResponse>, StatusCode> {
) -> Result<Json<SearchResponse>, SearchError> {
// Validate query length
if search_request.query.len() < 2 {
return Err(SearchError::query_too_short(search_request.query.len(), 2));
}
if search_request.query.len() > 1000 {
return Err(SearchError::query_too_long(search_request.query.len(), 1000));
}
// Validate pagination
let limit = search_request.limit.unwrap_or(25);
let offset = search_request.offset.unwrap_or(0);
if limit > 1000 || offset < 0 || limit <= 0 {
return Err(SearchError::invalid_pagination(offset, limit));
}
let documents = state
.db
.search_documents(auth_user.user.id, &search_request)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|e| SearchError::index_unavailable(format!("Search failed: {}", e)))?;
let total = documents.len() as i64;
// Check if too many results
if total > 10000 {
return Err(SearchError::too_many_results(total, 10000));
}
let response = SearchResponse {
documents: documents.into_iter().map(|doc| EnhancedDocumentResponse {

View File

@ -9,6 +9,7 @@ use std::sync::Arc;
use crate::{
auth::AuthUser,
errors::settings::SettingsError,
models::{SettingsResponse, UpdateSettings, UserRole},
AppState,
};
@ -36,12 +37,12 @@ pub fn router() -> Router<Arc<AppState>> {
async fn get_settings(
auth_user: AuthUser,
State(state): State<Arc<AppState>>,
) -> Result<Json<SettingsResponse>, StatusCode> {
) -> Result<Json<SettingsResponse>, SettingsError> {
let settings = state
.db
.get_user_settings(auth_user.user.id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|e| SettingsError::invalid_value("database", &format!("Failed to fetch settings: {}", e), "Settings must be accessible"))?;
let response = match settings {
Some(s) => s.into(),

View File

@ -9,6 +9,7 @@ use tracing::{error, info};
use crate::{
auth::AuthUser,
errors::source::SourceError,
models::{CreateSource, SourceResponse, SourceWithStats, UpdateSource, SourceType},
AppState,
};
@ -30,12 +31,12 @@ use crate::{
pub async fn list_sources(
auth_user: AuthUser,
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<SourceResponse>>, StatusCode> {
) -> Result<Json<Vec<SourceResponse>>, SourceError> {
let sources = state
.db
.get_sources(auth_user.user.id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|e| SourceError::connection_failed(format!("Failed to retrieve sources: {}", e)))?;
// Get source IDs for batch counting
let source_ids: Vec<Uuid> = sources.iter().map(|s| s.id).collect();
@ -45,7 +46,7 @@ pub async fn list_sources(
.db
.count_documents_for_sources(auth_user.user.id, &source_ids)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|e| SourceError::connection_failed(format!("Failed to count documents: {}", e)))?;
// Create a map for quick lookup
let count_map: std::collections::HashMap<Uuid, (i64, i64)> = counts
@ -87,12 +88,12 @@ pub async fn create_source(
auth_user: AuthUser,
State(state): State<Arc<AppState>>,
Json(source_data): Json<CreateSource>,
) -> Result<Json<SourceResponse>, StatusCode> {
) -> Result<Json<SourceResponse>, SourceError> {
// Validate source configuration based on type
if let Err(validation_error) = validate_source_config(&source_data) {
error!("Source validation failed: {}", validation_error);
error!("Invalid source data received: {:?}", source_data);
return Err(StatusCode::BAD_REQUEST);
return Err(SourceError::configuration_invalid(validation_error));
}
let source = state
@ -101,7 +102,12 @@ pub async fn create_source(
.await
.map_err(|e| {
error!("Failed to create source in database: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
let error_msg = e.to_string();
if error_msg.contains("name") && error_msg.contains("unique") {
SourceError::duplicate_name(&source_data.name)
} else {
SourceError::connection_failed(format!("Database error: {}", e))
}
})?;
let mut response: SourceResponse = source.into();

View File

@ -10,13 +10,14 @@ use uuid::Uuid;
use crate::{
auth::AuthUser,
errors::user::UserError,
models::{CreateUser, UpdateUser, UserResponse, UserRole},
AppState,
};
fn require_admin(auth_user: &AuthUser) -> Result<(), StatusCode> {
fn require_admin(auth_user: &AuthUser) -> Result<(), UserError> {
if auth_user.user.role != UserRole::Admin {
Err(StatusCode::FORBIDDEN)
Err(UserError::permission_denied("Admin access required"))
} else {
Ok(())
}
@ -45,13 +46,13 @@ pub fn router() -> Router<Arc<AppState>> {
async fn list_users(
auth_user: AuthUser,
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<UserResponse>>, StatusCode> {
) -> Result<Json<Vec<UserResponse>>, UserError> {
require_admin(&auth_user)?;
let users = state
.db
.get_all_users()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|e| UserError::internal_server_error(format!("Failed to fetch users: {}", e)))?;
let user_responses: Vec<UserResponse> = users.into_iter().map(|u| u.into()).collect();
Ok(Json(user_responses))
@ -79,14 +80,14 @@ async fn get_user(
auth_user: AuthUser,
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<UserResponse>, StatusCode> {
) -> Result<Json<UserResponse>, UserError> {
require_admin(&auth_user)?;
let user = state
.db
.get_user_by_id(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
.map_err(|e| UserError::internal_server_error(format!("Failed to fetch user: {}", e)))?
.ok_or_else(|| UserError::not_found_by_id(id))?;
Ok(Json(user.into()))
}
@ -111,13 +112,23 @@ async fn create_user(
auth_user: AuthUser,
State(state): State<Arc<AppState>>,
Json(user_data): Json<CreateUser>,
) -> Result<Json<UserResponse>, StatusCode> {
) -> Result<Json<UserResponse>, UserError> {
require_admin(&auth_user)?;
let user = state
.db
.create_user(user_data)
.await
.map_err(|_| StatusCode::BAD_REQUEST)?;
.map_err(|e| {
let error_msg = e.to_string();
if error_msg.contains("username") && error_msg.contains("unique") {
UserError::duplicate_username(&error_msg)
} else if error_msg.contains("email") && error_msg.contains("unique") {
UserError::duplicate_email(&error_msg)
} else {
UserError::internal_server_error(format!("Failed to create user: {}", e))
}
})?;
Ok(Json(user.into()))
}
@ -146,13 +157,25 @@ async fn update_user(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(update_data): Json<UpdateUser>,
) -> Result<Json<UserResponse>, StatusCode> {
) -> Result<Json<UserResponse>, UserError> {
require_admin(&auth_user)?;
let user = state
.db
.update_user(id, update_data.username, update_data.email, update_data.password)
.await
.map_err(|_| StatusCode::BAD_REQUEST)?;
.map_err(|e| {
let error_msg = e.to_string();
if error_msg.contains("username") && error_msg.contains("unique") {
UserError::duplicate_username(&error_msg)
} else if error_msg.contains("email") && error_msg.contains("unique") {
UserError::duplicate_email(&error_msg)
} else if error_msg.contains("not found") {
UserError::not_found_by_id(id)
} else {
UserError::internal_server_error(format!("Failed to update user: {}", e))
}
})?;
Ok(Json(user.into()))
}
@ -179,19 +202,26 @@ async fn delete_user(
auth_user: AuthUser,
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, StatusCode> {
) -> Result<StatusCode, UserError> {
require_admin(&auth_user)?;
// Prevent users from deleting themselves
if auth_user.user.id == id {
return Err(StatusCode::FORBIDDEN);
return Err(UserError::delete_restricted(id, "Cannot delete your own account"));
}
state
.db
.delete_user(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|e| {
let error_msg = e.to_string();
if error_msg.contains("not found") {
UserError::not_found_by_id(id)
} else {
UserError::internal_server_error(format!("Failed to delete user: {}", e))
}
})?;
Ok(StatusCode::NO_CONTENT)
}

View File

@ -921,4 +921,148 @@ pub mod document_helpers {
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
assert_response_status_with_debug(response, expected_status, context).await
}
}
/// Enhanced request assertion helper that provides comprehensive debugging information
#[cfg(any(test, feature = "test-utils"))]
pub struct AssertRequest;
#[cfg(any(test, feature = "test-utils"))]
impl AssertRequest {
/// Assert response status with comprehensive debugging output including URL, payload, and response
pub async fn assert_response(
response: axum::response::Response,
expected_status: axum::http::StatusCode,
context: &str,
original_url: &str,
payload: Option<&serde_json::Value>,
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
let actual_status = response.status();
let headers = response.headers().clone();
// Extract response body
let response_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
let response_text = String::from_utf8_lossy(&response_bytes);
println!("🔍 AssertRequest Debug Info for: {}", context);
println!("🔗 Request URL: {}", original_url);
if let Some(payload) = payload {
println!("📤 Request Payload:");
println!("{}", serde_json::to_string_pretty(payload).unwrap_or_else(|_| "Invalid JSON payload".to_string()));
} else {
println!("📤 Request Payload: (empty)");
}
println!("📊 Response Status: {} (expected: {})", actual_status, expected_status);
println!("📋 Response Headers:");
for (name, value) in headers.iter() {
println!(" {}: {}", name, value.to_str().unwrap_or("<invalid header>"));
}
println!("📝 Response Body ({} bytes):", response_bytes.len());
if response_text.is_empty() {
println!(" (empty response)");
} else {
// Try to format as JSON for better readability
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&response_text) {
println!("{}", serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| response_text.to_string()));
} else {
println!("{}", response_text);
}
}
if actual_status == expected_status {
println!("{} - Status {} as expected", context, expected_status);
if response_text.is_empty() {
Ok(serde_json::Value::Null)
} else {
match serde_json::from_str::<serde_json::Value>(&response_text) {
Ok(json_value) => Ok(json_value),
Err(e) => {
println!("⚠️ JSON parse error: {}", e);
Err(format!("JSON parse error: {}", e).into())
}
}
}
} else {
println!("{} - Expected status {}, got {}", context, expected_status, actual_status);
Err(format!(
"{} - Expected status {}, got {}. URL: {}. Response: {}",
context, expected_status, actual_status, original_url, response_text
).into())
}
}
/// Assert successful response (2xx status codes) with comprehensive debugging
pub async fn assert_success(
response: axum::response::Response,
context: &str,
original_url: &str,
payload: Option<&serde_json::Value>,
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
let status = response.status();
if status.is_success() {
Self::assert_response(response, status, context, original_url, payload).await
} else {
Self::assert_response(response, axum::http::StatusCode::OK, context, original_url, payload).await
}
}
/// Assert client error (4xx) with comprehensive debugging
pub async fn assert_client_error(
response: axum::response::Response,
expected_status: axum::http::StatusCode,
context: &str,
original_url: &str,
payload: Option<&serde_json::Value>,
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
Self::assert_response(response, expected_status, context, original_url, payload).await
}
/// Assert server error (5xx) with comprehensive debugging
pub async fn assert_server_error(
response: axum::response::Response,
expected_status: axum::http::StatusCode,
context: &str,
original_url: &str,
payload: Option<&serde_json::Value>,
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
Self::assert_response(response, expected_status, context, original_url, payload).await
}
/// Make a request and assert the response in one call
pub async fn make_and_assert(
app: &axum::Router,
method: &str,
uri: &str,
payload: Option<serde_json::Value>,
expected_status: axum::http::StatusCode,
context: &str,
token: Option<&str>,
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
let mut builder = axum::http::Request::builder()
.method(method)
.uri(uri)
.header("Content-Type", "application/json");
if let Some(token) = token {
builder = builder.header("Authorization", format!("Bearer {}", token));
}
let request_body = if let Some(ref body) = payload {
axum::body::Body::from(serde_json::to_vec(body)?)
} else {
axum::body::Body::empty()
};
let response = app
.clone()
.oneshot(builder.body(request_body)?)
.await?;
Self::assert_response(response, expected_status, context, uri, payload.as_ref()).await
}
}

View File

@ -59,6 +59,97 @@ impl FileProcessingTestClient {
}
}
/// Debug assertion helper for better test debugging
fn debug_assert_response_status(
&self,
response_status: reqwest::StatusCode,
expected_status: reqwest::StatusCode,
context: &str,
url: &str,
payload: Option<&str>,
response_body: Option<&str>,
) {
if response_status != expected_status {
println!("🔍 FileProcessingTestClient Debug Info for: {}", context);
println!("🔗 Request URL: {}", url);
if let Some(payload) = payload {
println!("📤 Request Payload:");
println!("{}", payload);
} else {
println!("📤 Request Payload: (empty or multipart)");
}
println!("📊 Response Status: {} (expected: {})", response_status, expected_status);
if let Some(body) = response_body {
println!("📝 Response Body:");
println!("{}", body);
}
panic!("{} - Expected status {}, got {}. URL: {}",
context, expected_status, response_status, url);
} else {
println!("{} - Status {} as expected", context, expected_status);
}
}
/// Debug assertion for content validation
fn debug_assert_content_contains(
&self,
content: &str,
expected_substring: &str,
context: &str,
url: &str,
) {
if !content.contains(expected_substring) {
println!("🔍 FileProcessingTestClient Debug Info for: {}", context);
println!("🔗 Request URL: {}", url);
println!("📝 Content length: {} bytes", content.len());
println!("🔍 Expected substring: '{}'", expected_substring);
println!("📝 Actual content (first 500 chars):");
println!("{}", &content[..content.len().min(500)]);
if content.len() > 500 {
println!("... (truncated)");
}
panic!("{} - Content does not contain expected substring '{}'", context, expected_substring);
} else {
println!("{} - Content contains expected substring", context);
}
}
/// Debug assertion for field validation
fn debug_assert_field_equals<T: std::fmt::Debug + PartialEq>(
&self,
actual: &T,
expected: &T,
field_name: &str,
context: &str,
url: &str,
) {
if actual != expected {
println!("🔍 FileProcessingTestClient Debug Info for: {}", context);
println!("🔗 Request URL: {}", url);
println!("📊 Field '{}': Expected {:?}, got {:?}", field_name, expected, actual);
panic!("{} - Field '{}' mismatch", context, field_name);
} else {
println!("{} - Field '{}' matches expected value", context, field_name);
}
}
/// Debug assertion for non-empty validation
fn debug_assert_non_empty(
&self,
content: &[u8],
context: &str,
url: &str,
) {
if content.is_empty() {
println!("🔍 FileProcessingTestClient Debug Info for: {}", context);
println!("🔗 Request URL: {}", url);
println!("📝 Content is empty when it should not be");
panic!("{} - Content is empty", context);
} else {
println!("{} - Content is non-empty ({} bytes)", context, content.len());
}
}
/// Setup test user
async fn setup_user(&mut self) -> Result<String, Box<dyn std::error::Error>> {
let timestamp = std::time::SystemTime::now()
@ -481,25 +572,33 @@ End of test document."#;
println!("✅ Text file uploaded: {}", document_id);
// Validate initial document properties
assert_eq!(document.mime_type, "text/plain");
assert!(document.file_size > 0);
assert_eq!(document.filename, "test_pipeline.txt");
let upload_url = format!("{}/api/documents", get_base_url());
client.debug_assert_field_equals(&document.mime_type, &"text/plain".to_string(), "mime_type", "document upload validation", &upload_url);
if document.file_size <= 0 {
println!("🔍 FileProcessingTestClient Debug Info for: file size validation");
println!("🔗 Request URL: {}", upload_url);
println!("📊 Field 'file_size': Expected > 0, got {}", document.file_size);
panic!("❌ document upload validation - Field 'file_size' should be > 0");
}
client.debug_assert_field_equals(&document.filename, &"test_pipeline.txt".to_string(), "filename", "document upload validation", &upload_url);
// Wait for processing to complete
let processed_doc = client.wait_for_processing(&document_id).await
.expect("Failed to wait for processing");
assert_eq!(processed_doc.ocr_status.as_deref(), Some("completed"));
let processing_url = format!("{}/api/documents/{}", get_base_url(), document_id);
client.debug_assert_field_equals(&processed_doc.ocr_status.as_deref(), &Some("completed"), "ocr_status", "document processing validation", &processing_url);
println!("✅ Text file processing completed");
// Test file download
let (download_status, downloaded_content) = client.download_file(&document_id).await
.expect("Failed to download file");
assert!(download_status.is_success());
assert!(!downloaded_content.is_empty());
let download_url = format!("{}/api/documents/{}/download", get_base_url(), document_id);
client.debug_assert_response_status(download_status, reqwest::StatusCode::OK, "file download", &download_url, None, None);
client.debug_assert_non_empty(&downloaded_content, "file download content", &download_url);
let downloaded_text = String::from_utf8_lossy(&downloaded_content);
assert!(downloaded_text.contains("test document for the file processing pipeline"));
client.debug_assert_content_contains(&downloaded_text, "test document for the file processing pipeline", "file download content validation", &download_url);
println!("✅ File download successful");
// Test file view
@ -668,9 +767,15 @@ async fn test_image_processing_pipeline() {
println!("✅ PNG image uploaded: {}", document_id);
// Validate image document properties
assert_eq!(document.mime_type, "image/png");
assert!(document.file_size > 0);
assert_eq!(document.filename, "test_image.png");
let image_upload_url = format!("{}/api/documents", get_base_url());
client.debug_assert_field_equals(&document.mime_type, &"image/png".to_string(), "mime_type", "image upload validation", &image_upload_url);
if document.file_size <= 0 {
println!("🔍 FileProcessingTestClient Debug Info for: image file size validation");
println!("🔗 Request URL: {}", image_upload_url);
println!("📊 Field 'file_size': Expected > 0, got {}", document.file_size);
panic!("❌ image upload validation - Field 'file_size' should be > 0");
}
client.debug_assert_field_equals(&document.filename, &"test_image.png".to_string(), "filename", "image upload validation", &image_upload_url);
// Wait for processing - note that minimal images might fail OCR
let processed_result = client.wait_for_processing(&document_id).await;

View File

@ -1,4 +1,4 @@
use readur::test_utils::TestContext;
use readur::test_utils::{TestContext, AssertRequest};
use axum::http::StatusCode;
use axum::body::Body;
use axum::http::Request;
@ -40,24 +40,72 @@ async fn test_get_available_languages_success() {
.await
.expect("Failed to make request");
assert_eq!(response.status(), 200);
let status = response.status();
if status != 200 {
println!("🔍 AssertRequest Debug Info for: get available languages");
println!("🔗 Request URL: http://localhost:8000/api/ocr/languages");
println!("📤 Request Payload: (empty - GET request)");
println!("📊 Response Status: {} (expected: 200)", status);
println!("📝 Response Body:");
let error_text = response.text().await.unwrap_or_else(|_| "Unable to read response body".to_string());
println!("{}", error_text);
panic!("Expected status 200, got {}. Response: {}", status, error_text);
}
let body: serde_json::Value = response.json().await.expect("Failed to parse JSON");
assert!(body.get("available_languages").is_some());
if body.get("available_languages").is_none() {
println!("🔍 AssertRequest Debug Info for: available_languages field check");
println!("🔗 Request URL: http://localhost:8000/api/ocr/languages");
println!("📤 Request Payload: (empty - GET request)");
println!("📊 Response Status: 200");
println!("📝 Response Body:");
println!("{}", serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string()));
panic!("Response missing 'available_languages' field");
}
let languages = body["available_languages"].as_array().unwrap();
assert!(languages.len() >= 1); // At least English should be available
if languages.len() < 1 {
println!("🔍 AssertRequest Debug Info for: minimum languages check");
println!("🔗 Request URL: http://localhost:8000/api/ocr/languages");
println!("📤 Request Payload: (empty - GET request)");
println!("📊 Response Status: 200");
println!("📝 Response Body:");
println!("{}", serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string()));
panic!("Expected at least 1 language, got {}", languages.len());
}
// Check that languages have the expected structure
for lang in languages {
assert!(lang.get("code").is_some());
assert!(lang.get("name").is_some());
for (i, lang) in languages.iter().enumerate() {
if lang.get("code").is_none() || lang.get("name").is_none() {
println!("🔍 AssertRequest Debug Info for: language structure check");
println!("🔗 Request URL: http://localhost:8000/api/ocr/languages");
println!("📤 Request Payload: (empty - GET request)");
println!("📊 Response Status: 200");
println!("📝 Response Body:");
println!("{}", serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string()));
println!("❌ Language at index {} missing required fields 'code' or 'name': {}", i, lang);
panic!("Language structure validation failed");
}
}
// Check that English is included
let has_english = languages.iter().any(|lang| {
lang.get("code").unwrap().as_str().unwrap() == "eng"
});
assert!(has_english);
if !has_english {
println!("🔍 AssertRequest Debug Info for: English language check");
println!("🔗 Request URL: http://localhost:8000/api/ocr/languages");
println!("📤 Request Payload: (empty - GET request)");
println!("📊 Response Status: 200");
println!("📝 Response Body:");
println!("{}", serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string()));
let available_codes: Vec<&str> = languages.iter()
.filter_map(|lang| lang.get("code")?.as_str())
.collect();
println!("Available language codes: {:?}", available_codes);
panic!("English language 'eng' not found in available languages");
}
}
#[tokio::test]
@ -72,7 +120,17 @@ async fn test_get_available_languages_unauthorized() {
.await
.expect("Failed to make request");
assert_eq!(response.status(), 401);
let status = response.status();
if status != 401 {
println!("🔍 AssertRequest Debug Info for: unauthorized access check");
println!("🔗 Request URL: http://localhost:8000/api/ocr/languages");
println!("📤 Request Payload: (empty - GET request without auth)");
println!("📊 Response Status: {} (expected: 401)", status);
println!("📝 Response Body:");
let error_text = response.text().await.unwrap_or_else(|_| "Unable to read response body".to_string());
println!("{}", error_text);
panic!("Expected status 401 (unauthorized), got {}. Response: {}", status, error_text);
}
}
#[tokio::test]
@ -116,12 +174,34 @@ async fn test_retry_ocr_with_language_success() {
.unwrap();
let response = ctx.app().clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = AssertRequest::assert_response(
response,
StatusCode::OK,
"retry OCR with language success",
&format!("/api/documents/{}/ocr/retry", document_id),
Some(&retry_request),
).await.expect("Response assertion failed");
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(body["success"].as_bool().unwrap(), true);
assert!(body.get("message").is_some());
if body["success"].as_bool() != Some(true) {
println!("🔍 AssertRequest Debug Info for: success field check");
println!("🔗 Request URL: /api/documents/{}/ocr/retry", document_id);
println!("📤 Request Payload:");
println!("{}", serde_json::to_string_pretty(&retry_request).unwrap());
println!("📝 Response Body:");
println!("{}", serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string()));
panic!("Expected success=true in response body");
}
if body.get("message").is_none() {
println!("🔍 AssertRequest Debug Info for: message field check");
println!("🔗 Request URL: /api/documents/{}/ocr/retry", document_id);
println!("📤 Request Payload:");
println!("{}", serde_json::to_string_pretty(&retry_request).unwrap());
println!("📝 Response Body:");
println!("{}", serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string()));
panic!("Expected 'message' field in response body");
}
}
#[tokio::test]
@ -165,7 +245,14 @@ async fn test_retry_ocr_with_invalid_language() {
.unwrap();
let response = ctx.app().clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
AssertRequest::assert_response(
response,
StatusCode::BAD_REQUEST,
"retry OCR with invalid language",
&format!("/api/documents/{}/ocr/retry", document_id),
Some(&retry_request),
).await.expect("Response assertion failed");
}
#[tokio::test]
@ -209,12 +296,34 @@ async fn test_retry_ocr_with_multiple_languages_success() {
.unwrap();
let response = ctx.app().clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = AssertRequest::assert_response(
response,
StatusCode::OK,
"retry OCR with multiple languages success",
&format!("/api/documents/{}/ocr/retry", document_id),
Some(&retry_request),
).await.expect("Response assertion failed");
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(body["success"].as_bool().unwrap(), true);
assert!(body.get("message").is_some());
if body["success"].as_bool() != Some(true) {
println!("🔍 AssertRequest Debug Info for: multiple languages success field check");
println!("🔗 Request URL: /api/documents/{}/ocr/retry", document_id);
println!("📤 Request Payload:");
println!("{}", serde_json::to_string_pretty(&retry_request).unwrap());
println!("📝 Response Body:");
println!("{}", serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string()));
panic!("Expected success=true in response body");
}
if body.get("message").is_none() {
println!("🔍 AssertRequest Debug Info for: multiple languages message field check");
println!("🔗 Request URL: /api/documents/{}/ocr/retry", document_id);
println!("📤 Request Payload:");
println!("{}", serde_json::to_string_pretty(&retry_request).unwrap());
println!("📝 Response Body:");
println!("{}", serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string()));
panic!("Expected 'message' field in response body");
}
}
#[tokio::test]
@ -259,7 +368,14 @@ async fn test_retry_ocr_with_too_many_languages() {
.unwrap();
let response = ctx.app().clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
AssertRequest::assert_response(
response,
StatusCode::BAD_REQUEST,
"retry OCR with too many languages",
&format!("/api/documents/{}/ocr/retry", document_id),
Some(&retry_request),
).await.expect("Response assertion failed");
}
#[tokio::test]
@ -304,5 +420,12 @@ async fn test_retry_ocr_with_invalid_language_in_array() {
.unwrap();
let response = ctx.app().clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
AssertRequest::assert_response(
response,
StatusCode::BAD_REQUEST,
"retry OCR with invalid language in array",
&format!("/api/documents/{}/ocr/retry", document_id),
Some(&retry_request),
).await.expect("Response assertion failed");
}