Skip to main content

max / makenotwork

19.9 KB · 689 lines History Blame Raw
1 //! Mailing list infrastructure: default list creation, follow/unfollow subscription hooks,
2 //! unsubscribe asymmetry, idempotency, and content newsletter delivery (I4).
3
4 use crate::harness::TestHarness;
5 use serde_json::Value;
6
7 /// Helper: create a creator, create a project via API, return (creator_id, project_id).
8 async fn create_project(h: &mut TestHarness) -> (makenotwork::db::UserId, String) {
9 let creator_id = h
10 .signup("mlcreator", "mlcreator@test.com", "password123")
11 .await;
12 h.grant_creator(creator_id).await;
13 h.client.post_form("/logout", "").await;
14 h.login("mlcreator", "password123").await;
15
16 let resp = h
17 .client
18 .post_form("/api/projects", "slug=mlproject&title=ML+Project")
19 .await;
20 assert!(
21 resp.status.is_success(),
22 "Create project failed: {} {}",
23 resp.status,
24 resp.text
25 );
26 let project: Value = resp.json();
27 let project_id = project["id"].as_str().unwrap().to_string();
28
29 // Make it public
30 let resp = h
31 .client
32 .put_json(
33 &format!("/api/projects/{project_id}"),
34 r#"{"visibility": "public"}"#,
35 )
36 .await;
37 assert!(
38 resp.status.is_success(),
39 "Make project public failed: {} {}",
40 resp.status,
41 resp.text
42 );
43
44 (creator_id, project_id)
45 }
46
47 #[tokio::test]
48 async fn project_creation_creates_default_lists() {
49 let mut h = TestHarness::new().await;
50 let (_creator_id, project_id) = create_project(&mut h).await;
51
52 // Verify two mailing lists were created (content + devlog)
53 let count: i64 =
54 sqlx::query_scalar("SELECT COUNT(*) FROM mailing_lists WHERE project_id = $1::uuid")
55 .bind(&project_id)
56 .fetch_one(&h.db)
57 .await
58 .unwrap();
59 assert_eq!(
60 count, 2,
61 "Should have 2 default mailing lists (content + devlog)"
62 );
63
64 // Verify list types
65 let types: Vec<String> = sqlx::query_scalar(
66 "SELECT list_type FROM mailing_lists WHERE project_id = $1::uuid ORDER BY list_type",
67 )
68 .bind(&project_id)
69 .fetch_all(&h.db)
70 .await
71 .unwrap();
72 assert_eq!(types, vec!["content", "devlog"]);
73 }
74
75 #[tokio::test]
76 async fn follow_project_subscribes_to_content_list() {
77 let mut h = TestHarness::new().await;
78 let (_creator_id, project_id) = create_project(&mut h).await;
79
80 // Create a follower
81 h.client.post_form("/logout", "").await;
82 let follower_id = h
83 .signup("mlfollower", "mlfollower@test.com", "password123")
84 .await;
85
86 // Follow the project
87 let resp = h
88 .client
89 .post_form(&format!("/api/follow/project/{project_id}"), "")
90 .await;
91 assert!(
92 resp.status.is_success(),
93 "Follow failed: {} {}",
94 resp.status,
95 resp.text
96 );
97
98 // Verify subscriber row exists in content list
99 let sub_count: i64 = sqlx::query_scalar(
100 r"
101 SELECT COUNT(*) FROM mailing_list_subscribers s
102 JOIN mailing_lists l ON l.id = s.list_id
103 WHERE l.project_id = $1::uuid AND l.list_type = 'content' AND s.user_id = $2
104 ",
105 )
106 .bind(&project_id)
107 .bind(follower_id)
108 .fetch_one(&h.db)
109 .await
110 .unwrap();
111 assert_eq!(
112 sub_count, 1,
113 "Follower should be subscribed to content list"
114 );
115 }
116
117 #[tokio::test]
118 async fn unfollow_project_removes_from_all_lists() {
119 let mut h = TestHarness::new().await;
120 let (_creator_id, project_id) = create_project(&mut h).await;
121
122 // Create and login as follower
123 h.client.post_form("/logout", "").await;
124 let follower_id = h
125 .signup("mlfollower2", "mlfollower2@test.com", "password123")
126 .await;
127
128 // Follow (auto-subscribes to content list)
129 let resp = h
130 .client
131 .post_form(&format!("/api/follow/project/{project_id}"), "")
132 .await;
133 assert!(resp.status.is_success());
134
135 // Also manually subscribe to devlog list
136 let devlog_id: uuid::Uuid = sqlx::query_scalar(
137 "SELECT id FROM mailing_lists WHERE project_id = $1::uuid AND list_type = 'devlog'",
138 )
139 .bind(&project_id)
140 .fetch_one(&h.db)
141 .await
142 .unwrap();
143 sqlx::query("INSERT INTO mailing_list_subscribers (list_id, user_id) VALUES ($1, $2)")
144 .bind(devlog_id)
145 .bind(follower_id)
146 .execute(&h.db)
147 .await
148 .unwrap();
149
150 // Verify 2 subscriptions exist
151 let sub_count: i64 = sqlx::query_scalar(
152 r"
153 SELECT COUNT(*) FROM mailing_list_subscribers s
154 JOIN mailing_lists l ON l.id = s.list_id
155 WHERE l.project_id = $1::uuid AND s.user_id = $2
156 ",
157 )
158 .bind(&project_id)
159 .bind(follower_id)
160 .fetch_one(&h.db)
161 .await
162 .unwrap();
163 assert_eq!(
164 sub_count, 2,
165 "Should be subscribed to both lists before unfollow"
166 );
167
168 // Unfollow the project
169 let resp = h
170 .client
171 .delete(&format!("/api/follow/project/{project_id}"))
172 .await;
173 assert!(
174 resp.status.is_success(),
175 "Unfollow failed: {} {}",
176 resp.status,
177 resp.text
178 );
179
180 // Verify all subscriptions removed
181 let sub_count: i64 = sqlx::query_scalar(
182 r"
183 SELECT COUNT(*) FROM mailing_list_subscribers s
184 JOIN mailing_lists l ON l.id = s.list_id
185 WHERE l.project_id = $1::uuid AND s.user_id = $2
186 ",
187 )
188 .bind(&project_id)
189 .bind(follower_id)
190 .fetch_one(&h.db)
191 .await
192 .unwrap();
193 assert_eq!(
194 sub_count, 0,
195 "All subscriptions should be removed after unfollow"
196 );
197 }
198
199 #[tokio::test]
200 async fn unsubscribe_removes_from_list_but_keeps_follow() {
201 let mut h = TestHarness::new().await;
202 let (_creator_id, project_id) = create_project(&mut h).await;
203
204 // Create and login as follower
205 h.client.post_form("/logout", "").await;
206 let follower_id = h
207 .signup("mlfollower3", "mlfollower3@test.com", "password123")
208 .await;
209
210 // Follow (auto-subscribes to content list)
211 let resp = h
212 .client
213 .post_form(&format!("/api/follow/project/{project_id}"), "")
214 .await;
215 assert!(resp.status.is_success());
216
217 // Directly unsubscribe from content list via DB
218 let content_list_id: uuid::Uuid = sqlx::query_scalar(
219 "SELECT id FROM mailing_lists WHERE project_id = $1::uuid AND list_type = 'content'",
220 )
221 .bind(&project_id)
222 .fetch_one(&h.db)
223 .await
224 .unwrap();
225
226 let deleted =
227 sqlx::query("DELETE FROM mailing_list_subscribers WHERE list_id = $1 AND user_id = $2")
228 .bind(content_list_id)
229 .bind(follower_id)
230 .execute(&h.db)
231 .await
232 .unwrap();
233 assert_eq!(
234 deleted.rows_affected(),
235 1,
236 "Should have deleted one subscription"
237 );
238
239 // Verify follow relationship still exists
240 let is_following: bool = sqlx::query_scalar(
241 "SELECT EXISTS(SELECT 1 FROM follows WHERE follower_id = $1 AND target_id = $2::uuid)",
242 )
243 .bind(follower_id)
244 .bind(&project_id)
245 .fetch_one(&h.db)
246 .await
247 .unwrap();
248 assert!(
249 is_following,
250 "Follow should still exist after mailing list unsubscribe"
251 );
252 }
253
254 #[tokio::test]
255 async fn imported_email_only_subscribers_are_reachable_and_unsubscribable() {
256 let mut h = TestHarness::new().await;
257 let (_creator_id, project_id) = create_project(&mut h).await;
258
259 let content_list_id: uuid::Uuid = sqlx::query_scalar(
260 "SELECT id FROM mailing_lists WHERE project_id = $1::uuid AND list_type = 'content'",
261 )
262 .bind(&project_id)
263 .fetch_one(&h.db)
264 .await
265 .unwrap();
266 let list_id = makenotwork::db::MailingListId::from_uuid(content_list_id);
267
268 // Import an email-only subscriber (no MNW account), as the import pipeline does.
269 let inserted = makenotwork::db::mailing_lists::subscribe_many_by_email(
270 &h.db,
271 list_id,
272 &["Imported@Example.com".to_string()],
273 )
274 .await
275 .unwrap();
276 assert_eq!(inserted, 1);
277
278 // get_subscribers must include it, an INNER JOIN on users used to drop
279 // email-only rows so imported subscribers were never emailed (Run 21).
280 let subs = makenotwork::db::mailing_lists::get_subscribers(&h.db, list_id)
281 .await
282 .unwrap();
283 let imported = subs
284 .iter()
285 .find(|s| s.email == "imported@example.com")
286 .expect("imported email-only subscriber must be reachable");
287 assert!(
288 imported.user_id.is_none(),
289 "imported subscriber has no MNW account"
290 );
291
292 // Email-keyed unsubscribe removes it.
293 let removed = makenotwork::db::mailing_lists::unsubscribe_by_email(
294 &h.db,
295 list_id,
296 "imported@example.com",
297 )
298 .await
299 .unwrap();
300 assert!(removed);
301
302 let subs_after = makenotwork::db::mailing_lists::get_subscribers(&h.db, list_id)
303 .await
304 .unwrap();
305 assert!(
306 !subs_after.iter().any(|s| s.email == "imported@example.com"),
307 "unsubscribed email-only subscriber must be gone"
308 );
309 }
310
311 #[tokio::test]
312 async fn subscribe_idempotent() {
313 let mut h = TestHarness::new().await;
314 let (_creator_id, project_id) = create_project(&mut h).await;
315
316 // Create and login as follower
317 h.client.post_form("/logout", "").await;
318 let follower_id = h
319 .signup("mlfollower4", "mlfollower4@test.com", "password123")
320 .await;
321
322 // Follow twice (both should succeed without error)
323 let resp = h
324 .client
325 .post_form(&format!("/api/follow/project/{project_id}"), "")
326 .await;
327 assert!(resp.status.is_success());
328
329 let resp = h
330 .client
331 .post_form(&format!("/api/follow/project/{project_id}"), "")
332 .await;
333 assert!(resp.status.is_success());
334
335 // Verify exactly one subscriber row
336 let sub_count: i64 = sqlx::query_scalar(
337 r"
338 SELECT COUNT(*) FROM mailing_list_subscribers s
339 JOIN mailing_lists l ON l.id = s.list_id
340 WHERE l.project_id = $1::uuid AND l.list_type = 'content' AND s.user_id = $2
341 ",
342 )
343 .bind(&project_id)
344 .bind(follower_id)
345 .fetch_one(&h.db)
346 .await
347 .unwrap();
348 assert_eq!(
349 sub_count, 1,
350 "Should have exactly one subscriber row despite double follow"
351 );
352 }
353
354 #[tokio::test]
355 async fn default_lists_idempotent() {
356 let mut h = TestHarness::new().await;
357 let (_creator_id, project_id) = create_project(&mut h).await;
358
359 // Insert duplicate lists via SQL with ON CONFLICT, should not error
360 sqlx::query(
361 r"
362 INSERT INTO mailing_lists (project_id, list_type, name)
363 VALUES ($1::uuid, 'content', 'Duplicate Content')
364 ON CONFLICT (project_id, list_type) DO UPDATE SET name = EXCLUDED.name
365 ",
366 )
367 .bind(&project_id)
368 .execute(&h.db)
369 .await
370 .expect("ON CONFLICT should handle duplicate list creation");
371
372 // Still exactly 2 lists (content was upserted, devlog unchanged)
373 let count: i64 =
374 sqlx::query_scalar("SELECT COUNT(*) FROM mailing_lists WHERE project_id = $1::uuid")
375 .bind(&project_id)
376 .fetch_one(&h.db)
377 .await
378 .unwrap();
379 assert_eq!(
380 count, 2,
381 "Should still have exactly 2 lists after idempotent insert"
382 );
383 }
384
385 // I4: Content Newsletter Delivery
386
387 /// Helper: create a project with a follower subscribed to the content mailing list.
388 /// Returns (creator_id, project_id, follower_id).
389 async fn setup_project_with_subscriber(
390 h: &mut TestHarness,
391 ) -> (makenotwork::db::UserId, String, makenotwork::db::UserId) {
392 let creator_id = h
393 .signup("i4creator", "i4creator@test.com", "password123")
394 .await;
395 h.grant_creator(creator_id).await;
396 h.client.post_form("/logout", "").await;
397 h.login("i4creator", "password123").await;
398
399 let resp = h
400 .client
401 .post_form("/api/projects", "slug=i4project&title=I4+Project")
402 .await;
403 assert!(
404 resp.status.is_success(),
405 "Create project failed: {} {}",
406 resp.status,
407 resp.text
408 );
409 let project: Value = resp.json();
410 let project_id = project["id"].as_str().unwrap().to_string();
411
412 // Make project public
413 let resp = h
414 .client
415 .put_json(
416 &format!("/api/projects/{project_id}"),
417 r#"{"visibility": "public"}"#,
418 )
419 .await;
420 assert!(resp.status.is_success());
421
422 // Create a follower and follow the project (auto-subscribes to content list)
423 h.client.post_form("/logout", "").await;
424 let follower_id = h
425 .signup("i4follower", "i4follower@test.com", "password123")
426 .await;
427 // Verify email so they'd receive announcements
428 sqlx::query("UPDATE users SET email_verified = true WHERE id = $1")
429 .bind(follower_id)
430 .execute(&h.db)
431 .await
432 .unwrap();
433
434 let resp = h
435 .client
436 .post_form(&format!("/api/follow/project/{project_id}"), "")
437 .await;
438 assert!(
439 resp.status.is_success(),
440 "Follow failed: {} {}",
441 resp.status,
442 resp.text
443 );
444
445 (creator_id, project_id, follower_id)
446 }
447
448 #[tokio::test]
449 async fn item_release_emails_use_mailing_list() {
450 let mut h = TestHarness::new().await;
451 let (_creator_id, project_id, follower_id) = setup_project_with_subscriber(&mut h).await;
452
453 // Switch back to creator
454 h.client.post_form("/logout", "").await;
455 h.login("i4creator", "password123").await;
456
457 // Create an item
458 let resp = h
459 .client
460 .post_form(
461 &format!("/api/projects/{project_id}/items"),
462 "title=Test+Item&item_type=digital",
463 )
464 .await;
465 assert!(
466 resp.status.is_success(),
467 "Create item failed: {} {}",
468 resp.status,
469 resp.text
470 );
471 let item: Value = resp.json();
472 let item_id = item["id"].as_str().unwrap().to_string();
473
474 // Publish the item
475 let resp = h
476 .client
477 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
478 .await;
479 assert!(
480 resp.status.is_success(),
481 "Publish item failed: {} {}",
482 resp.status,
483 resp.text
484 );
485
486 // Verify release_announced_at is set
487 let announced: bool = sqlx::query_scalar(
488 "SELECT release_announced_at IS NOT NULL FROM items WHERE id = $1::uuid",
489 )
490 .bind(&item_id)
491 .fetch_one(&h.db)
492 .await
493 .unwrap();
494 assert!(
495 announced,
496 "release_announced_at should be set after publishing"
497 );
498
499 // Verify the follower is a subscriber on the content mailing list
500 let sub_count: i64 = sqlx::query_scalar(
501 r"
502 SELECT COUNT(*) FROM mailing_list_subscribers s
503 JOIN mailing_lists l ON l.id = s.list_id
504 WHERE l.project_id = $1::uuid AND l.list_type = 'content' AND s.user_id = $2
505 ",
506 )
507 .bind(&project_id)
508 .bind(follower_id)
509 .fetch_one(&h.db)
510 .await
511 .unwrap();
512 assert_eq!(sub_count, 1, "Follower should be on content mailing list");
513
514 // Verify idempotency: trying to clear release_announced_at and re-announce
515 // should demonstrate that the column is set (no row affected)
516 let rows_affected = sqlx::query(
517 "UPDATE items SET release_announced_at = NOW() WHERE id = $1::uuid AND release_announced_at IS NULL",
518 )
519 .bind(&item_id)
520 .execute(&h.db)
521 .await
522 .unwrap()
523 .rows_affected();
524 assert_eq!(
525 rows_affected, 0,
526 "Second mark should be a no-op (already announced)"
527 );
528 }
529
530 #[tokio::test]
531 async fn blog_post_announcement_sends_emails() {
532 let mut h = TestHarness::new().await;
533 let (_creator_id, project_id, _follower_id) = setup_project_with_subscriber(&mut h).await;
534
535 // Switch back to creator
536 h.client.post_form("/logout", "").await;
537 h.login("i4creator", "password123").await;
538
539 // Create a published blog post
540 let resp = h
541 .client
542 .post_json(
543 &format!("/api/projects/{project_id}/blog"),
544 r#"{"title": "Test Blog Post", "body_markdown": "Hello world", "is_published": true}"#,
545 )
546 .await;
547 assert!(
548 resp.status.is_success(),
549 "Create blog post failed: {} {}",
550 resp.status,
551 resp.text
552 );
553 let post: Value = resp.json();
554 let post_id = post["id"].as_str().unwrap().to_string();
555
556 // Verify release_announced_at is set on the blog post
557 let announced: bool = sqlx::query_scalar(
558 "SELECT release_announced_at IS NOT NULL FROM blog_posts WHERE id = $1::uuid",
559 )
560 .bind(&post_id)
561 .fetch_one(&h.db)
562 .await
563 .unwrap();
564 assert!(
565 announced,
566 "release_announced_at should be set after publishing blog post"
567 );
568
569 // Verify idempotency: second mark is a no-op
570 let rows_affected = sqlx::query(
571 "UPDATE blog_posts SET release_announced_at = NOW() WHERE id = $1::uuid AND release_announced_at IS NULL",
572 )
573 .bind(&post_id)
574 .execute(&h.db)
575 .await
576 .unwrap()
577 .rows_affected();
578 assert_eq!(
579 rows_affected, 0,
580 "Second mark should be a no-op (already announced)"
581 );
582 }
583
584 #[tokio::test]
585 async fn web_only_item_skips_email() {
586 let mut h = TestHarness::new().await;
587 let (_creator_id, project_id, _follower_id) = setup_project_with_subscriber(&mut h).await;
588
589 // Switch back to creator
590 h.client.post_form("/logout", "").await;
591 h.login("i4creator", "password123").await;
592
593 // Create an item
594 let resp = h
595 .client
596 .post_form(
597 &format!("/api/projects/{project_id}/items"),
598 "title=WebOnly+Item&item_type=digital",
599 )
600 .await;
601 assert!(resp.status.is_success());
602 let item: Value = resp.json();
603 let item_id = item["id"].as_str().unwrap().to_string();
604
605 // Set web_only=true and publish
606 let resp = h
607 .client
608 .put_form(
609 &format!("/api/items/{item_id}"),
610 "is_public=true&web_only=true",
611 )
612 .await;
613 assert!(
614 resp.status.is_success(),
615 "Publish web_only item failed: {} {}",
616 resp.status,
617 resp.text
618 );
619
620 // Verify web_only is set
621 let web_only: bool = sqlx::query_scalar("SELECT web_only FROM items WHERE id = $1::uuid")
622 .bind(&item_id)
623 .fetch_one(&h.db)
624 .await
625 .unwrap();
626 assert!(web_only, "web_only should be true");
627
628 // Verify release_announced_at IS set (the mark happens before the web_only check)
629 let announced: bool = sqlx::query_scalar(
630 "SELECT release_announced_at IS NOT NULL FROM items WHERE id = $1::uuid",
631 )
632 .bind(&item_id)
633 .fetch_one(&h.db)
634 .await
635 .unwrap();
636 assert!(
637 announced,
638 "release_announced_at should still be set (idempotent guard)"
639 );
640 }
641
642 #[tokio::test]
643 async fn web_only_blog_post_skips_email() {
644 let mut h = TestHarness::new().await;
645 let (_creator_id, project_id, _follower_id) = setup_project_with_subscriber(&mut h).await;
646
647 // Switch back to creator
648 h.client.post_form("/logout", "").await;
649 h.login("i4creator", "password123").await;
650
651 // Create a published web_only blog post
652 let resp = h
653 .client
654 .post_json(
655 &format!("/api/projects/{project_id}/blog"),
656 r#"{"title": "Web Only Post", "body_markdown": "Silent post", "is_published": true, "web_only": true}"#,
657 )
658 .await;
659 assert!(
660 resp.status.is_success(),
661 "Create web_only blog post failed: {} {}",
662 resp.status,
663 resp.text
664 );
665 let post: Value = resp.json();
666 let post_id = post["id"].as_str().unwrap().to_string();
667
668 // Verify web_only is set
669 let web_only: bool = sqlx::query_scalar("SELECT web_only FROM blog_posts WHERE id = $1::uuid")
670 .bind(&post_id)
671 .fetch_one(&h.db)
672 .await
673 .unwrap();
674 assert!(web_only, "web_only should be true on blog post");
675
676 // Verify release_announced_at IS set (idempotent guard still fires)
677 let announced: bool = sqlx::query_scalar(
678 "SELECT release_announced_at IS NOT NULL FROM blog_posts WHERE id = $1::uuid",
679 )
680 .bind(&post_id)
681 .fetch_one(&h.db)
682 .await
683 .unwrap();
684 assert!(
685 announced,
686 "release_announced_at should still be set for web_only post"
687 );
688 }
689