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:
parent
45ec99a031
commit
aac32ea64a
|
|
@ -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(¶ms, 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)
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
Loading…
Reference in New Issue