Skip to main content

max / goingson

14.6 KB · 411 lines History Blame Raw
1 //! Integration tests for SqliteEmailRepository.
2
3 mod common;
4
5 use chrono::{Duration, Utc};
6 use goingson_core::{EmailRepository, NewEmail};
7 use goingson_db_sqlite::SqliteEmailRepository;
8
9 #[tokio::test]
10 async fn test_create_and_get_email() {
11 let pool = common::setup_test_db().await;
12 let user_id = common::create_test_user(&pool).await;
13 let repo = SqliteEmailRepository::new(pool);
14
15 let new_email = NewEmail {
16 project_id: None,
17 from_address: "sender@example.com".to_string(),
18 to_address: "recipient@example.com".to_string(),
19 subject: "Test Subject".to_string(),
20 body: "Test email body content".to_string(),
21 is_read: false,
22 received_at: Some(Utc::now()),
23 };
24
25 let created = repo.create(user_id, new_email).await.expect("Failed to create email");
26 assert_eq!(created.subject, "Test Subject");
27 assert_eq!(created.from, "sender@example.com");
28 assert!(!created.is_read);
29 assert!(!created.is_archived);
30
31 let fetched = repo.get_by_id(created.id, user_id).await.expect("Failed to get email");
32 assert!(fetched.is_some());
33 assert_eq!(fetched.unwrap().id, created.id);
34 }
35
36 #[tokio::test]
37 async fn test_email_read_unread() {
38 let pool = common::setup_test_db().await;
39 let user_id = common::create_test_user(&pool).await;
40 let repo = SqliteEmailRepository::new(pool);
41
42 let new_email = NewEmail {
43 project_id: None,
44 from_address: "sender@example.com".to_string(),
45 to_address: "recipient@example.com".to_string(),
46 subject: "Read/Unread test".to_string(),
47 body: "Test body".to_string(),
48 is_read: false,
49 received_at: Some(Utc::now()),
50 };
51
52 let email = repo.create(user_id, new_email).await.expect("Failed to create");
53 assert!(!email.is_read);
54
55 // Mark as read
56 let marked_read = repo.mark_read(email.id, user_id).await.expect("Failed to mark read");
57 assert!(marked_read);
58
59 // Verify it's read
60 let fetched = repo.get_by_id(email.id, user_id).await.expect("Failed to get").unwrap();
61 assert!(fetched.is_read);
62
63 // Mark as unread
64 let marked_unread = repo.mark_unread(email.id, user_id).await.expect("Failed to mark unread");
65 assert!(marked_unread);
66
67 // Verify it's unread
68 let fetched = repo.get_by_id(email.id, user_id).await.expect("Failed to get").unwrap();
69 assert!(!fetched.is_read);
70 }
71
72 #[tokio::test]
73 async fn test_email_archive() {
74 let pool = common::setup_test_db().await;
75 let user_id = common::create_test_user(&pool).await;
76 let repo = SqliteEmailRepository::new(pool);
77
78 let new_email = NewEmail {
79 project_id: None,
80 from_address: "sender@example.com".to_string(),
81 to_address: "recipient@example.com".to_string(),
82 subject: "Archive test".to_string(),
83 body: "Test body".to_string(),
84 is_read: false,
85 received_at: Some(Utc::now()),
86 };
87
88 let email = repo.create(user_id, new_email).await.expect("Failed to create");
89 assert!(!email.is_archived);
90
91 // Archive
92 let archived = repo.archive(email.id, user_id).await.expect("Failed to archive");
93 assert!(archived);
94
95 // Verify it's archived
96 let fetched = repo.get_by_id(email.id, user_id).await.expect("Failed to get").unwrap();
97 assert!(fetched.is_archived);
98
99 // Should not appear in non-archived list
100 let emails = repo.list_all(user_id, false).await.expect("Failed to list");
101 assert!(emails.iter().all(|e| e.id != email.id));
102
103 // Should appear in list with archived included
104 let all_emails = repo.list_all(user_id, true).await.expect("Failed to list all");
105 assert!(all_emails.iter().any(|e| e.id == email.id));
106
107 // Unarchive
108 let unarchived = repo.unarchive(email.id, user_id).await.expect("Failed to unarchive");
109 assert!(unarchived);
110
111 // Verify it's unarchived
112 let fetched = repo.get_by_id(email.id, user_id).await.expect("Failed to get").unwrap();
113 assert!(!fetched.is_archived);
114 }
115
116 #[tokio::test]
117 async fn test_email_snooze() {
118 let pool = common::setup_test_db().await;
119 let user_id = common::create_test_user(&pool).await;
120 let repo = SqliteEmailRepository::new(pool);
121
122 let new_email = NewEmail {
123 project_id: None,
124 from_address: "sender@example.com".to_string(),
125 to_address: "recipient@example.com".to_string(),
126 subject: "Snooze test".to_string(),
127 body: "Test body".to_string(),
128 is_read: false,
129 received_at: Some(Utc::now()),
130 };
131
132 let email = repo.create(user_id, new_email).await.expect("Failed to create");
133
134 let until = Utc::now() + Duration::hours(4);
135 let snoozed = repo.snooze(email.id, user_id, until).await.expect("Failed to snooze");
136 assert!(snoozed.is_some());
137 assert!(snoozed.as_ref().unwrap().snoozed_until.is_some());
138
139 // Should appear in snoozed list
140 let snoozed_emails = repo.list_snoozed(user_id).await.expect("Failed to list snoozed");
141 assert_eq!(snoozed_emails.len(), 1);
142
143 // Unsnooze
144 let unsnoozed = repo.unsnooze(email.id, user_id).await.expect("Failed to unsnooze");
145 assert!(unsnoozed.is_some());
146 assert!(unsnoozed.unwrap().snoozed_until.is_none());
147 }
148
149 #[tokio::test]
150 async fn test_email_waiting_for_response() {
151 let pool = common::setup_test_db().await;
152 let user_id = common::create_test_user(&pool).await;
153 let repo = SqliteEmailRepository::new(pool);
154
155 let new_email = NewEmail {
156 project_id: None,
157 from_address: "me@example.com".to_string(),
158 to_address: "them@example.com".to_string(),
159 subject: "Waiting test".to_string(),
160 body: "Test body".to_string(),
161 is_read: true,
162 received_at: Some(Utc::now()),
163 };
164
165 let email = repo.create(user_id, new_email).await.expect("Failed to create");
166
167 let expected = Utc::now() + Duration::days(2);
168 let waiting = repo.mark_waiting(email.id, user_id, Some(expected)).await.expect("Failed to mark waiting");
169 assert!(waiting.is_some());
170 assert!(waiting.as_ref().unwrap().waiting_for_response);
171
172 // Should appear in waiting list
173 let waiting_emails = repo.list_waiting(user_id).await.expect("Failed to list waiting");
174 assert_eq!(waiting_emails.len(), 1);
175
176 // Clear waiting
177 let cleared = repo.clear_waiting(email.id, user_id).await.expect("Failed to clear");
178 assert!(cleared.is_some());
179 assert!(!cleared.unwrap().waiting_for_response);
180 }
181
182 #[tokio::test]
183 async fn test_delete_email() {
184 let pool = common::setup_test_db().await;
185 let user_id = common::create_test_user(&pool).await;
186 let repo = SqliteEmailRepository::new(pool);
187
188 let new_email = NewEmail {
189 project_id: None,
190 from_address: "sender@example.com".to_string(),
191 to_address: "recipient@example.com".to_string(),
192 subject: "Delete test".to_string(),
193 body: "Test body".to_string(),
194 is_read: false,
195 received_at: Some(Utc::now()),
196 };
197
198 let email = repo.create(user_id, new_email).await.expect("Failed to create");
199
200 let deleted = repo.delete(email.id, user_id).await.expect("Failed to delete");
201 assert!(deleted);
202
203 let fetched = repo.get_by_id(email.id, user_id).await.expect("Failed to get");
204 assert!(fetched.is_none());
205 }
206
207 #[tokio::test]
208 async fn test_email_ordering_by_received_at() {
209 let pool = common::setup_test_db().await;
210 let user_id = common::create_test_user(&pool).await;
211 let repo = SqliteEmailRepository::new(pool);
212
213 // Create emails at different times
214 for i in 0..3 {
215 let new_email = NewEmail {
216 project_id: None,
217 from_address: "sender@example.com".to_string(),
218 to_address: "recipient@example.com".to_string(),
219 subject: format!("Email {}", i),
220 body: "Test body".to_string(),
221 is_read: false,
222 received_at: Some(Utc::now() - Duration::hours(i as i64)),
223 };
224 repo.create(user_id, new_email).await.expect("Failed to create");
225 }
226
227 let emails = repo.list_all(user_id, false).await.expect("Failed to list");
228 assert_eq!(emails.len(), 3);
229
230 // Should be ordered by received_at descending (newest first)
231 assert!(emails[0].received_at >= emails[1].received_at);
232 assert!(emails[1].received_at >= emails[2].received_at);
233 }
234
235 #[tokio::test]
236 async fn test_list_threaded_returns_latest_per_thread() {
237 let pool = common::setup_test_db().await;
238 let user_id = common::create_test_user(&pool).await;
239 let repo = SqliteEmailRepository::new(pool.clone());
240
241 let mk = |subject: &str, read: bool, hours_ago: i64| NewEmail {
242 project_id: None,
243 from_address: "a@example.com".to_string(),
244 to_address: "me@example.com".to_string(),
245 subject: subject.to_string(),
246 body: "body".to_string(),
247 is_read: read,
248 received_at: Some(Utc::now() - Duration::hours(hours_ago)),
249 };
250
251 // Two emails that will share a thread (older read, newer unread) + a standalone.
252 let older = repo.create(user_id, mk("Thread root", true, 2)).await.unwrap();
253 let newer = repo.create(user_id, mk("Thread reply", false, 1)).await.unwrap();
254 let _standalone = repo.create(user_id, mk("Lonely", true, 0)).await.unwrap();
255
256 // Group the pair into one thread keyed by the root id.
257 sqlx::query("UPDATE emails SET thread_id = ? WHERE id IN (?, ?)")
258 .bind(older.id.to_string())
259 .bind(older.id.to_string())
260 .bind(newer.id.to_string())
261 .execute(&pool)
262 .await
263 .unwrap();
264
265 let (threads, total) = repo
266 .list_threaded(user_id, true, None, None, None, None)
267 .await
268 .expect("list_threaded");
269
270 assert_eq!(total, 2, "the grouped pair counts as one thread, plus the standalone");
271 let grouped = threads
272 .iter()
273 .find(|t| t.thread_count == 2)
274 .expect("expected a 2-email thread");
275 // The window function must surface the newest message as the thread's face.
276 assert_eq!(grouped.most_recent_email.id, newer.id);
277 assert!(grouped.has_unread, "thread contains an unread email");
278 }
279
280 #[tokio::test]
281 async fn test_count_unread() {
282 let pool = common::setup_test_db().await;
283 let user_id = common::create_test_user(&pool).await;
284 let repo = SqliteEmailRepository::new(pool);
285
286 // Create 3 emails, mark 1 as read
287 for i in 0..3 {
288 let new_email = NewEmail {
289 project_id: None,
290 from_address: "sender@example.com".to_string(),
291 to_address: "recipient@example.com".to_string(),
292 subject: format!("Email {}", i),
293 body: "Test body".to_string(),
294 is_read: i == 0, // First one is read
295 received_at: Some(Utc::now()),
296 };
297 repo.create(user_id, new_email).await.expect("Failed to create");
298 }
299
300 let count = repo.count_unread(user_id).await.expect("Failed to count");
301 assert_eq!(count, 2);
302 }
303
304 #[tokio::test]
305 async fn test_mark_all_read() {
306 let pool = common::setup_test_db().await;
307 let user_id = common::create_test_user(&pool).await;
308 let repo = SqliteEmailRepository::new(pool);
309
310 // Create 3 unread emails
311 for i in 0..3 {
312 let new_email = NewEmail {
313 project_id: None,
314 from_address: "sender@example.com".to_string(),
315 to_address: "recipient@example.com".to_string(),
316 subject: format!("Email {}", i),
317 body: "Test body".to_string(),
318 is_read: false,
319 received_at: Some(Utc::now()),
320 };
321 repo.create(user_id, new_email).await.expect("Failed to create");
322 }
323
324 let marked = repo.mark_all_read(user_id).await.expect("Failed to mark all read");
325 assert_eq!(marked, 3);
326
327 let count = repo.count_unread(user_id).await.expect("Failed to count");
328 assert_eq!(count, 0);
329 }
330
331 /// The project-dashboard email list (`list_by_project`) renders only
332 /// subject/from/date and opens the reader (re-fetching the body via
333 /// `get_by_id`) on click, so it must return body-less rows -- otherwise every
334 /// email body materializes into RAM. Mirrors `list_metadata`'s Perf S3 rule.
335 #[tokio::test]
336 async fn list_by_project_is_body_less() {
337 use goingson_core::{NewProject, ProjectRepository};
338 use goingson_db_sqlite::SqliteProjectRepository;
339
340 let pool = common::setup_test_db().await;
341 let user_id = common::create_test_user(&pool).await;
342 let projects = SqliteProjectRepository::new(pool.clone());
343 let emails = SqliteEmailRepository::new(pool);
344
345 let project = projects
346 .create(
347 user_id,
348 NewProject {
349 name: "Dashboard project".into(),
350 description: String::new(),
351 project_type: Default::default(),
352 status: Default::default(),
353 },
354 )
355 .await
356 .expect("create project");
357
358 emails
359 .create(
360 user_id,
361 NewEmail {
362 project_id: Some(project.id),
363 from_address: "s@example.com".to_string(),
364 to_address: "r@example.com".to_string(),
365 subject: "Linked".to_string(),
366 body: "a very long body that must not be loaded for a list view".to_string(),
367 is_read: false,
368 received_at: Some(Utc::now()),
369 },
370 )
371 .await
372 .expect("create email");
373
374 let listed = emails.list_by_project(user_id, project.id).await.expect("list_by_project");
375 assert_eq!(listed.len(), 1);
376 assert_eq!(listed[0].subject, "Linked", "metadata still returned");
377 assert!(listed[0].body.is_empty(), "body must not be materialized in a list view");
378 assert!(listed[0].html_body.is_none(), "html body must not be materialized");
379 assert!(listed[0].body_truncated, "reader must know to re-fetch the full body");
380 }
381
382 /// `list_unlinked` feeds the "link email to project" picker (subject/from only)
383 /// and must likewise stay body-less so an unbounded mailbox can't blow up RAM.
384 #[tokio::test]
385 async fn list_unlinked_is_body_less() {
386 let pool = common::setup_test_db().await;
387 let user_id = common::create_test_user(&pool).await;
388 let repo = SqliteEmailRepository::new(pool);
389
390 repo.create(
391 user_id,
392 NewEmail {
393 project_id: None,
394 from_address: "s@example.com".to_string(),
395 to_address: "r@example.com".to_string(),
396 subject: "Unlinked".to_string(),
397 body: "body that should never be loaded into the picker".to_string(),
398 is_read: false,
399 received_at: Some(Utc::now()),
400 },
401 )
402 .await
403 .expect("create email");
404
405 let listed = repo.list_unlinked(user_id).await.expect("list_unlinked");
406 assert_eq!(listed.len(), 1);
407 assert_eq!(listed[0].subject, "Unlinked");
408 assert!(listed[0].body.is_empty(), "body must not be materialized");
409 assert!(listed[0].body_truncated, "reader must know to re-fetch the full body");
410 }
411