diff --git a/tests/integration_search_pagination_tests.rs b/tests/integration_search_pagination_tests.rs new file mode 100644 index 0000000..48b3c56 --- /dev/null +++ b/tests/integration_search_pagination_tests.rs @@ -0,0 +1,658 @@ +//! Integration tests for search pagination functionality. +//! +//! These tests verify that the `count_search_documents` method returns accurate +//! total counts for pagination, ensuring the fix for the pagination bug doesn't regress. + +#[cfg(test)] +mod tests { + use anyhow::Result; + use readur::test_utils::TestContext; + use readur::models::{CreateUser, Document, SearchRequest, UserRole}; + use chrono::Utc; + use uuid::Uuid; + use std::collections::HashSet; + use sqlx; + + /// Creates unique test user data with a given suffix for test isolation + fn create_test_user_data(suffix: &str) -> CreateUser { + let test_id = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + .to_string(); + let unique_suffix = &test_id[test_id.len().saturating_sub(8)..]; + + CreateUser { + username: format!("testuser_{}_{}", suffix, unique_suffix), + email: format!("test_{}_{}@example.com", suffix, unique_suffix), + password: "password123".to_string(), + role: Some(UserRole::User), + } + } + + /// Creates an admin user for role-based access tests + fn create_admin_user_data(suffix: &str) -> CreateUser { + let test_id = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + .to_string(); + let unique_suffix = &test_id[test_id.len().saturating_sub(8)..]; + + CreateUser { + username: format!("admin_{}_{}", suffix, unique_suffix), + email: format!("admin_{}_{}@example.com", suffix, unique_suffix), + password: "password123".to_string(), + role: Some(UserRole::Admin), + } + } + + /// Creates a searchable document with unique content + fn create_searchable_document(user_id: Uuid, index: i32, mime_type: &str) -> Document { + Document { + id: Uuid::new_v4(), + filename: format!("test_{}.txt", index), + original_filename: format!("test_{}.txt", index), + file_path: format!("/path/to/test_{}.txt", index), + file_size: 1024, + mime_type: mime_type.to_string(), + content: Some(format!("Document {} with searchable content for pagination testing", index)), + ocr_text: Some(format!("OCR text {} searchable pagination", index)), + ocr_confidence: Some(95.0), + ocr_word_count: Some(10), + ocr_processing_time_ms: Some(800), + ocr_status: Some("completed".to_string()), + ocr_error: None, + ocr_completed_at: Some(Utc::now()), + tags: vec!["test".to_string(), "pagination".to_string()], + created_at: Utc::now(), + updated_at: Utc::now(), + user_id, + file_hash: Some(format!("{:x}", Uuid::new_v4().as_u128())), + original_created_at: None, + original_modified_at: None, + source_path: None, + source_type: None, + source_id: None, + file_permissions: None, + file_owner: None, + file_group: None, + source_metadata: None, + ocr_retry_count: None, + ocr_failure_reason: None, + } + } + + /// Test that count returns actual matching documents, not the limit + #[tokio::test] + async fn test_count_matches_actual_documents() { + let ctx = TestContext::new().await; + + let result: Result<()> = async { + let db = &ctx.state.db; + let user = db.create_user(create_test_user_data("count1")).await?; + + // Create 15 documents with searchable content + for i in 0..15 { + db.create_document(create_searchable_document(user.id, i, "text/plain")).await?; + } + + // Search with limit=5 + let request = SearchRequest { + query: "searchable".to_string(), + tags: None, + mime_types: None, + limit: Some(5), + offset: Some(0), + include_snippets: Some(false), + snippet_length: None, + search_mode: None, + }; + + let count = db.count_search_documents(user.id, UserRole::User, &request).await?; + let results = db.search_documents(user.id, &request).await?; + + // Total should be 15, not 5 + assert_eq!(count, 15, "Count should be total matching docs (15), not limit (5)"); + assert_eq!(results.len(), 5, "Results should respect the limit of 5"); + + Ok(()) + }.await; + + if let Err(e) = ctx.cleanup_and_close().await { + eprintln!("Warning: Test cleanup failed: {}", e); + } + + result.unwrap(); + } + + /// Test that total remains consistent across all pages + #[tokio::test] + async fn test_pagination_total_consistent() { + let ctx = TestContext::new().await; + + let result: Result<()> = async { + let db = &ctx.state.db; + let user = db.create_user(create_test_user_data("consistent1")).await?; + + // Create 20 documents + for i in 0..20 { + db.create_document(create_searchable_document(user.id, i, "text/plain")).await?; + } + + // Check total is same across all pages + for offset in [0, 5, 10, 15] { + let request = SearchRequest { + query: "searchable".to_string(), + tags: None, + mime_types: None, + limit: Some(5), + offset: Some(offset), + include_snippets: Some(false), + snippet_length: None, + search_mode: None, + }; + let count = db.count_search_documents(user.id, UserRole::User, &request).await?; + assert_eq!(count, 20, "Total should be consistent (20) at offset {}", offset); + } + + Ok(()) + }.await; + + if let Err(e) = ctx.cleanup_and_close().await { + eprintln!("Warning: Test cleanup failed: {}", e); + } + + result.unwrap(); + } + + /// Test that iterating through all pages fetches all documents exactly once + #[tokio::test] + async fn test_pagination_fetches_all_documents() { + let ctx = TestContext::new().await; + + let result: Result<()> = async { + let db = &ctx.state.db; + let user = db.create_user(create_test_user_data("fetchall1")).await?; + + // Create 17 documents (not evenly divisible by page size) + for i in 0..17 { + db.create_document(create_searchable_document(user.id, i, "text/plain")).await?; + } + + let mut all_ids: HashSet = HashSet::new(); + let page_size = 5i64; + + // Fetch all pages + for page in 0..4 { + let request = SearchRequest { + query: "searchable".to_string(), + tags: None, + mime_types: None, + limit: Some(page_size), + offset: Some(page * page_size), + include_snippets: Some(false), + snippet_length: None, + search_mode: None, + }; + let results = db.search_documents(user.id, &request).await?; + + for doc in results { + let is_new = all_ids.insert(doc.id); + assert!(is_new, "Document {} appeared on multiple pages", doc.id); + } + } + + assert_eq!(all_ids.len(), 17, "Should have fetched all 17 documents exactly once"); + + Ok(()) + }.await; + + if let Err(e) = ctx.cleanup_and_close().await { + eprintln!("Warning: Test cleanup failed: {}", e); + } + + result.unwrap(); + } + + /// Test that count correctly filters by MIME type + #[tokio::test] + async fn test_pagination_with_mime_filter() { + let ctx = TestContext::new().await; + + let result: Result<()> = async { + let db = &ctx.state.db; + let user = db.create_user(create_test_user_data("mime1")).await?; + + // Create 10 text/plain documents + for i in 0..10 { + db.create_document(create_searchable_document(user.id, i, "text/plain")).await?; + } + + // Create 5 application/pdf documents + for i in 10..15 { + db.create_document(create_searchable_document(user.id, i, "application/pdf")).await?; + } + + // Filter by text/plain only + let request = SearchRequest { + query: "searchable".to_string(), + tags: None, + mime_types: Some(vec!["text/plain".to_string()]), + limit: Some(5), + offset: Some(0), + include_snippets: Some(false), + snippet_length: None, + search_mode: None, + }; + + let count = db.count_search_documents(user.id, UserRole::User, &request).await?; + assert_eq!(count, 10, "Count should be 10 (only text/plain), not 15 (all docs)"); + + // Filter by PDF only + let request_pdf = SearchRequest { + query: "searchable".to_string(), + tags: None, + mime_types: Some(vec!["application/pdf".to_string()]), + limit: Some(5), + offset: Some(0), + include_snippets: Some(false), + snippet_length: None, + search_mode: None, + }; + + let count_pdf = db.count_search_documents(user.id, UserRole::User, &request_pdf).await?; + assert_eq!(count_pdf, 5, "Count should be 5 (only PDFs)"); + + Ok(()) + }.await; + + if let Err(e) = ctx.cleanup_and_close().await { + eprintln!("Warning: Test cleanup failed: {}", e); + } + + result.unwrap(); + } + + /// Test that count returns 0 when no documents match + #[tokio::test] + async fn test_pagination_empty_results() { + let ctx = TestContext::new().await; + + let result: Result<()> = async { + let db = &ctx.state.db; + let user = db.create_user(create_test_user_data("empty1")).await?; + + // Create documents that won't match our query + for i in 0..5 { + let mut doc = create_searchable_document(user.id, i, "text/plain"); + doc.content = Some("This content has no matching words".to_string()); + doc.ocr_text = Some("OCR text without matches".to_string()); + db.create_document(doc).await?; + } + + // Search for something that doesn't exist + let request = SearchRequest { + query: "xyznonexistent".to_string(), + tags: None, + mime_types: None, + limit: Some(10), + offset: Some(0), + include_snippets: Some(false), + snippet_length: None, + search_mode: None, + }; + + let count = db.count_search_documents(user.id, UserRole::User, &request).await?; + let results = db.search_documents(user.id, &request).await?; + + assert_eq!(count, 0, "Count should be 0 when no matches"); + assert_eq!(results.len(), 0, "Results should be empty when no matches"); + + Ok(()) + }.await; + + if let Err(e) = ctx.cleanup_and_close().await { + eprintln!("Warning: Test cleanup failed: {}", e); + } + + result.unwrap(); + } + + /// Test that the last page returns remaining documents correctly + #[tokio::test] + async fn test_pagination_boundary_last_page() { + let ctx = TestContext::new().await; + + let result: Result<()> = async { + let db = &ctx.state.db; + let user = db.create_user(create_test_user_data("boundary1")).await?; + + // Create 13 documents (13 % 5 = 3 on last page) + for i in 0..13 { + db.create_document(create_searchable_document(user.id, i, "text/plain")).await?; + } + + // Request last page (offset 10, should return 3 docs) + let request = SearchRequest { + query: "searchable".to_string(), + tags: None, + mime_types: None, + limit: Some(5), + offset: Some(10), + include_snippets: Some(false), + snippet_length: None, + search_mode: None, + }; + + let count = db.count_search_documents(user.id, UserRole::User, &request).await?; + let results = db.search_documents(user.id, &request).await?; + + assert_eq!(count, 13, "Total count should still be 13"); + assert_eq!(results.len(), 3, "Last page should have 3 remaining documents"); + + Ok(()) + }.await; + + if let Err(e) = ctx.cleanup_and_close().await { + eprintln!("Warning: Test cleanup failed: {}", e); + } + + result.unwrap(); + } + + /// Test that count is unaffected by limit/offset values + #[tokio::test] + async fn test_count_ignores_limit_offset() { + let ctx = TestContext::new().await; + + let result: Result<()> = async { + let db = &ctx.state.db; + let user = db.create_user(create_test_user_data("ignore1")).await?; + + // Create 25 documents + for i in 0..25 { + db.create_document(create_searchable_document(user.id, i, "text/plain")).await?; + } + + // Test with various limit/offset combinations + let test_cases = vec![ + (1, 0), // Tiny limit + (100, 0), // Large limit + (5, 0), // First page + (5, 20), // Near last page + (5, 100), // Past end + ]; + + for (limit, offset) in test_cases { + let request = SearchRequest { + query: "searchable".to_string(), + tags: None, + mime_types: None, + limit: Some(limit), + offset: Some(offset), + include_snippets: Some(false), + snippet_length: None, + search_mode: None, + }; + + let count = db.count_search_documents(user.id, UserRole::User, &request).await?; + assert_eq!(count, 25, "Count should always be 25 regardless of limit={}, offset={}", limit, offset); + } + + Ok(()) + }.await; + + if let Err(e) = ctx.cleanup_and_close().await { + eprintln!("Warning: Test cleanup failed: {}", e); + } + + result.unwrap(); + } + + /// Test role-based access: users see only their own documents, admins see all + #[tokio::test] + async fn test_admin_sees_all_user_sees_own() { + let ctx = TestContext::new().await; + + let result: Result<()> = async { + let db = &ctx.state.db; + + // Create two regular users + let user_a = db.create_user(create_test_user_data("usera")).await?; + let user_b = db.create_user(create_test_user_data("userb")).await?; + let admin = db.create_user(create_admin_user_data("admin")).await?; + + // Create 10 documents for user A + for i in 0..10 { + db.create_document(create_searchable_document(user_a.id, i, "text/plain")).await?; + } + + // Create 5 documents for user B + for i in 10..15 { + db.create_document(create_searchable_document(user_b.id, i, "text/plain")).await?; + } + + let request = SearchRequest { + query: "searchable".to_string(), + tags: None, + mime_types: None, + limit: Some(100), + offset: Some(0), + include_snippets: Some(false), + snippet_length: None, + search_mode: None, + }; + + // User A should see only their 10 documents + let count_a = db.count_search_documents(user_a.id, UserRole::User, &request).await?; + assert_eq!(count_a, 10, "User A should see only their 10 documents"); + + // User B should see only their 5 documents + let count_b = db.count_search_documents(user_b.id, UserRole::User, &request).await?; + assert_eq!(count_b, 5, "User B should see only their 5 documents"); + + // Admin should see all 15 documents + let count_admin = db.count_search_documents(admin.id, UserRole::Admin, &request).await?; + assert_eq!(count_admin, 15, "Admin should see all 15 documents"); + + Ok(()) + }.await; + + if let Err(e) = ctx.cleanup_and_close().await { + eprintln!("Warning: Test cleanup failed: {}", e); + } + + result.unwrap(); + } + + /// Test pagination with text query filtering + #[tokio::test] + async fn test_pagination_with_text_query() { + let ctx = TestContext::new().await; + + let result: Result<()> = async { + let db = &ctx.state.db; + let user = db.create_user(create_test_user_data("textq1")).await?; + + // Create documents with different content + for i in 0..10 { + let mut doc = create_searchable_document(user.id, i, "text/plain"); + if i < 6 { + doc.content = Some(format!("Document {} contains the word apple", i)); + } else { + doc.content = Some(format!("Document {} contains the word orange", i)); + } + db.create_document(doc).await?; + } + + // Search for "apple" + let request_apple = SearchRequest { + query: "apple".to_string(), + tags: None, + mime_types: None, + limit: Some(3), + offset: Some(0), + include_snippets: Some(false), + snippet_length: None, + search_mode: None, + }; + + let count_apple = db.count_search_documents(user.id, UserRole::User, &request_apple).await?; + let results_apple = db.search_documents(user.id, &request_apple).await?; + + assert_eq!(count_apple, 6, "Should find 6 documents with 'apple'"); + assert_eq!(results_apple.len(), 3, "Should return 3 (limit)"); + + // Search for "orange" + let request_orange = SearchRequest { + query: "orange".to_string(), + tags: None, + mime_types: None, + limit: Some(10), + offset: Some(0), + include_snippets: Some(false), + snippet_length: None, + search_mode: None, + }; + + let count_orange = db.count_search_documents(user.id, UserRole::User, &request_orange).await?; + assert_eq!(count_orange, 4, "Should find 4 documents with 'orange'"); + + Ok(()) + }.await; + + if let Err(e) = ctx.cleanup_and_close().await { + eprintln!("Warning: Test cleanup failed: {}", e); + } + + result.unwrap(); + } + + /// Test that count correctly filters by labels + #[tokio::test] + async fn test_pagination_with_label_filter() { + let ctx = TestContext::new().await; + + let result: Result<()> = async { + let db = &ctx.state.db; + let user = db.create_user(create_test_user_data("label1")).await?; + + // Create a label using direct SQL (no db.create_label method exists) + let label_id = Uuid::new_v4(); + sqlx::query( + r#" + INSERT INTO labels (id, user_id, name, description, color, is_system) + VALUES ($1, $2, $3, $4, $5, $6) + "# + ) + .bind(label_id) + .bind(user.id) + .bind("important") + .bind("Important documents") + .bind("#ff0000") + .bind(false) + .execute(db.get_pool()) + .await?; + + // Create 10 documents, assign label to 6 of them + for i in 0..10 { + let doc = db.create_document(create_searchable_document(user.id, i, "text/plain")).await?; + if i < 6 { + // Assign label to first 6 documents + sqlx::query( + "INSERT INTO document_labels (document_id, label_id, assigned_by) VALUES ($1, $2, $3)" + ) + .bind(doc.id) + .bind(label_id) + .bind(user.id) + .execute(db.get_pool()) + .await?; + } + } + + // Filter by label name + let request = SearchRequest { + query: "searchable".to_string(), + tags: Some(vec!["important".to_string()]), + mime_types: None, + limit: Some(3), + offset: Some(0), + include_snippets: Some(false), + snippet_length: None, + search_mode: None, + }; + + let count = db.count_search_documents(user.id, UserRole::User, &request).await?; + let results = db.search_documents(user.id, &request).await?; + + assert_eq!(count, 6, "Count should be 6 (only labeled docs), not 10 (all docs)"); + assert_eq!(results.len(), 3, "Results should respect limit of 3"); + + // Test with non-existent label + let request_none = SearchRequest { + query: "searchable".to_string(), + tags: Some(vec!["nonexistent".to_string()]), + mime_types: None, + limit: Some(3), + offset: Some(0), + include_snippets: Some(false), + snippet_length: None, + search_mode: None, + }; + + let count_none = db.count_search_documents(user.id, UserRole::User, &request_none).await?; + assert_eq!(count_none, 0, "Count should be 0 for non-existent label"); + + Ok(()) + }.await; + + if let Err(e) = ctx.cleanup_and_close().await { + eprintln!("Warning: Test cleanup failed: {}", e); + } + + result.unwrap(); + } + + /// Test filter-only search (empty query with MIME filter) + #[tokio::test] + async fn test_pagination_filter_only_no_query() { + let ctx = TestContext::new().await; + + let result: Result<()> = async { + let db = &ctx.state.db; + let user = db.create_user(create_test_user_data("filteronly1")).await?; + + // Create mixed documents + for i in 0..8 { + db.create_document(create_searchable_document(user.id, i, "text/plain")).await?; + } + for i in 8..12 { + db.create_document(create_searchable_document(user.id, i, "image/png")).await?; + } + + // Filter by MIME type only (no text query) + let request = SearchRequest { + query: String::new(), // Empty query + tags: None, + mime_types: Some(vec!["image/png".to_string()]), + limit: Some(2), + offset: Some(0), + include_snippets: Some(false), + snippet_length: None, + search_mode: None, + }; + + let count = db.count_search_documents(user.id, UserRole::User, &request).await?; + assert_eq!(count, 4, "Should count 4 PNG images with empty query"); + + Ok(()) + }.await; + + if let Err(e) = ctx.cleanup_and_close().await { + eprintln!("Warning: Test cleanup failed: {}", e); + } + + result.unwrap(); + } +}