//! Integration tests for SqliteSearchRepository. mod common; use goingson_core::{ NewProject, NewTask, Priority, ProjectRepository, ProjectStatus, ProjectType, SearchQuery, SearchRepository, SearchResultType, TaskRepository, UserId, }; use goingson_db_sqlite::{SqliteProjectRepository, SqliteSearchRepository, SqliteTaskRepository}; /// Helper: create a task with a specific description for FTS matching. async fn create_task_with_desc(pool: &sqlx::SqlitePool, user_id: UserId, description: &str) { let repo = SqliteTaskRepository::new(pool.clone()); let new_task = NewTask { project_id: None, description: description.to_string(), priority: Priority::Medium, due: None, tags: vec![], recurrence: goingson_core::Recurrence::None, recurrence_rule: None, urgency: 0.0, source_email_id: None, scheduled_start: None, scheduled_duration: None, contact_id: None, milestone_id: None, estimated_minutes: None, recurrence_parent_id: None, }; repo.create(user_id, new_task).await.unwrap(); } /// Helper: create a task with a specific priority. async fn create_task_with_priority( pool: &sqlx::SqlitePool, user_id: UserId, description: &str, priority: Priority, ) { let repo = SqliteTaskRepository::new(pool.clone()); let new_task = NewTask { project_id: None, description: description.to_string(), priority, due: None, tags: vec![], recurrence: goingson_core::Recurrence::None, recurrence_rule: None, urgency: 0.0, source_email_id: None, scheduled_start: None, scheduled_duration: None, contact_id: None, milestone_id: None, estimated_minutes: None, recurrence_parent_id: None, }; repo.create(user_id, new_task).await.unwrap(); } /// Helper: create a project with a name. async fn create_project( pool: &sqlx::SqlitePool, user_id: UserId, name: &str, ) -> goingson_core::Project { let repo = SqliteProjectRepository::new(pool.clone()); repo.create( user_id, NewProject { name: name.to_string(), description: format!("Project for {}", name), project_type: ProjectType::SideProject, status: ProjectStatus::Active, }, ) .await .unwrap() } // ============ Core Search Tests ============ #[tokio::test] async fn search_empty_query_returns_empty() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteSearchRepository::new(pool); let query = SearchQuery { query: String::new(), ..Default::default() }; let (results, _total) = repo.search(user_id, query).await.unwrap(); assert!(results.is_empty()); } #[tokio::test] async fn search_tasks_by_text() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; create_task_with_desc(&pool, user_id, "Fix login bug in auth module").await; create_task_with_desc(&pool, user_id, "Update documentation for API").await; let repo = SqliteSearchRepository::new(pool); let query = SearchQuery::new("login"); let (results, _total) = repo.search(user_id, query).await.unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].result_type, SearchResultType::Task); assert!(results[0].title.contains("login")); } #[tokio::test] async fn search_projects_by_name() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; create_project(&pool, user_id, "GoingsOn Development").await; create_project(&pool, user_id, "Website Redesign").await; let repo = SqliteSearchRepository::new(pool); let query = SearchQuery::new("redesign"); let (results, _total) = repo.search(user_id, query).await.unwrap(); assert!(results.iter().any(|r| r.result_type == SearchResultType::Project)); assert!(results.iter().any(|r| r.title.contains("Redesign"))); } #[tokio::test] async fn search_returns_multiple_types() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; create_task_with_desc(&pool, user_id, "Build authentication system").await; create_project(&pool, user_id, "Authentication Service").await; let repo = SqliteSearchRepository::new(pool); let query = SearchQuery::new("authentication"); let (results, _total) = repo.search(user_id, query).await.unwrap(); let types: Vec<_> = results.iter().map(|r| &r.result_type).collect(); assert!(types.contains(&&SearchResultType::Task)); assert!(types.contains(&&SearchResultType::Project)); } // ============ Filter Tests ============ #[tokio::test] async fn search_type_filter() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; create_task_with_desc(&pool, user_id, "Deploy server infrastructure").await; create_project(&pool, user_id, "Server Infrastructure").await; let repo = SqliteSearchRepository::new(pool); let query = SearchQuery::new("infrastructure") .with_types(vec![SearchResultType::Task]); let (results, _total) = repo.search(user_id, query).await.unwrap(); assert!(results.iter().all(|r| r.result_type == SearchResultType::Task)); } #[tokio::test] async fn search_priority_filter() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; create_task_with_priority(&pool, user_id, "High priority deploy task", Priority::High).await; create_task_with_priority(&pool, user_id, "Low priority deploy cleanup", Priority::Low).await; let repo = SqliteSearchRepository::new(pool); let mut query = SearchQuery::new("deploy"); query.priority = Some(Priority::High); let (results, _total) = repo.search(user_id, query).await.unwrap(); assert_eq!(results.len(), 1); assert!(results[0].title.contains("High priority")); } #[tokio::test] async fn search_tag_include() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; // Create tasks with tags via the repository let task_repo = SqliteTaskRepository::new(pool.clone()); let task = task_repo .create( user_id, NewTask { project_id: None, description: "Tagged task for search testing".to_string(), priority: Priority::Medium, due: None, tags: vec!["backend".to_string()], recurrence: goingson_core::Recurrence::None, recurrence_rule: None, urgency: 0.0, source_email_id: None, scheduled_start: None, scheduled_duration: None, contact_id: None, milestone_id: None, estimated_minutes: None, recurrence_parent_id: None, }, ) .await .unwrap(); // Also create an untagged task with similar text create_task_with_desc(&pool, user_id, "Untagged task for search testing").await; let repo = SqliteSearchRepository::new(pool); let mut query = SearchQuery::new("search testing"); query.tags_include = vec!["backend".to_string()]; let (results, _total) = repo.search(user_id, query).await.unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].id, *task.id); } #[tokio::test] async fn search_tag_exclude() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let task_repo = SqliteTaskRepository::new(pool.clone()); task_repo .create( user_id, NewTask { project_id: None, description: "Excluded task for tag filtering".to_string(), priority: Priority::Medium, due: None, tags: vec!["wontfix".to_string()], recurrence: goingson_core::Recurrence::None, recurrence_rule: None, urgency: 0.0, source_email_id: None, scheduled_start: None, scheduled_duration: None, contact_id: None, milestone_id: None, estimated_minutes: None, recurrence_parent_id: None, }, ) .await .unwrap(); create_task_with_desc(&pool, user_id, "Included task for tag filtering").await; let repo = SqliteSearchRepository::new(pool); let mut query = SearchQuery::new("tag filtering"); query.tags_exclude = vec!["wontfix".to_string()]; let (results, _total) = repo.search(user_id, query).await.unwrap(); assert_eq!(results.len(), 1); assert!(results[0].title.contains("Included")); } // ============ Pagination Tests ============ #[tokio::test] async fn search_with_limit() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; for i in 1..=5 { create_task_with_desc(&pool, user_id, &format!("Pagination task number {}", i)).await; } let repo = SqliteSearchRepository::new(pool); let query = SearchQuery::new("pagination").with_limit(2); let (results, _total) = repo.search(user_id, query).await.unwrap(); assert_eq!(results.len(), 2); } #[tokio::test] async fn search_with_offset() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; for i in 1..=5 { create_task_with_desc(&pool, user_id, &format!("Offset task item {}", i)).await; } let repo = SqliteSearchRepository::new(pool); // Get all results first let all_query = SearchQuery::new("offset task"); let (all, _total) = repo.search(user_id, all_query).await.unwrap(); // Get with offset let offset_query = SearchQuery::new("offset task").with_offset(2).with_limit(50); let (offset, _total) = repo.search(user_id, offset_query).await.unwrap(); assert_eq!(offset.len(), all.len() - 2); } // ============ Edge Cases ============ #[tokio::test] async fn search_special_characters_escaped() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; create_task_with_desc(&pool, user_id, "Normal task for safety").await; let repo = SqliteSearchRepository::new(pool); // These characters should be escaped by prepare_fts5_query, not crash let query = SearchQuery::new("test*\"(query):special"); let results = repo.search(user_id, query).await; assert!(results.is_ok()); } #[tokio::test] async fn search_no_results() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; create_task_with_desc(&pool, user_id, "A regular task").await; let repo = SqliteSearchRepository::new(pool); let query = SearchQuery::new("xyznonexistentquery"); let (results, _total) = repo.search(user_id, query).await.unwrap(); assert!(results.is_empty()); }