Skip to main content

max / goingson

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