Skip to main content

max / goingson

5.7 KB · 204 lines History Blame Raw
1 //! Integration tests for full-text search.
2
3 use chrono::{Duration, Utc};
4 use goingson_core::{
5 NewContact, NewEvent, NewTask, Priority, SearchQuery, SearchResultItem, SearchResultType,
6 };
7 use uuid::Uuid;
8
9 use crate::commands::search::SearchResultResponse;
10 use crate::test_utils::{create_test_project, setup_test_state};
11
12 fn task(desc: &str, priority: Priority) -> NewTask {
13 NewTask::builder(desc).priority(priority).build()
14 }
15
16 #[tokio::test]
17 async fn text_search_returns_task_hit() {
18 let (state, user_id) = setup_test_state().await;
19
20 state
21 .tasks
22 .create(user_id, task("Fix login bug in auth module", Priority::Medium))
23 .await
24 .unwrap();
25 state
26 .tasks
27 .create(user_id, task("Write release notes", Priority::Medium))
28 .await
29 .unwrap();
30
31 let (results, _total) = state
32 .search
33 .search(user_id, SearchQuery::new("login"))
34 .await
35 .unwrap();
36
37 assert_eq!(results.len(), 1);
38 assert_eq!(results[0].result_type, SearchResultType::Task);
39 assert!(results[0].title.contains("login"));
40 }
41
42 #[tokio::test]
43 async fn search_spans_multiple_types() {
44 let (state, user_id) = setup_test_state().await;
45
46 state
47 .tasks
48 .create(user_id, task("Authentication rewrite", Priority::High))
49 .await
50 .unwrap();
51 create_test_project(&state, user_id).await; // "Test Project", no match
52 state
53 .contacts
54 .create(
55 user_id,
56 NewContact {
57 display_name: "Authentication Vendor".to_string(),
58 nickname: None,
59 company: None,
60 title: None,
61 notes: String::new(),
62 tags: vec![],
63 birthday: None,
64 timezone: None,
65 is_implicit: false,
66 },
67 )
68 .await
69 .unwrap();
70 let start = Utc::now() + Duration::days(1);
71 state
72 .events
73 .create(
74 user_id,
75 NewEvent::builder("Authentication planning", start).build(),
76 )
77 .await
78 .unwrap();
79
80 let (results, _total) = state
81 .search
82 .search(user_id, SearchQuery::new("authentication"))
83 .await
84 .unwrap();
85
86 let types: Vec<_> = results.iter().map(|r| r.result_type).collect();
87 assert!(types.contains(&SearchResultType::Task));
88 assert!(types.contains(&SearchResultType::Contact));
89 assert!(types.contains(&SearchResultType::Event));
90 }
91
92 #[tokio::test]
93 async fn type_filter_restricts_results() {
94 let (state, user_id) = setup_test_state().await;
95
96 state
97 .tasks
98 .create(user_id, task("Deploy infrastructure", Priority::Medium))
99 .await
100 .unwrap();
101 state
102 .contacts
103 .create(
104 user_id,
105 NewContact {
106 display_name: "Infrastructure Team".to_string(),
107 nickname: None,
108 company: None,
109 title: None,
110 notes: String::new(),
111 tags: vec![],
112 birthday: None,
113 timezone: None,
114 is_implicit: false,
115 },
116 )
117 .await
118 .unwrap();
119
120 let query = SearchQuery::new("infrastructure").with_types(vec![SearchResultType::Task]);
121 let (results, _total) = state.search.search(user_id, query).await.unwrap();
122
123 assert!(!results.is_empty());
124 assert!(results
125 .iter()
126 .all(|r| r.result_type == SearchResultType::Task));
127 }
128
129 #[tokio::test]
130 async fn empty_query_returns_nothing() {
131 let (state, user_id) = setup_test_state().await;
132 state
133 .tasks
134 .create(user_id, task("Some task", Priority::Medium))
135 .await
136 .unwrap();
137
138 let (results, total) = state
139 .search
140 .search(user_id, SearchQuery::new(""))
141 .await
142 .unwrap();
143 assert!(results.is_empty());
144 assert_eq!(total, 0);
145 }
146
147 /// A filter-only (no text) task search must exclude soft-deleted tasks
148 /// (status = 'Deleted').
149 #[tokio::test]
150 async fn filter_only_search_excludes_deleted_tasks() {
151 let (state, user_id) = setup_test_state().await;
152
153 let keep = state
154 .tasks
155 .create(user_id, task("Keep me high", Priority::High))
156 .await
157 .unwrap();
158 let drop = state
159 .tasks
160 .create(user_id, task("Delete me high", Priority::High))
161 .await
162 .unwrap();
163 state.tasks.delete(drop.id, user_id).await.unwrap();
164
165 let query = SearchQuery {
166 priority: Some(Priority::High),
167 ..Default::default()
168 };
169 let (results, _total) = state.search.search(user_id, query).await.unwrap();
170
171 assert_eq!(results.len(), 1);
172 assert_eq!(results[0].id, *keep.id);
173 }
174
175 /// The result-type enum is stringified in the response conversion; verify the mapping.
176 #[tokio::test]
177 async fn response_conversion_maps_type_strings() {
178 let cases = [
179 (SearchResultType::Task, "task"),
180 (SearchResultType::Email, "email"),
181 (SearchResultType::Project, "project"),
182 (SearchResultType::Event, "event"),
183 (SearchResultType::Contact, "contact"),
184 ];
185
186 for (rt, expected) in cases {
187 let item = SearchResultItem {
188 id: Uuid::new_v4(),
189 result_type: rt,
190 title: "Title".to_string(),
191 snippet: Some("snip".to_string()),
192 project_id: None,
193 project_name: Some("Proj".to_string()),
194 rank: 1.5,
195 };
196 let resp = SearchResultResponse::from(item.clone());
197 assert_eq!(resp.result_type, expected);
198 assert_eq!(resp.id, item.id);
199 assert_eq!(resp.title, "Title");
200 assert_eq!(resp.snippet.as_deref(), Some("snip"));
201 assert_eq!(resp.rank, 1.5);
202 }
203 }
204