Skip to main content

max / makenotwork

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