Files
readur/tests/integration_integration_tests.rs

391 lines
14 KiB
Rust

/*!
* Integration Tests for Readur OCR System
*
* Tests complete user workflows against a running server using Rust's reqwest client.
* These tests import and use the same models/types as the main application.
*/
use reqwest::Client;
use serde_json::Value;
use std::time::{Duration, Instant};
use tokio::time::sleep;
use readur::models::{CreateUser, LoginRequest, LoginResponse, DocumentResponse};
use readur::routes::documents::types::DocumentUploadResponse;
fn get_base_url() -> String {
std::env::var("API_URL").unwrap_or_else(|_| "http://localhost:8000".to_string())
}
const TIMEOUT: Duration = Duration::from_secs(30);
/// Integration test client that handles authentication and common operations
struct TestClient {
client: Client,
token: Option<String>,
}
impl TestClient {
fn new() -> Self {
Self {
client: Client::new(),
token: None,
}
}
/// Check if server is running and healthy
async fn check_server_health(&self) -> Result<(), Box<dyn std::error::Error>> {
let response = self.client
.get(&format!("{}/api/health", get_base_url()))
.timeout(Duration::from_secs(5))
.send()
.await?;
if !response.status().is_success() {
return Err("Server health check failed".into());
}
let health: Value = response.json().await?;
if health["status"] != "ok" {
return Err("Server is not healthy".into());
}
Ok(())
}
/// Register a new user and login to get auth token
async fn register_and_login(&mut self, username: &str, email: &str, password: &str) -> Result<String, Box<dyn std::error::Error>> {
// Register user
let user_data = CreateUser {
username: username.to_string(),
email: email.to_string(),
password: password.to_string(),
role: Some(readur::models::UserRole::User),
};
let register_response = self.client
.post(&format!("{}/api/auth/register", get_base_url()))
.json(&user_data)
.send()
.await?;
if !register_response.status().is_success() {
return Err(format!("Registration failed: {}", register_response.text().await?).into());
}
// Login to get token
let login_data = LoginRequest {
username: username.to_string(),
password: password.to_string(),
};
let login_response = self.client
.post(&format!("{}/api/auth/login", get_base_url()))
.json(&login_data)
.send()
.await?;
if !login_response.status().is_success() {
return Err(format!("Login failed: {}", login_response.text().await?).into());
}
let login_result: LoginResponse = login_response.json().await?;
self.token = Some(login_result.token.clone());
Ok(login_result.token)
}
/// Upload a test document
async fn upload_document(&self, content: &str, filename: &str) -> Result<DocumentUploadResponse, Box<dyn std::error::Error>> {
let token = self.token.as_ref().ok_or("Not authenticated")?;
let part = reqwest::multipart::Part::text(content.to_string())
.file_name(filename.to_string())
.mime_str("text/plain")?;
let form = reqwest::multipart::Form::new()
.part("file", part);
let response = self.client
.post(&format!("{}/api/documents", get_base_url()))
.header("Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await?;
if !response.status().is_success() {
return Err(format!("Upload failed: {}", response.text().await?).into());
}
let document: DocumentUploadResponse = response.json().await?;
Ok(document)
}
/// Wait for OCR processing to complete
async fn wait_for_ocr_completion(&self, document_id: &str) -> Result<bool, Box<dyn std::error::Error>> {
let token = self.token.as_ref().ok_or("Not authenticated")?;
let start = Instant::now();
while start.elapsed() < TIMEOUT {
let response = self.client
.get(&format!("{}/api/documents", get_base_url()))
.header("Authorization", format!("Bearer {}", token))
.send()
.await?;
if response.status().is_success() {
let response_json: serde_json::Value = response.json().await?;
let documents = if let Some(docs_array) = response_json.get("documents").and_then(|d| d.as_array()) {
// Documents are in a "documents" key
docs_array
} else if let Some(docs_array) = response_json.as_array() {
// Response is directly an array of documents
docs_array
} else {
return Err("Invalid response format: missing documents array".into());
};
for doc_value in documents {
let doc: DocumentResponse = serde_json::from_value(doc_value.clone())?;
if doc.id.to_string() == document_id {
match doc.ocr_status.as_deref() {
Some("completed") => return Ok(true),
Some("failed") => return Err("OCR processing failed".into()),
_ => {
sleep(Duration::from_millis(500)).await;
continue;
}
}
}
}
}
sleep(Duration::from_millis(500)).await;
}
Ok(false)
}
/// Get OCR text for a document
async fn get_ocr_text(&self, document_id: &str) -> Result<Value, Box<dyn std::error::Error>> {
let token = self.token.as_ref().ok_or("Not authenticated")?;
let response = self.client
.get(&format!("{}/api/documents/{}/ocr", get_base_url(), document_id))
.header("Authorization", format!("Bearer {}", token))
.send()
.await?;
if !response.status().is_success() {
return Err(format!("OCR retrieval failed: {}", response.text().await?).into());
}
let ocr_data: Value = response.json().await?;
Ok(ocr_data)
}
}
#[tokio::test]
async fn test_complete_ocr_workflow() {
let mut client = TestClient::new();
// Check server health
if let Err(e) = client.check_server_health().await {
panic!("Server not running at {}: {}", get_base_url(), e);
}
// Create test user with unique timestamp
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
let username = format!("rust_integration_test_{}", timestamp);
let email = format!("rust_test_{}@example.com", timestamp);
let password = "testpassword123";
let token = client.register_and_login(&username, &email, password).await
.expect("Failed to register and login");
println!("✅ User registered and logged in, token: {}", &token[..20]);
// Upload test document
let test_content = r#"This is a test document for OCR processing.
It contains multiple lines of text.
The OCR service should extract this text accurately.
Document ID: RUST-INTEGRATION-TEST-001
Date: 2024-01-01
Technology: Rust + Axum + SQLx"#;
let document = client.upload_document(test_content, "rust_test.txt").await
.expect("Failed to upload document");
println!("✅ Document uploaded: {}", document.id);
// Validate document response structure using our types
assert!(!document.filename.is_empty());
assert!(document.file_size > 0);
assert_eq!(document.mime_type, "text/plain");
// Wait for OCR processing
let ocr_completed = client.wait_for_ocr_completion(&document.id.to_string()).await
.expect("Failed to wait for OCR completion");
assert!(ocr_completed, "OCR processing did not complete within timeout");
println!("✅ OCR processing completed");
// Retrieve OCR text
let ocr_data = client.get_ocr_text(&document.id.to_string()).await
.expect("Failed to retrieve OCR text");
// Validate OCR response structure
assert_eq!(ocr_data["id"], document.id.to_string());
assert_eq!(ocr_data["filename"], document.filename);
assert!(ocr_data["has_ocr_text"].as_bool().unwrap_or(false));
// Validate OCR content if available
if let Some(ocr_text) = ocr_data["ocr_text"].as_str() {
assert!(!ocr_text.is_empty(), "OCR text should not be empty");
assert!(ocr_text.to_lowercase().contains("test document"), "OCR text should contain expected content");
println!("✅ OCR text extracted: {} characters", ocr_text.len());
// Validate optional fields using Rust type checking
if let Some(confidence) = ocr_data["ocr_confidence"].as_f64() {
assert!((0.0..=100.0).contains(&confidence), "OCR confidence should be 0-100");
println!("✅ OCR confidence: {:.1}%", confidence);
}
if let Some(word_count) = ocr_data["ocr_word_count"].as_i64() {
assert!(word_count > 0, "Word count should be positive");
println!("✅ OCR word count: {}", word_count);
}
if let Some(processing_time) = ocr_data["ocr_processing_time_ms"].as_i64() {
assert!(processing_time >= 0, "Processing time should be non-negative");
println!("✅ OCR processing time: {}ms", processing_time);
}
}
println!("🎉 Complete OCR workflow test passed!");
}
#[tokio::test]
async fn test_ocr_error_handling() {
let mut client = TestClient::new();
// Test unauthorized access
let response = client.client
.get(&format!("{}/api/documents/test-id/ocr", get_base_url()))
.send()
.await
.expect("Failed to make request");
assert_eq!(response.status(), 401, "Should return 401 for unauthorized access");
// Test with valid auth but invalid document
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
let token = client.register_and_login(
&format!("rust_error_test_{}", timestamp),
&format!("rust_error_{}@test.com", timestamp),
"testpass123"
).await.expect("Failed to register and login");
let response = client.client
.get(&format!("{}/api/documents/00000000-0000-0000-0000-000000000000/ocr", get_base_url()))
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.expect("Failed to make request");
assert_eq!(response.status(), 404, "Should return 404 for non-existent document");
println!("✅ Error handling tests passed!");
}
#[tokio::test]
async fn test_health_endpoint() {
let client = TestClient::new();
client.check_server_health().await
.expect("Health check should pass");
println!("✅ Health endpoint test passed!");
}
#[tokio::test]
async fn test_document_list_structure() {
let mut client = TestClient::new();
// Register and login
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
let _token = client.register_and_login(
&format!("rust_list_test_{}", timestamp),
&format!("rust_list_{}@test.com", timestamp),
"testpass123"
).await.expect("Failed to register and login");
// Upload a document
let document = client.upload_document("Test content for list", "list_test.txt").await
.expect("Failed to upload document");
// Get document list
let response = client.client
.get(&format!("{}/api/documents", get_base_url()))
.header("Authorization", format!("Bearer {}", client.token.as_ref().unwrap()))
.send()
.await
.expect("Failed to get documents");
assert!(response.status().is_success());
// Parse as our DocumentResponse type to ensure structure compatibility
let response_json: serde_json::Value = response.json().await
.expect("Failed to parse response JSON");
let documents_array = if let Some(docs_array) = response_json.get("documents").and_then(|d| d.as_array()) {
// Documents are in a "documents" key
docs_array
} else if let Some(docs_array) = response_json.as_array() {
// Response is directly an array of documents
docs_array
} else {
panic!("Failed to find documents array in response");
};
let documents: Vec<DocumentResponse> = documents_array.iter()
.map(|doc_value| serde_json::from_value(doc_value.clone()))
.collect::<Result<Vec<_>, _>>()
.expect("Failed to parse documents as DocumentResponse");
// Find our uploaded document
let found_doc = documents.iter().find(|d| d.id.to_string() == document.id.to_string())
.expect("Uploaded document should be in list");
// Validate structure matches our types
assert_eq!(found_doc.filename, document.filename);
assert_eq!(found_doc.file_size, document.file_size);
assert_eq!(found_doc.mime_type, document.mime_type);
assert!(found_doc.ocr_status.is_some());
println!("✅ Document list structure test passed!");
}
/// Helper function to run all integration tests when server is not available
#[tokio::test]
async fn test_server_availability() {
let client = TestClient::new();
match client.check_server_health().await {
Ok(_) => println!("✅ Server is running and healthy"),
Err(e) => {
println!("⚠️ Server not available: {}", e);
println!("To run integration tests, start the server with: cargo run");
// Don't fail the test, just skip
}
}
}