diff --git a/docs/dev/ERROR_SYSTEM.md b/docs/dev/ERROR_SYSTEM.md new file mode 100644 index 0000000..687dffd --- /dev/null +++ b/docs/dev/ERROR_SYSTEM.md @@ -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; // For repeated error handling + fn suggested_action(&self) -> Option; // 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>, + Json(user_data): Json, +) -> Result, 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 { + 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, StatusCode> + + // After + async fn my_handler() -> Result, 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>, + Json(user_data): Json, +) -> Result, 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>, + Path(source_id): Path, + auth_user: AuthUser, +) -> Result, 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>, + Query(params): Query, + auth_user: AuthUser, +) -> Result, 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) \ No newline at end of file diff --git a/frontend/src/components/Auth/Login.tsx b/frontend/src/components/Auth/Login.tsx index e873c4c..65255ab 100644 --- a/frontend/src/components/Auth/Login.tsx +++ b/frontend/src/components/Auth/Login.tsx @@ -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); } }; diff --git a/frontend/src/components/Auth/OidcCallback.tsx b/frontend/src/components/Auth/OidcCallback.tsx index 05a3b33..b7f94fc 100644 --- a/frontend/src/components/Auth/OidcCallback.tsx +++ b/frontend/src/components/Auth/OidcCallback.tsx @@ -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); } }; diff --git a/frontend/src/components/BulkRetryModal.tsx b/frontend/src/components/BulkRetryModal.tsx index 7958d58..660adda 100644 --- a/frontend/src/components/BulkRetryModal.tsx +++ b/frontend/src/components/BulkRetryModal.tsx @@ -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 = ({ 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 = ({ 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); } diff --git a/frontend/src/components/OcrRetryDialog/OcrRetryDialog.tsx b/frontend/src/components/OcrRetryDialog/OcrRetryDialog.tsx index a9b6c52..9a3fc90 100644 --- a/frontend/src/components/OcrRetryDialog/OcrRetryDialog.tsx +++ b/frontend/src/components/OcrRetryDialog/OcrRetryDialog.tsx @@ -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 = ({ } } 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); } diff --git a/frontend/src/components/Upload/UploadZone.tsx b/frontend/src/components/Upload/UploadZone.tsx index 96d2fbb..7bceae7 100644 --- a/frontend/src/components/Upload/UploadZone.tsx +++ b/frontend/src/components/Upload/UploadZone.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 diff --git a/frontend/src/pages/DocumentManagementPage.tsx b/frontend/src/pages/DocumentManagementPage.tsx index da50d4c..ff52384 100644 --- a/frontend/src/pages/DocumentManagementPage.tsx +++ b/frontend/src/pages/DocumentManagementPage.tsx @@ -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 { diff --git a/frontend/src/pages/LabelsPage.tsx b/frontend/src/pages/LabelsPage.tsx index 1d7afff..767ef73 100644 --- a/frontend/src/pages/LabelsPage.tsx +++ b/frontend/src/pages/LabelsPage.tsx @@ -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'); + } } }; diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 2a1cd76..f6a99d9 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -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); } diff --git a/frontend/src/pages/SourcesPage.tsx b/frontend/src/pages/SourcesPage.tsx index 264ae25..285404c 100644 --- a/frontend/src/pages/SourcesPage.tsx +++ b/frontend/src/pages/SourcesPage.tsx @@ -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); diff --git a/frontend/src/services/__tests__/errors.test.ts b/frontend/src/services/__tests__/errors.test.ts new file mode 100644 index 0000000..630540f --- /dev/null +++ b/frontend/src/services/__tests__/errors.test.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 465a133..f8c49a1 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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 diff --git a/frontend/src/services/errors.ts b/frontend/src/services/errors.ts new file mode 100644 index 0000000..0c11838 --- /dev/null +++ b/frontend/src/services/errors.ts @@ -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 + } +} \ No newline at end of file diff --git a/src/errors/label.rs b/src/errors/label.rs new file mode 100644 index 0000000..b7b7cba --- /dev/null +++ b/src/errors/label.rs @@ -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 { + match self { + LabelError::NotFound => Some("label_not_found".to_string()), + LabelError::DuplicateName { name } => Some(format!("label_duplicate_{}", name)), + _ => None, + } + } + + fn suggested_action(&self) -> Option { + 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>(name: S) -> Self { + Self::DuplicateName { name: name.into() } + } + + pub fn system_label_modification>(name: S) -> Self { + Self::SystemLabelModification { name: name.into() } + } + + pub fn invalid_color>(color: S) -> Self { + Self::InvalidColor { color: color.into() } + } + + pub fn invalid_name>(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>(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>(reason: S) -> Self { + Self::PermissionDenied { reason: reason.into() } + } + + pub fn color_conflict>(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>(reason: S) -> Self { + Self::DeleteRestricted { reason: reason.into() } + } + + pub fn invalid_assignment>(document_id: Uuid, reason: S) -> Self { + Self::InvalidAssignment { + document_id, + reason: reason.into() + } + } + + pub fn reserved_name>(name: S) -> Self { + Self::ReservedName { name: name.into() } + } +} \ No newline at end of file diff --git a/src/errors/mod.rs b/src/errors/mod.rs new file mode 100644 index 0000000..aa46234 --- /dev/null +++ b/src/errors/mod.rs @@ -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 { + None + } + + /// Get optional suggested action for the user + fn suggested_action(&self) -> Option { + 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>(message: S) -> Self { + Self::BadRequest { message: message.into() } + } + + pub fn conflict>(message: S) -> Self { + Self::Conflict { message: message.into() } + } + + pub fn forbidden>(message: S) -> Self { + Self::Forbidden { message: message.into() } + } + + pub fn payload_too_large>(message: S) -> Self { + Self::PayloadTooLarge { message: message.into() } + } + + pub fn internal_server_error>(message: S) -> Self { + Self::InternalServerError { message: message.into() } + } + + pub fn service_unavailable>(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; \ No newline at end of file diff --git a/src/errors/search.rs b/src/errors/search.rs new file mode 100644 index 0000000..215803c --- /dev/null +++ b/src/errors/search.rs @@ -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 { + 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 { + 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>(reason: S) -> Self { + Self::IndexUnavailable { reason: reason.into() } + } + + pub fn invalid_syntax>(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>(mode: S) -> Self { + Self::InvalidSearchMode { mode: mode.into() } + } + + pub fn invalid_mime_type>(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>(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>(tag: S) -> Self { + Self::InvalidTagFilter { tag: tag.into() } + } + + pub fn index_corruption>(details: S) -> Self { + Self::IndexCorruption { details: details.into() } + } +} \ No newline at end of file diff --git a/src/errors/settings.rs b/src/errors/settings.rs new file mode 100644 index 0000000..3f8e09b --- /dev/null +++ b/src/errors/settings.rs @@ -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 { + 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 { + 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>(language: S, available_languages: S) -> Self { + Self::InvalidLanguage { + language: language.into(), + available_languages: available_languages.into() + } + } + + pub fn invalid_value>(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>(setting_name: S) -> Self { + Self::ReadOnlySetting { setting_name: setting_name.into() } + } + + pub fn validation_failed>(setting_name: S, reason: S) -> Self { + Self::ValidationFailed { + setting_name: setting_name.into(), + reason: reason.into() + } + } + + pub fn invalid_ocr_configuration>(details: S) -> Self { + Self::InvalidOcrConfiguration { details: details.into() } + } + + pub fn invalid_file_type>(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>(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>(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>(list_type: S, details: S) -> Self { + Self::InvalidCharacterList { + list_type: list_type.into(), + details: details.into() + } + } + + pub fn conflicting_settings>(setting1: S, setting2: S) -> Self { + Self::ConflictingSettings { + setting1: setting1.into(), + setting2: setting2.into() + } + } + + pub fn permission_denied>(reason: S) -> Self { + Self::PermissionDenied { reason: reason.into() } + } + + pub fn invalid_search_configuration>(details: S) -> Self { + Self::InvalidSearchConfiguration { details: details.into() } + } +} \ No newline at end of file diff --git a/src/errors/source.rs b/src/errors/source.rs new file mode 100644 index 0000000..7763cd6 --- /dev/null +++ b/src/errors/source.rs @@ -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 { + 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 { + 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>(name: S) -> Self { + Self::DuplicateName { name: name.into() } + } + + pub fn invalid_path>(path: S) -> Self { + Self::InvalidPath { path: path.into() } + } + + pub fn connection_failed>(details: S) -> Self { + Self::ConnectionFailed { details: details.into() } + } + + pub fn authentication_failed>(name: S, reason: S) -> Self { + Self::AuthenticationFailed { + name: name.into(), + reason: reason.into() + } + } + + pub fn sync_in_progress>(name: S) -> Self { + Self::SyncInProgress { name: name.into() } + } + + pub fn configuration_invalid>(details: S) -> Self { + Self::ConfigurationInvalid { details: details.into() } + } + + pub fn access_denied>(path: S, reason: S) -> Self { + Self::AccessDenied { + path: path.into(), + reason: reason.into() + } + } + + pub fn source_disabled>(name: S) -> Self { + Self::SourceDisabled { name: name.into() } + } + + pub fn invalid_source_type>(source_type: S) -> Self { + Self::InvalidSourceType { source_type: source_type.into() } + } + + pub fn network_timeout>(url: S, timeout_seconds: u64) -> Self { + Self::NetworkTimeout { + url: url.into(), + timeout_seconds + } + } + + pub fn capacity_exceeded>(details: S) -> Self { + Self::CapacityExceeded { details: details.into() } + } + + pub fn server_error>(server: S, error_code: u16, message: S) -> Self { + Self::ServerError { + server: server.into(), + error_code, + message: message.into() + } + } + + pub fn certificate_error>(server: S, details: S) -> Self { + Self::CertificateError { + server: server.into(), + details: details.into() + } + } + + pub fn unsupported_server_version>(version: S, source_type: S) -> Self { + Self::UnsupportedServerVersion { + version: version.into(), + source_type: source_type.into() + } + } + + pub fn rate_limit_exceeded>(name: S, retry_after_seconds: u64) -> Self { + Self::RateLimitExceeded { + name: name.into(), + retry_after_seconds + } + } + + pub fn file_not_found>(path: S) -> Self { + Self::FileNotFound { path: path.into() } + } + + pub fn directory_not_found>(path: S) -> Self { + Self::DirectoryNotFound { path: path.into() } + } + + pub fn validation_failed>(issues: S) -> Self { + Self::ValidationFailed { issues: issues.into() } + } + + pub fn sync_failed>(reason: S) -> Self { + Self::SyncFailed { reason: reason.into() } + } + + pub fn delete_restricted>(name: S, reason: S) -> Self { + Self::DeleteRestricted { + name: name.into(), + reason: reason.into() + } + } +} \ No newline at end of file diff --git a/src/errors/user.rs b/src/errors/user.rs new file mode 100644 index 0000000..e153306 --- /dev/null +++ b/src/errors/user.rs @@ -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 { + match self { + UserError::InvalidCredentials => Some("user_invalid_credentials".to_string()), + UserError::NotFound => Some("user_not_found".to_string()), + _ => None, + } + } + + fn suggested_action(&self) -> Option { + 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>(username: S) -> Self { + Self::DuplicateUsername { username: username.into() } + } + + pub fn duplicate_email>(email: S) -> Self { + Self::DuplicateEmail { email: email.into() } + } + + pub fn invalid_role>(role: S) -> Self { + Self::InvalidRole { role: role.into() } + } + + pub fn permission_denied>(reason: S) -> Self { + Self::PermissionDenied { reason: reason.into() } + } + + pub fn invalid_password>(requirements: S) -> Self { + Self::InvalidPassword { requirements: requirements.into() } + } + + pub fn invalid_username>(username: S, reason: S) -> Self { + Self::InvalidUsername { + username: username.into(), + reason: reason.into() + } + } + + pub fn invalid_email>(email: S) -> Self { + Self::InvalidEmail { email: email.into() } + } + + pub fn delete_restricted>(id: Uuid, reason: S) -> Self { + Self::DeleteRestricted { + id, + reason: reason.into() + } + } + + pub fn oidc_authentication_failed>(details: S) -> Self { + Self::OidcAuthenticationFailed { details: details.into() } + } + + pub fn auth_provider_not_configured>(provider: S) -> Self { + Self::AuthProviderNotConfigured { provider: provider.into() } + } + + pub fn internal_server_error>(message: S) -> Self { + Self::InternalServerError { message: message.into() } + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 9045b60..bddea3b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/routes/labels.rs b/src/routes/labels.rs index 80a4152..f070bf7 100644 --- a/src/routes/labels.rs +++ b/src/routes/labels.rs @@ -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 { diff --git a/src/routes/search.rs b/src/routes/search.rs index 710ead1..0f4317e 100644 --- a/src/routes/search.rs +++ b/src/routes/search.rs @@ -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>, auth_user: AuthUser, Query(search_request): Query, -) -> Result, StatusCode> { +) -> Result, 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 { diff --git a/src/routes/settings.rs b/src/routes/settings.rs index f11a006..c311089 100644 --- a/src/routes/settings.rs +++ b/src/routes/settings.rs @@ -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> { async fn get_settings( auth_user: AuthUser, State(state): State>, -) -> Result, StatusCode> { +) -> Result, 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(), diff --git a/src/routes/sources/crud.rs b/src/routes/sources/crud.rs index ae070f7..b1beeb2 100644 --- a/src/routes/sources/crud.rs +++ b/src/routes/sources/crud.rs @@ -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>, -) -> Result>, StatusCode> { +) -> Result>, 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 = 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 = counts @@ -87,12 +88,12 @@ pub async fn create_source( auth_user: AuthUser, State(state): State>, Json(source_data): Json, -) -> Result, StatusCode> { +) -> Result, 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(); diff --git a/src/routes/users.rs b/src/routes/users.rs index a9b539f..5a900a9 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -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> { async fn list_users( auth_user: AuthUser, State(state): State>, -) -> Result>, StatusCode> { +) -> Result>, 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 = 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>, Path(id): Path, -) -> Result, StatusCode> { +) -> Result, 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>, Json(user_data): Json, -) -> Result, StatusCode> { +) -> Result, 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>, Path(id): Path, Json(update_data): Json, -) -> Result, StatusCode> { +) -> Result, 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>, Path(id): Path, -) -> Result { +) -> Result { 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) } \ No newline at end of file diff --git a/src/test_utils.rs b/src/test_utils.rs index d858635..cc1576f 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -921,4 +921,148 @@ pub mod document_helpers { ) -> Result> { 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> { + 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("")); + } + + 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::(&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::(&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> { + 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> { + 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> { + 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, + expected_status: axum::http::StatusCode, + context: &str, + token: Option<&str>, + ) -> Result> { + 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 + } } \ No newline at end of file diff --git a/tests/integration_file_processing_pipeline_tests.rs b/tests/integration_file_processing_pipeline_tests.rs index ddfc4bb..1682696 100644 --- a/tests/integration_file_processing_pipeline_tests.rs +++ b/tests/integration_file_processing_pipeline_tests.rs @@ -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( + &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> { 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; diff --git a/tests/integration_ocr_language_endpoints.rs b/tests/integration_ocr_language_endpoints.rs index 6843996..8cf01f1 100644 --- a/tests/integration_ocr_language_endpoints.rs +++ b/tests/integration_ocr_language_endpoints.rs @@ -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"); } \ No newline at end of file