//! Integration tests for full-text search. use chrono::{Duration, Utc}; use goingson_core::{ NewContact, NewEvent, NewTask, Priority, SearchQuery, SearchResultItem, SearchResultType, }; use uuid::Uuid; use crate::commands::search::SearchResultResponse; use crate::test_utils::{create_test_project, setup_test_state}; fn task(desc: &str, priority: Priority) -> NewTask { NewTask::builder(desc).priority(priority).build() } #[tokio::test] async fn text_search_returns_task_hit() { let (state, user_id) = setup_test_state().await; state .tasks .create( user_id, task("Fix login bug in auth module", Priority::Medium), ) .await .unwrap(); state .tasks .create(user_id, task("Write release notes", Priority::Medium)) .await .unwrap(); let (results, _total) = state .search .search(user_id, SearchQuery::new("login")) .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_spans_multiple_types() { let (state, user_id) = setup_test_state().await; state .tasks .create(user_id, task("Authentication rewrite", Priority::High)) .await .unwrap(); create_test_project(&state, user_id).await; // "Test Project", no match state .contacts .create( user_id, NewContact { display_name: "Authentication Vendor".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, is_implicit: false, }, ) .await .unwrap(); let start = Utc::now() + Duration::days(1); state .events .create( user_id, NewEvent::builder("Authentication planning", start).build(), ) .await .unwrap(); let (results, _total) = state .search .search(user_id, SearchQuery::new("authentication")) .await .unwrap(); let types: Vec<_> = results.iter().map(|r| r.result_type).collect(); assert!(types.contains(&SearchResultType::Task)); assert!(types.contains(&SearchResultType::Contact)); assert!(types.contains(&SearchResultType::Event)); } #[tokio::test] async fn type_filter_restricts_results() { let (state, user_id) = setup_test_state().await; state .tasks .create(user_id, task("Deploy infrastructure", Priority::Medium)) .await .unwrap(); state .contacts .create( user_id, NewContact { display_name: "Infrastructure Team".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, is_implicit: false, }, ) .await .unwrap(); let query = SearchQuery::new("infrastructure").with_types(vec![SearchResultType::Task]); let (results, _total) = state.search.search(user_id, query).await.unwrap(); assert!(!results.is_empty()); assert!( results .iter() .all(|r| r.result_type == SearchResultType::Task) ); } #[tokio::test] async fn empty_query_returns_nothing() { let (state, user_id) = setup_test_state().await; state .tasks .create(user_id, task("Some task", Priority::Medium)) .await .unwrap(); let (results, total) = state .search .search(user_id, SearchQuery::new("")) .await .unwrap(); assert!(results.is_empty()); assert_eq!(total, 0); } /// A filter-only (no text) task search must exclude soft-deleted tasks /// (status = 'Deleted'). #[tokio::test] async fn filter_only_search_excludes_deleted_tasks() { let (state, user_id) = setup_test_state().await; let keep = state .tasks .create(user_id, task("Keep me high", Priority::High)) .await .unwrap(); let drop = state .tasks .create(user_id, task("Delete me high", Priority::High)) .await .unwrap(); state.tasks.delete(drop.id, user_id).await.unwrap(); let query = SearchQuery { priority: Some(Priority::High), ..Default::default() }; let (results, _total) = state.search.search(user_id, query).await.unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].id, *keep.id); } /// The result-type enum is stringified in the response conversion; verify the mapping. #[tokio::test] #[allow( clippy::float_cmp, reason = "asserting an exact f64 rank round-trips through the response conversion" )] async fn response_conversion_maps_type_strings() { let cases = [ (SearchResultType::Task, "task"), (SearchResultType::Email, "email"), (SearchResultType::Project, "project"), (SearchResultType::Event, "event"), (SearchResultType::Contact, "contact"), ]; for (rt, expected) in cases { let item = SearchResultItem { id: Uuid::new_v4(), result_type: rt, title: "Title".to_string(), snippet: Some("snip".to_string()), project_id: None, project_name: Some("Proj".to_string()), rank: 1.5, }; let resp = SearchResultResponse::from(item.clone()); assert_eq!(resp.result_type, expected); assert_eq!(resp.id, item.id); assert_eq!(resp.title, "Title"); assert_eq!(resp.snippet.as_deref(), Some("snip")); assert_eq!(resp.rank, 1.5); } }