Skip to main content

max / makenotwork

Split tests/workflows/lists.rs along its own step boundaries The mailing-list work landed one 1258-line module, which broke both test hygiene ratchets and failed the Sando build at 0.11.7: twelve oversized workflow modules where eleven is the seal, and 834 loose status assertions where 830 is. The file already marked its seams with step banners, so the split follows them: the model and consent log stay in lists.rs, the audience resolver goes to lists_resolver.rs, the unsubscribe surface to lists_preferences.rs, and the notification lists to lists_notifications.rs. prefs_url is the one helper two of them share and is now pub(crate) where it is defined. The four new loose assertions are tightened to the codes the handlers contract: 200 from create_project and update_preferences, 400 from preferences_toggle on a retargeted token and from unsubscribe_handler on a required list.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 14:13 UTC
Signed with PGP, not checked
Commit: 1f35027140c9ac8d5af8dcf2d564cab0fa9d43a7
Parent: c089397
5 files changed, +980 insertions, -487 deletions
@@ -4,6 +4,10 @@
4 4 //! shape: the constraints that make bad states unrepresentable, and the
5 5 //! append-only guarantee the consent log rests on. Those are the parts a later
6 6 //! step will lean on without re-checking.
7 + //!
8 + //! The later steps live beside this one: `lists_resolver` for the audience,
9 + //! `lists_preferences` for the unsubscribe surface, `lists_notifications` for
10 + //! the per-account and per-repo notification lists.
7 11
8 12 use crate::harness::TestHarness;
9 13 use makenotwork::db::{
@@ -289,970 +293,3 @@
289 293 "consent rows outlived the subscription they described"
290 294 );
291 295 }
292 -
293 - // ── Step 3: the resolver, and the legacy writes that feed it ──
294 - //
295 - // Sends now read the unified tables, so a write that reaches only the legacy
296 - // tables is a subscriber no send can see, or worse, an unsubscribe no send
297 - // honours. These pin the mirroring that closes that gap.
298 -
299 - /// The resolver requires a verified, unsuspended account, matching the query it
300 - /// replaced. `signup` does not verify, so tests that expect delivery say so.
301 - async fn verify_email(h: &TestHarness, user: makenotwork::db::UserId) {
302 - sqlx::query("UPDATE users SET email_verified = true WHERE id = $1")
303 - .bind(user)
304 - .execute(&h.db)
305 - .await
306 - .expect("verify email");
307 - }
308 -
309 - /// Create a project through the API and return its unified content list.
310 - async fn project_with_list(h: &mut TestHarness) -> (makenotwork::db::UserId, uuid::Uuid) {
311 - let creator_id = h.signup("mirror", "mirror@test.com", "password123").await;
312 - h.grant_creator(creator_id).await;
313 - h.client.post_form("/logout", "").await;
314 - h.login("mirror", "password123").await;
315 - let resp = h
316 - .client
317 - .post_form("/api/projects", "slug=mirrorproj&title=Mirror+Project")
318 - .await;
319 - assert!(resp.status.is_success(), "create project: {}", resp.text);
320 - let project: serde_json::Value = resp.json();
321 - let project_id: uuid::Uuid = project["id"].as_str().unwrap().parse().unwrap();
322 - (creator_id, project_id)
323 - }
324 -
325 - /// Creating a project mirrors its default lists, so an announcement has
326 - /// somewhere to resolve. Without this the send errors rather than silently
327 - /// mailing nobody.
328 - #[tokio::test]
329 - async fn creating_a_project_mirrors_its_lists() {
330 - let mut h = TestHarness::new().await;
331 - let (_creator, project_id) = project_with_list(&mut h).await;
332 -
333 - let content = lists::find_list(
334 - &h.db,
335 - ListScope::Project,
336 - Some(project_id),
337 - ListKind::Content,
338 - )
339 - .await
340 - .expect("query");
341 - assert!(content.is_some(), "content list was not mirrored");
342 -
343 - let devlog = lists::find_list(
344 - &h.db,
345 - ListScope::Project,
346 - Some(project_id),
347 - ListKind::Devlog,
348 - )
349 - .await
350 - .expect("query");
351 - assert!(devlog.is_some(), "devlog list was not mirrored");
352 - }
353 -
354 - /// A subscribe through the legacy path reaches the audience the resolver
355 - /// returns. This is the dual-write hazard: writes still go to the old tables,
356 - /// and sends now read the new ones.
357 - #[tokio::test]
358 - async fn a_legacy_subscribe_reaches_the_resolved_audience() {
359 - let mut h = TestHarness::new().await;
360 - let (_creator, project_id) = project_with_list(&mut h).await;
361 - let fan = h
362 - .signup("mirrorfan", "mirrorfan@test.com", "password123")
363 - .await;
364 - verify_email(&h, fan).await;
365 -
366 - let legacy = makenotwork::db::mailing_lists::get_list_by_project_and_type(
367 - &h.db,
368 - project_id.into(),
369 - makenotwork::db::MailingListType::Content,
370 - )
371 - .await
372 - .unwrap()
373 - .expect("legacy list");
374 - makenotwork::db::mailing_lists::subscribe(&h.db, legacy.id, fan)
375 - .await
376 - .expect("subscribe");
377 -
378 - let unified = lists::find_list(
379 - &h.db,
380 - ListScope::Project,
381 - Some(project_id),
382 - ListKind::Content,
383 - )
384 - .await
385 - .unwrap()
386 - .unwrap();
387 - let audience = lists::resolve_audience(&h.db, unified)
388 - .await
389 - .expect("resolve");
390 - assert!(
391 - audience.recipients.iter().any(|r| r.user_id == Some(fan)),
392 - "a subscriber added through the legacy path is invisible to sends"
393 - );
394 - }
395 -
396 - /// The one that matters most: an unsubscribe through the legacy path must
397 - /// remove them from the audience. A missed mirror here means mailing somebody
398 - /// who asked us not to.
399 - #[tokio::test]
400 - async fn a_legacy_unsubscribe_removes_them_from_the_audience() {
401 - let mut h = TestHarness::new().await;
402 - let (_creator, project_id) = project_with_list(&mut h).await;
403 - let fan = h.signup("leaver", "leaver@test.com", "password123").await;
404 - verify_email(&h, fan).await;
405 -
406 - let legacy = makenotwork::db::mailing_lists::get_list_by_project_and_type(
407 - &h.db,
408 - project_id.into(),
409 - makenotwork::db::MailingListType::Content,
410 - )
411 - .await
412 - .unwrap()
413 - .unwrap();
414 - makenotwork::db::mailing_lists::subscribe(&h.db, legacy.id, fan)
415 - .await
416 - .unwrap();
417 -
418 - let unified = lists::find_list(
419 - &h.db,
420 - ListScope::Project,
421 - Some(project_id),
422 - ListKind::Content,
423 - )
424 - .await
425 - .unwrap()
426 - .unwrap();
427 -
428 - // Present first, so this cannot pass by never having been subscribed.
429 - let before = lists::resolve_audience(&h.db, unified).await.unwrap();
430 - assert!(
431 - before.recipients.iter().any(|r| r.user_id == Some(fan)),
432 - "test setup: the subscriber never reached the audience"
433 - );
434 -
435 - makenotwork::db::mailing_lists::unsubscribe(&h.db, legacy.id, fan)
436 - .await
437 - .unwrap();
438 -
439 - let audience = lists::resolve_audience(&h.db, unified).await.unwrap();
440 - assert!(
441 - !audience.recipients.iter().any(|r| r.user_id == Some(fan)),
442 - "an unsubscribed user is still in the send audience"
443 - );
444 -
445 - // And the opt-out is on the record, not just absent from the audience.
446 - let events: Vec<String> = sqlx::query_scalar(
447 - "SELECT ce.event FROM consent_events ce \
448 - JOIN list_subscriptions ls ON ls.id = ce.subscription_id \
449 - WHERE ls.user_id = $1 ORDER BY ce.at",
450 - )
451 - .bind(fan)
452 - .fetch_all(&h.db)
453 - .await
454 - .unwrap();
455 - assert!(events.contains(&"opt_out".to_string()));
456 - }
457 -
458 - /// A suppressed address never resolves, whatever its subscription says.
459 - /// Bounces and complaints are the one rule that was already applied
460 - /// consistently, and it stays that way.
461 - #[tokio::test]
462 - async fn suppressed_addresses_are_never_in_the_audience() {
463 - let h = TestHarness::new().await;
464 - let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
465 - .await
466 - .unwrap()
467 - .unwrap();
468 -
469 - lists::subscribe(
470 - &h.db,
471 - list,
472 - &lists::Subscriber::Email("bounced@example.com".to_string()),
473 - SubscriptionState::Confirmed,
474 - SubscriptionSource::LandingForm,
475 - ConsentEvent::OptIn,
476 - None,
477 - )
478 - .await
479 - .unwrap();
480 -
481 - let before = lists::resolve_audience(&h.db, list).await.unwrap();
482 - assert_eq!(before.recipients.len(), 1);
483 -
484 - sqlx::query("INSERT INTO email_suppressions (email, reason) VALUES ($1, 'bounce')")
485 - .bind("bounced@example.com")
486 - .execute(&h.db)
487 - .await
488 - .unwrap();
489 -
490 - let after = lists::resolve_audience(&h.db, list).await.unwrap();
491 - assert!(
492 - after.recipients.is_empty(),
493 - "a suppressed address resolved as deliverable"
494 - );
495 - }
496 -
497 - /// An unsubscribed subscription is not sendable, and neither is a pending one:
498 - /// nothing may be mailed on the strength of a double opt-in that never
499 - /// completed.
500 - #[tokio::test]
501 - async fn unsendable_states_stay_out_of_the_audience() {
502 - let h = TestHarness::new().await;
503 - let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
504 - .await
505 - .unwrap()
506 - .unwrap();
507 -
508 - for (addr, state) in [
509 - ("pending@example.com", SubscriptionState::Pending),
510 - ("bounced2@example.com", SubscriptionState::Bounced),
511 - ] {
512 - lists::subscribe(
513 - &h.db,
514 - list,
515 - &lists::Subscriber::Email(addr.to_string()),
516 - state,
517 - SubscriptionSource::LandingForm,
518 - ConsentEvent::OptIn,
519 - None,
520 - )
521 - .await
522 - .unwrap();
523 - }
524 -
525 - let audience = lists::resolve_audience(&h.db, list).await.unwrap();
526 - assert!(
527 - audience.recipients.is_empty(),
528 - "pending or bounced subscriptions resolved as deliverable: {:?}",
529 - audience.recipients
530 - );
531 - }
532 -
533 - // ── Step 4: the unsubscribe surface ──
534 -
535 - fn prefs_url(subscription: makenotwork::db::ListSubscriptionId) -> String {
536 - makenotwork::email::generate_subscription_unsubscribe_url(
537 - "",
538 - *subscription.as_uuid(),
539 - "test-signing-secret-for-integration-tests",
540 - )
541 - }
542 -
543 - /// Subscribe an address to the platform marketing list and return the row.
544 - async fn marketing_subscription(
545 - h: &TestHarness,
546 - addr: &str,
547 - ) -> makenotwork::db::ListSubscriptionId {
548 - let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
549 - .await
550 - .unwrap()
551 - .unwrap();
552 - lists::subscribe(
553 - &h.db,
554 - list,
555 - &lists::Subscriber::Email(addr.to_string()),
556 - SubscriptionState::Confirmed,
557 - SubscriptionSource::LandingForm,
558 - ConsentEvent::OptIn,
559 - None,
560 - )
561 - .await
562 - .unwrap()
563 - }
564 -
565 - /// GET renders the page and changes nothing. A mail client or link scanner
566 - /// prefetching the URL must not unsubscribe anyone.
567 - #[tokio::test]
568 - async fn the_preferences_page_does_not_mutate_on_get() {
569 - let mut h = TestHarness::new().await;
570 - let sub = marketing_subscription(&h, "prefs@example.com").await;
571 -
572 - let resp = h.client.get(&prefs_url(sub)).await;
573 - assert_eq!(resp.status, 200);
574 - assert!(resp.text.contains("Email preferences"));
575 -
576 - let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1")
577 - .bind(sub)
578 - .fetch_one(&h.db)
579 - .await
580 - .unwrap();
581 - assert_eq!(state, "confirmed", "a GET unsubscribed somebody");
582 - }
583 -
584 - /// RFC 8058: a POST to the same URL unsubscribes that one list with no
585 - /// confirmation step, and a retry still reports success.
586 - #[tokio::test]
587 - async fn one_click_post_unsubscribes_that_list_and_retries_cleanly() {
588 - let mut h = TestHarness::new().await;
589 - let sub = marketing_subscription(&h, "oneclick2@example.com").await;
590 - let url = prefs_url(sub);
591 -
592 - let first = h.client.post_form(&url, "List-Unsubscribe=One-Click").await;
593 - assert_eq!(first.status, 200);
594 -
595 - let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1")
596 - .bind(sub)
597 - .fetch_one(&h.db)
598 - .await
599 - .unwrap();
600 - assert_eq!(state, "unsubscribed");
601 -
602 - let second = h.client.post_form(&url, "List-Unsubscribe=One-Click").await;
603 - assert_eq!(second.status, 200, "a retried one-click must not fail");
604 - }
605 -
606 - /// The page lists every list the subscriber is on, not only the one whose mail
607 - /// brought them there. Making somebody hunt for the rest is how "unsubscribe"
608 - /// becomes "mark as spam".
609 - #[tokio::test]
610 - async fn the_page_shows_every_list_the_subscriber_is_on() {
611 - let mut h = TestHarness::new().await;
612 - let marketing = marketing_subscription(&h, "many@example.com").await;
613 -
614 - // A second list for the same address.
615 - sqlx::query(
616 - "INSERT INTO lists (scope, kind, title, required) VALUES ('platform', 'announce', 'Product announcements', false)",
617 - )
618 - .execute(&h.db)
619 - .await
620 - .unwrap();
621 - let announce = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Announce)
622 - .await
623 - .unwrap()
624 - .unwrap();
625 - lists::subscribe(
626 - &h.db,
627 - announce,
628 - &lists::Subscriber::Email("many@example.com".to_string()),
629 - SubscriptionState::Confirmed,
630 - SubscriptionSource::LandingForm,
631 - ConsentEvent::OptIn,
632 - None,
633 - )
634 - .await
635 - .unwrap();
636 -
637 - let resp = h.client.get(&prefs_url(marketing)).await;
638 - assert!(resp.text.contains("Makenotwork updates"));
639 - assert!(
640 - resp.text.contains("Product announcements"),
641 - "the page showed only the originating list"
642 - );
643 - }
644 -
645 - /// Required lists appear but carry no toggle. There is no opting out of a
646 - /// receipt, and "unsubscribe from everything" means everything on offer.
647 - #[tokio::test]
648 - async fn required_lists_are_shown_but_cannot_be_left() {
649 - let mut h = TestHarness::new().await;
650 - let marketing = marketing_subscription(&h, "receipts@example.com").await;
651 -
652 - sqlx::query(
653 - "INSERT INTO lists (scope, kind, title, required) VALUES ('platform', 'announce', 'Receipts', true)",
654 - )
655 - .execute(&h.db)
656 - .await
657 - .unwrap();
658 - let receipts = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Announce)
659 - .await
660 - .unwrap()
661 - .unwrap();
662 - let receipt_sub = lists::subscribe(
663 - &h.db,
664 - receipts,
665 - &lists::Subscriber::Email("receipts@example.com".to_string()),
666 - SubscriptionState::Confirmed,
667 - SubscriptionSource::Admin,
668 - ConsentEvent::OptIn,
669 - None,
670 - )
671 - .await
672 - .unwrap();
673 -
674 - let page = h.client.get(&prefs_url(marketing)).await;
675 - assert!(
676 - page.text.contains("Always sent"),
677 - "required list had a toggle"
678 - );
679 -
680 - // Unsubscribe-from-all leaves it alone.
681 - let url = prefs_url(marketing);
682 - let token = url.split("sub=").nth(1).unwrap();
683 - let (sub, sig) = token.split_once("&sig=").unwrap();
684 - h.client
685 - .post_form("/unsubscribe/all", &format!("sub={sub}&sig={sig}"))
686 - .await;
687 -
688 - let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1")
689 - .bind(receipt_sub)
690 - .fetch_one(&h.db)
691 - .await
692 - .unwrap();
693 - assert_eq!(state, "confirmed", "a required list was unsubscribed");
694 -
695 - let marketing_state: String =
696 - sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1")
697 - .bind(marketing)
698 - .fetch_one(&h.db)
699 - .await
700 - .unwrap();
701 - assert_eq!(marketing_state, "unsubscribed");
702 - }
703 -
704 - /// A valid token authorises one subscriber, not any subscription. Retargeting
705 - /// it at somebody else's row is refused.
706 - #[tokio::test]
707 - async fn a_token_cannot_be_retargeted_at_another_subscriber() {
708 - let mut h = TestHarness::new().await;
709 - let mine = marketing_subscription(&h, "mine@example.com").await;
710 - let theirs = marketing_subscription(&h, "theirs@example.com").await;
711 -
712 - let url = prefs_url(mine);
713 - let token = url.split("sub=").nth(1).unwrap();
714 - let (sub, sig) = token.split_once("&sig=").unwrap();
715 -
716 - let resp = h
717 - .client
718 - .post_form(
719 - "/unsubscribe/list",
720 - &format!("sub={sub}&sig={sig}&target={theirs}&action=unsubscribe"),
721 - )
722 - .await;
723 - assert!(resp.status.is_client_error(), "a token was retargeted");
724 -
725 - let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1")
726 - .bind(theirs)
727 - .fetch_one(&h.db)
728 - .await
729 - .unwrap();
730 - assert_eq!(state, "confirmed", "somebody else was unsubscribed");
731 - }
732 -
733 - /// A forged signature does nothing.
734 - #[tokio::test]
735 - async fn the_preferences_page_rejects_a_bad_signature() {
736 - let mut h = TestHarness::new().await;
737 - let sub = marketing_subscription(&h, "forged@example.com").await;
738 -
739 - let resp = h
740 - .client
741 - .get(&format!("/unsubscribe?sub={sub}&sig=deadbeef"))
742 - .await;
743 - assert!(
744 - !resp.text.contains("Email preferences"),
745 - "a forged signature opened the page"
746 - );
747 - }
748 -
749 - // ── Step 5: notification preferences as subscriptions ──
750 - //
751 - // Reads still use the users.notify_* columns, so what matters here is that
752 - // every write keeps both sides in step. A preferences page that reports a
753 - // change it did not make is worse than one that never offered the toggle.
754 -
755 - async fn notification_subscription(
756 - h: &TestHarness,
757 - user: makenotwork::db::UserId,
758 - kind: ListKind,
759 - ) -> makenotwork::db::ListSubscriptionId {
760 - let list = lists::find_list(&h.db, ListScope::Platform, None, kind)
761 - .await
762 - .unwrap()
763 - .expect("notification list exists");
764 - sqlx::query_scalar("SELECT id FROM list_subscriptions WHERE list_id = $1 AND user_id = $2")
765 - .bind(list)
766 - .bind(user)
767 - .fetch_one(&h.db)
768 - .await
769 - .expect("backfilled subscription")
770 - }
771 -
772 - /// A new account gets a subscription per preference, in the state the default
773 - /// calls for. Status alerts are the one that starts off.
774 - #[tokio::test]
775 - async fn signup_lands_a_subscription_for_each_notification() {
776 - let mut h = TestHarness::new().await;
777 - let user = h.signup("prefs1", "prefs1@test.com", "password123").await;
778 -
Lines truncated
@@ -72,6 +72,9 @@
72 72 mod license_keys;
73 73 mod lifecycle;
74 74 mod lists;
75 + mod lists_notifications;
76 + mod lists_preferences;
77 + mod lists_resolver;
75 78 mod mailing_lists;
76 79 mod media_library;
77 80 mod mock_payment_flows;
@@ -1,0 +1,518 @@
1 + //! Notification preferences as subscriptions (steps 5, 5b, 5c and 6 of wiki
2 + //! `mnw-mailing-lists`).
3 + //!
4 + //! The `users.notify_*` columns are gone and the subscription rows are the
5 + //! only record, so these cover both directions of the move: every write still
6 + //! reaches the row the settings screen reads back, and the reads that used to
7 + //! consult a column now resolve through the list.
8 +
9 + use super::lists_preferences::prefs_url;
10 + use crate::harness::TestHarness;
11 + use makenotwork::db::{
12 + ConsentEvent, ListKind, ListScope, SubscriptionSource, SubscriptionState, lists,
13 + };
14 +
15 + async fn notification_subscription(
16 + h: &TestHarness,
17 + user: makenotwork::db::UserId,
18 + kind: ListKind,
19 + ) -> makenotwork::db::ListSubscriptionId {
20 + let list = lists::find_list(&h.db, ListScope::Platform, None, kind)
21 + .await
22 + .unwrap()
23 + .expect("notification list exists");
24 + sqlx::query_scalar("SELECT id FROM list_subscriptions WHERE list_id = $1 AND user_id = $2")
25 + .bind(list)
26 + .bind(user)
27 + .fetch_one(&h.db)
28 + .await
29 + .expect("backfilled subscription")
30 + }
31 +
32 + /// A new account gets a subscription per preference, in the state the default
33 + /// calls for. Status alerts are the one that starts off.
34 + #[tokio::test]
35 + async fn signup_lands_a_subscription_for_each_notification() {
36 + let mut h = TestHarness::new().await;
37 + let user = h.signup("prefs1", "prefs1@test.com", "password123").await;
38 +
39 + for (kind, _legacy) in makenotwork::db::lists::NOTIFICATION_LISTS {
40 + let list = lists::find_list(
41 + &h.db,
42 + ListScope::Platform,
43 + None,
44 + kind.parse::<ListKind>().unwrap(),
45 + )
46 + .await
47 + .unwrap()
48 + .expect("list exists");
49 + let state: Option<String> = sqlx::query_scalar(
50 + "SELECT state FROM list_subscriptions WHERE list_id = $1 AND user_id = $2",
51 + )
52 + .bind(list)
53 + .bind(user)
54 + .fetch_optional(&h.db)
55 + .await
56 + .unwrap();
57 + let expected = if *kind == "status" {
58 + "unsubscribed"
59 + } else {
60 + "confirmed"
61 + };
62 + assert_eq!(state.as_deref(), Some(expected), "{kind}: wrong seed state");
63 + }
64 + }
65 +
66 + /// Changing a preference in settings moves the subscription with it.
67 + #[tokio::test]
68 + async fn settings_changes_reach_the_subscription() {
69 + let mut h = TestHarness::new().await;
70 + let user = h.signup("prefs2", "prefs2@test.com", "password123").await;
71 +
72 + makenotwork::db::users::disable_notification(&h.db, user, "notify_sale")
73 + .await
74 + .unwrap();
75 +
76 + let sub = notification_subscription(&h, user, ListKind::Sale).await;
77 + let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1")
78 + .bind(sub)
79 + .fetch_one(&h.db)
80 + .await
81 + .unwrap();
82 + assert_eq!(state, "unsubscribed");
83 + }
84 +
85 + /// Sign-in alerts are a required list. Opting out of being told your account
86 + /// was accessed is not on offer, so one-click refuses and unsubscribe-from-all
87 + /// leaves it alone.
88 + #[tokio::test]
89 + async fn sign_in_alerts_cannot_be_unsubscribed() {
90 + let mut h = TestHarness::new().await;
91 + let user = h.signup("prefs4", "prefs4@test.com", "password123").await;
92 + let login_sub = notification_subscription(&h, user, ListKind::Login).await;
93 +
94 + let resp = h
95 + .client
96 + .post_form(&prefs_url(login_sub), "List-Unsubscribe=One-Click")
97 + .await;
98 + assert_eq!(
99 + resp.status, 400,
100 + "one-click unsubscribed a security notification"
101 + );
102 + assert!(
103 + lists::may_notify(&h.db, user, ListKind::Login)
104 + .await
105 + .unwrap()
106 + );
107 +
108 + // And unsubscribe-from-all skips it while taking the rest.
109 + let sale_sub = notification_subscription(&h, user, ListKind::Sale).await;
110 + let url = prefs_url(sale_sub);
111 + let token = url.split("sub=").nth(1).unwrap();
112 + let (sub, sig) = token.split_once("&sig=").unwrap();
113 + h.client
114 + .post_form("/unsubscribe/all", &format!("sub={sub}&sig={sig}"))
115 + .await;
116 +
117 + assert!(
118 + lists::may_notify(&h.db, user, ListKind::Login)
119 + .await
120 + .unwrap(),
121 + "unsubscribe-from-all silenced sign-in alerts"
122 + );
123 + assert!(
124 + !lists::may_notify(&h.db, user, ListKind::Sale)
125 + .await
126 + .unwrap(),
127 + "unsubscribe-from-all left an ordinary preference on"
128 + );
129 + }
130 +
131 + // ── Step 6: per-repo issue notifications ──
132 +
133 + /// Create a repo through the API and return (owner_id, owner_name, repo_id).
134 + async fn repo_with_list(h: &mut TestHarness) -> (makenotwork::db::UserId, String, uuid::Uuid) {
135 + let owner = h
136 + .signup("repoowner", "repoowner@test.com", "password123")
137 + .await;
138 + h.grant_creator(owner).await;
139 + h.client.post_form("/logout", "").await;
140 + h.login("repoowner", "password123").await;
141 +
142 + let repo_id: uuid::Uuid = sqlx::query_scalar(
143 + "INSERT INTO git_repos (user_id, name, visibility) VALUES ($1, 'noisy', 'public') RETURNING id",
144 + )
145 + .bind(owner)
146 + .fetch_one(&h.db)
147 + .await
148 + .expect("create repo");
149 + (owner, "repoowner".to_string(), repo_id)
150 + }
151 +
152 + /// Creating a repository mirrors its issues list, whichever path made it. The
153 + /// trigger is what covers the SSH push and seed paths that never touch Rust.
154 + #[tokio::test]
155 + async fn creating_a_repo_seeds_its_issues_list() {
156 + let mut h = TestHarness::new().await;
157 + let (_owner, _name, repo_id) = repo_with_list(&mut h).await;
158 +
159 + let list = lists::find_list(&h.db, ListScope::Repo, Some(repo_id), ListKind::Issues)
160 + .await
161 + .expect("query");
162 + assert!(list.is_some(), "repo has no issues list");
163 + }
164 +
165 + /// Muting is per repository. The default is unmuted, which keeps the behaviour
166 + /// the account-wide bool had: eligibility decides who is mailed, and the mute
167 + /// only takes people out.
168 + #[tokio::test]
169 + async fn muting_one_repo_leaves_another_alone() {
170 + let mut h = TestHarness::new().await;
171 + let (owner, _name, noisy) = repo_with_list(&mut h).await;
172 +
173 + let quiet: uuid::Uuid = sqlx::query_scalar(
174 + "INSERT INTO git_repos (user_id, name, visibility) VALUES ($1, 'quiet', 'public') RETURNING id",
175 + )
176 + .bind(owner)
177 + .fetch_one(&h.db)
178 + .await
179 + .unwrap();
180 +
181 + assert!(
182 + !lists::repo_notifications_muted(&h.db, noisy, owner, ListKind::Issues)
183 + .await
184 + .unwrap(),
185 + "default should be unmuted"
186 + );
187 +
188 + lists::set_repo_muted(&h.db, noisy, owner, ListKind::Issues, true)
189 + .await
190 + .unwrap();
191 +
192 + assert!(
193 + lists::repo_notifications_muted(&h.db, noisy, owner, ListKind::Issues)
194 + .await
195 + .unwrap()
196 + );
197 + assert!(
198 + !lists::repo_notifications_muted(&h.db, quiet, owner, ListKind::Issues)
199 + .await
200 + .unwrap(),
201 + "muting one repository silenced another"
202 + );
203 + }
204 +
205 + /// Unmuting moves the row back, and the consent log keeps both events.
206 + #[tokio::test]
207 + async fn unmuting_restores_and_records_both_events() {
208 + let mut h = TestHarness::new().await;
209 + let (owner, _name, repo_id) = repo_with_list(&mut h).await;
210 +
211 + lists::set_repo_muted(&h.db, repo_id, owner, ListKind::Issues, true)
212 + .await
213 + .unwrap();
214 + lists::set_repo_muted(&h.db, repo_id, owner, ListKind::Issues, false)
215 + .await
216 + .unwrap();
217 +
218 + assert!(
219 + !lists::repo_notifications_muted(&h.db, repo_id, owner, ListKind::Issues)
220 + .await
221 + .unwrap()
222 + );
223 +
224 + let list = lists::find_list(&h.db, ListScope::Repo, Some(repo_id), ListKind::Issues)
225 + .await
226 + .unwrap()
227 + .unwrap();
228 + let events: Vec<String> = sqlx::query_scalar(
229 + "SELECT ce.event FROM consent_events ce \
230 + JOIN list_subscriptions ls ON ls.id = ce.subscription_id \
231 + WHERE ls.list_id = $1 AND ls.user_id = $2 ORDER BY ce.at",
232 + )
233 + .bind(list)
234 + .bind(owner)
235 + .fetch_all(&h.db)
236 + .await
237 + .unwrap();
238 + assert_eq!(events, vec!["opt_out".to_string(), "opt_in".to_string()]);
239 + }
240 +
241 + /// An unsubscribed row records when it happened. A row that says unsubscribed
242 + /// with no timestamp cannot answer the first question asked of an opt-out.
243 + #[tokio::test]
244 + async fn muting_records_when_it_happened() {
245 + let mut h = TestHarness::new().await;
246 + let (owner, _name, repo_id) = repo_with_list(&mut h).await;
247 + lists::set_repo_muted(&h.db, repo_id, owner, ListKind::Issues, true)
248 + .await
249 + .unwrap();
250 +
251 + let list = lists::find_list(&h.db, ListScope::Repo, Some(repo_id), ListKind::Issues)
252 + .await
253 + .unwrap()
254 + .unwrap();
255 + let at: Option<chrono::DateTime<chrono::Utc>> = sqlx::query_scalar(
256 + "SELECT unsubscribed_at FROM list_subscriptions WHERE list_id = $1 AND user_id = $2",
257 + )
258 + .bind(list)
259 + .bind(owner)
260 + .fetch_one(&h.db)
261 + .await
262 + .unwrap();
263 + assert!(at.is_some(), "unsubscribed row has no unsubscribed_at");
264 + }
265 +
266 + /// Renaming a repository renames its list, which is the one place a subscriber
267 + /// reads that name.
268 + #[tokio::test]
269 + async fn renaming_a_repo_renames_its_list() {
270 + let mut h = TestHarness::new().await;
271 + let (_owner, _name, repo_id) = repo_with_list(&mut h).await;
272 +
273 + sqlx::query("UPDATE git_repos SET name = 'renamed' WHERE id = $1")
274 + .bind(repo_id)
275 + .execute(&h.db)
276 + .await
277 + .unwrap();
278 +
279 + let title: String =
280 + sqlx::query_scalar("SELECT title FROM lists WHERE scope = 'repo' AND scope_id = $1")
281 + .bind(repo_id)
282 + .fetch_one(&h.db)
283 + .await
284 + .unwrap();
285 + assert_eq!(title, "renamed: issues");
286 + }
287 +
288 + // ── Step 5b: the notification reads ──
289 +
290 + /// The invariant every read now rests on. `may_notify` falls back to a default
291 + /// when a subscription is missing, and that fallback should be unreachable: the
292 + /// 186 backfill covered the accounts that existed and the 187 trigger covers
293 + /// every one created since, whichever path created it.
294 + #[tokio::test]
295 + async fn notification_rows_exist_for_every_account() {
296 + let mut h = TestHarness::new().await;
297 + h.signup("viahandler", "viahandler@test.com", "password123")
298 + .await;
299 + // A direct insert, the path the seed flow and the harness itself use.
300 + sqlx::query(
301 + "INSERT INTO users (username, email, password_hash, email_verified) \
302 + VALUES ('viasql', 'viasql@test.com', 'x', true)",
303 + )
304 + .execute(&h.db)
305 + .await
306 + .unwrap();
307 +
308 + let missing: i64 = sqlx::query_scalar(
309 + "SELECT COUNT(*) FROM users u \
310 + CROSS JOIN lists l \
311 + WHERE l.scope = 'platform' \
312 + AND l.kind IN ('sale','follower','releases','issues','status','tip','login') \
313 + AND NOT EXISTS ( \
314 + SELECT 1 FROM list_subscriptions ls \
315 + WHERE ls.list_id = l.id AND ls.user_id = u.id)",
316 + )
317 + .fetch_one(&h.db)
318 + .await
319 + .unwrap();
320 + assert_eq!(
321 + missing, 0,
322 + "accounts are missing notification subscriptions"
323 + );
324 + }
325 +
326 + /// Status alerts are the one opt-in default, and the read has to preserve that.
327 + /// Everything else defaults to on.
328 + #[tokio::test]
329 + async fn notification_defaults_survive_the_move() {
330 + let mut h = TestHarness::new().await;
331 + let user = h
332 + .signup("defaults", "defaults@test.com", "password123")
333 + .await;
334 +
335 + for kind in [
336 + ListKind::Sale,
337 + ListKind::Follower,
338 + ListKind::Releases,
339 + ListKind::Issues,
340 + ListKind::Tip,
341 + ListKind::Login,
342 + ] {
343 + assert!(
344 + lists::may_notify(&h.db, user, kind).await.unwrap(),
345 + "{kind} should default on"
346 + );
347 + }
348 + assert!(
349 + !lists::may_notify(&h.db, user, ListKind::Status)
350 + .await
351 + .unwrap(),
352 + "status alerts should default off"
353 + );
354 + }
355 +
356 + /// The read follows the subscription, which is the whole point of the move.
357 + #[tokio::test]
358 + async fn may_notify_follows_the_subscription() {
359 + let mut h = TestHarness::new().await;
360 + let user = h.signup("follows", "follows@test.com", "password123").await;
361 + assert!(
362 + lists::may_notify(&h.db, user, ListKind::Sale)
363 + .await
364 + .unwrap()
365 + );
366 +
367 + let sub = notification_subscription(&h, user, ListKind::Sale).await;
368 + lists::unsubscribe(&h.db, sub, ConsentEvent::OptOut)
369 + .await
370 + .unwrap();
371 +
372 + assert!(
373 + !lists::may_notify(&h.db, user, ListKind::Sale)
374 + .await
375 + .unwrap(),
376 + "a send would still fire for somebody who opted out"
377 + );
378 + }
379 +
380 + /// Unsubscribing on the preferences page reaches the read. Before 5b this
381 + /// worked only because the page also wrote the column; now it is the
382 + /// subscription itself doing the work.
383 + #[tokio::test]
384 + async fn the_preferences_page_now_drives_the_read_directly() {
385 + let mut h = TestHarness::new().await;
386 + let user = h.signup("viapage", "viapage@test.com", "password123").await;
387 + let sub = notification_subscription(&h, user, ListKind::Sale).await;
388 +
389 + let resp = h
390 + .client
391 + .post_form(&prefs_url(sub), "List-Unsubscribe=One-Click")
392 + .await;
393 + assert_eq!(resp.status, 200);
394 +
395 + assert!(
396 + !lists::may_notify(&h.db, user, ListKind::Sale)
397 + .await
398 + .unwrap(),
399 + "the page unsubscribed them but the send would still fire"
400 + );
401 + }
402 +
403 + /// Status alerts are drawn from subscriptions now, not the column.
404 + #[tokio::test]
405 + async fn status_alert_recipients_come_from_subscriptions() {
406 + let mut h = TestHarness::new().await;
407 + let user = h
408 + .signup("statusfan", "statusfan@test.com", "password123")
409 + .await;
410 +
411 + let before = makenotwork::db::users::get_status_alert_subscribers(&h.db)
412 + .await
413 + .unwrap();
414 + assert!(
415 + !before.iter().any(|s| s.id == user),
416 + "status alerts default off"
417 + );
418 +
419 + let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Status)
420 + .await
421 + .unwrap()
422 + .unwrap();
423 + lists::subscribe(
424 + &h.db,
425 + list,
426 + &lists::Subscriber::User(user),
427 + SubscriptionState::Confirmed,
428 + SubscriptionSource::Admin,
429 + ConsentEvent::OptIn,
430 + None,
431 + )
432 + .await
433 + .unwrap();
434 +
435 + let after = makenotwork::db::users::get_status_alert_subscribers(&h.db)
436 + .await
437 + .unwrap();
438 + assert!(
439 + after.iter().any(|s| s.id == user),
440 + "opting in did not reach the status-alert query"
441 + );
442 + }
443 +
444 + // ── Step 5c: the columns are gone ──
445 +
446 + /// The settings screen still round-trips every toggle, now against
447 + /// subscriptions. This is the check that the column drop did not quietly turn
448 + /// the notification tab into a set of controls that render but do not stick.
449 + #[tokio::test]
450 + async fn the_settings_screen_round_trips_every_toggle() {
451 + let mut h = TestHarness::new().await;
452 + let user = h
453 + .signup("roundtrip", "roundtrip@test.com", "password123")
454 + .await;
455 +
456 + // Turn everything off except status, which starts off and goes on, so the
457 + // test would fail if the handler ignored the form and wrote defaults.
458 + let resp = h
459 + .client
460 + .put_form("/api/users/me/preferences", "notify_status=on")
461 + .await;
462 + assert_eq!(resp.status, 200, "save failed: {}", resp.text);
463 +
464 + let prefs = lists::notification_prefs(&h.db, user).await.unwrap();
465 + assert!(prefs.status, "status did not turn on");
466 + for (name, value) in [
467 + ("sale", prefs.sale),
468 + ("follower", prefs.follower),
469 + ("release", prefs.release),
470 + ("issues", prefs.issues),
471 + ("login", prefs.login),
472 + ] {
473 + assert!(!value, "{name} should have been turned off");
474 + }
475 +
476 + // And the tab renders the saved state rather than the defaults.
477 + let tab = h.client.htmx_get("/dashboard/tabs/account").await;
478 + assert_eq!(tab.status, 200);
479 + let checked_status = tab
480 + .text
481 + .split("id=\"notify-status\"")
482 + .nth(1)
483 + .map(|s| s[..80.min(s.len())].contains("checked"));
484 + assert_eq!(
485 + checked_status,
486 + Some(true),
487 + "the status toggle rendered unchecked after being saved on"
488 + );
489 + }
490 +
491 + /// The legacy preference names in already-sent unsubscribe links still work,
492 + /// even though the columns they were named after are gone.
493 + #[tokio::test]
494 + async fn legacy_unsubscribe_names_still_resolve() {
495 + let mut h = TestHarness::new().await;
496 + let user = h
497 + .signup("legacyname", "legacyname@test.com", "password123")
498 + .await;
499 +
500 + assert!(
Lines truncated
@@ -1,0 +1,228 @@
1 + //! The unsubscribe surface (step 4 of wiki `mnw-mailing-lists`).
2 + //!
3 + //! The preferences page and the one-click POST are the only list controls a
4 + //! subscriber ever sees, and both are reached by a signed URL rather than a
5 + //! session. What is worth pinning is that the signature is the whole of the
6 + //! authorization: a GET mutates nothing, a token names one subscriber, and a
7 + //! required list cannot be left however the form is posted.
8 +
9 + use crate::harness::TestHarness;
10 + use makenotwork::db::{
11 + ConsentEvent, ListKind, ListScope, SubscriptionSource, SubscriptionState, lists,
12 + };
13 +
14 + /// The signed preferences URL for one subscription. `lists_notifications` posts
15 + /// to it too, since the notification lists are reached through the same surface.
16 + pub(crate) fn prefs_url(subscription: makenotwork::db::ListSubscriptionId) -> String {
17 + makenotwork::email::generate_subscription_unsubscribe_url(
18 + "",
19 + *subscription.as_uuid(),
20 + "test-signing-secret-for-integration-tests",
21 + )
22 + }
23 +
24 + /// Subscribe an address to the platform marketing list and return the row.
25 + async fn marketing_subscription(
26 + h: &TestHarness,
27 + addr: &str,
28 + ) -> makenotwork::db::ListSubscriptionId {
29 + let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
30 + .await
31 + .unwrap()
32 + .unwrap();
33 + lists::subscribe(
34 + &h.db,
35 + list,
36 + &lists::Subscriber::Email(addr.to_string()),
37 + SubscriptionState::Confirmed,
38 + SubscriptionSource::LandingForm,
39 + ConsentEvent::OptIn,
40 + None,
41 + )
42 + .await
43 + .unwrap()
44 + }
45 +
46 + /// GET renders the page and changes nothing. A mail client or link scanner
47 + /// prefetching the URL must not unsubscribe anyone.
48 + #[tokio::test]
49 + async fn the_preferences_page_does_not_mutate_on_get() {
50 + let mut h = TestHarness::new().await;
51 + let sub = marketing_subscription(&h, "prefs@example.com").await;
52 +
53 + let resp = h.client.get(&prefs_url(sub)).await;
54 + assert_eq!(resp.status, 200);
55 + assert!(resp.text.contains("Email preferences"));
56 +
57 + let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1")
58 + .bind(sub)
59 + .fetch_one(&h.db)
60 + .await
61 + .unwrap();
62 + assert_eq!(state, "confirmed", "a GET unsubscribed somebody");
63 + }
64 +
65 + /// RFC 8058: a POST to the same URL unsubscribes that one list with no
66 + /// confirmation step, and a retry still reports success.
67 + #[tokio::test]
68 + async fn one_click_post_unsubscribes_that_list_and_retries_cleanly() {
69 + let mut h = TestHarness::new().await;
70 + let sub = marketing_subscription(&h, "oneclick2@example.com").await;
71 + let url = prefs_url(sub);
72 +
73 + let first = h.client.post_form(&url, "List-Unsubscribe=One-Click").await;
74 + assert_eq!(first.status, 200);
75 +
76 + let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1")
77 + .bind(sub)
78 + .fetch_one(&h.db)
79 + .await
80 + .unwrap();
81 + assert_eq!(state, "unsubscribed");
82 +
83 + let second = h.client.post_form(&url, "List-Unsubscribe=One-Click").await;
84 + assert_eq!(second.status, 200, "a retried one-click must not fail");
85 + }
86 +
87 + /// The page lists every list the subscriber is on, not only the one whose mail
88 + /// brought them there. Making somebody hunt for the rest is how "unsubscribe"
89 + /// becomes "mark as spam".
90 + #[tokio::test]
91 + async fn the_page_shows_every_list_the_subscriber_is_on() {
92 + let mut h = TestHarness::new().await;
93 + let marketing = marketing_subscription(&h, "many@example.com").await;
94 +
95 + // A second list for the same address.
96 + sqlx::query(
97 + "INSERT INTO lists (scope, kind, title, required) VALUES ('platform', 'announce', 'Product announcements', false)",
98 + )
99 + .execute(&h.db)
100 + .await
101 + .unwrap();
102 + let announce = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Announce)
103 + .await
104 + .unwrap()
105 + .unwrap();
106 + lists::subscribe(
107 + &h.db,
108 + announce,
109 + &lists::Subscriber::Email("many@example.com".to_string()),
110 + SubscriptionState::Confirmed,
111 + SubscriptionSource::LandingForm,
112 + ConsentEvent::OptIn,
113 + None,
114 + )
115 + .await
116 + .unwrap();
117 +
118 + let resp = h.client.get(&prefs_url(marketing)).await;
119 + assert!(resp.text.contains("Makenotwork updates"));
120 + assert!(
121 + resp.text.contains("Product announcements"),
122 + "the page showed only the originating list"
123 + );
124 + }
125 +
126 + /// Required lists appear but carry no toggle. There is no opting out of a
127 + /// receipt, and "unsubscribe from everything" means everything on offer.
128 + #[tokio::test]
129 + async fn required_lists_are_shown_but_cannot_be_left() {
130 + let mut h = TestHarness::new().await;
131 + let marketing = marketing_subscription(&h, "receipts@example.com").await;
132 +
133 + sqlx::query(
134 + "INSERT INTO lists (scope, kind, title, required) VALUES ('platform', 'announce', 'Receipts', true)",
135 + )
136 + .execute(&h.db)
137 + .await
138 + .unwrap();
139 + let receipts = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Announce)
140 + .await
141 + .unwrap()
142 + .unwrap();
143 + let receipt_sub = lists::subscribe(
144 + &h.db,
145 + receipts,
146 + &lists::Subscriber::Email("receipts@example.com".to_string()),
147 + SubscriptionState::Confirmed,
148 + SubscriptionSource::Admin,
149 + ConsentEvent::OptIn,
150 + None,
151 + )
152 + .await
153 + .unwrap();
154 +
155 + let page = h.client.get(&prefs_url(marketing)).await;
156 + assert!(
157 + page.text.contains("Always sent"),
158 + "required list had a toggle"
159 + );
160 +
161 + // Unsubscribe-from-all leaves it alone.
162 + let url = prefs_url(marketing);
163 + let token = url.split("sub=").nth(1).unwrap();
164 + let (sub, sig) = token.split_once("&sig=").unwrap();
165 + h.client
166 + .post_form("/unsubscribe/all", &format!("sub={sub}&sig={sig}"))
167 + .await;
168 +
169 + let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1")
170 + .bind(receipt_sub)
171 + .fetch_one(&h.db)
172 + .await
173 + .unwrap();
174 + assert_eq!(state, "confirmed", "a required list was unsubscribed");
175 +
176 + let marketing_state: String =
177 + sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1")
178 + .bind(marketing)
179 + .fetch_one(&h.db)
180 + .await
181 + .unwrap();
182 + assert_eq!(marketing_state, "unsubscribed");
183 + }
184 +
185 + /// A valid token authorises one subscriber, not any subscription. Retargeting
186 + /// it at somebody else's row is refused.
187 + #[tokio::test]
188 + async fn a_token_cannot_be_retargeted_at_another_subscriber() {
189 + let mut h = TestHarness::new().await;
190 + let mine = marketing_subscription(&h, "mine@example.com").await;
191 + let theirs = marketing_subscription(&h, "theirs@example.com").await;
192 +
193 + let url = prefs_url(mine);
194 + let token = url.split("sub=").nth(1).unwrap();
195 + let (sub, sig) = token.split_once("&sig=").unwrap();
196 +
197 + let resp = h
198 + .client
199 + .post_form(
200 + "/unsubscribe/list",
201 + &format!("sub={sub}&sig={sig}&target={theirs}&action=unsubscribe"),
202 + )
203 + .await;
204 + assert_eq!(resp.status, 400, "a token was retargeted");
205 +
206 + let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1")
207 + .bind(theirs)
208 + .fetch_one(&h.db)
209 + .await
210 + .unwrap();
211 + assert_eq!(state, "confirmed", "somebody else was unsubscribed");
212 + }
213 +
214 + /// A forged signature does nothing.
215 + #[tokio::test]
216 + async fn the_preferences_page_rejects_a_bad_signature() {
217 + let mut h = TestHarness::new().await;
218 + let sub = marketing_subscription(&h, "forged@example.com").await;
219 +
220 + let resp = h
221 + .client
222 + .get(&format!("/unsubscribe?sub={sub}&sig=deadbeef"))
223 + .await;
224 + assert!(
225 + !resp.text.contains("Email preferences"),
226 + "a forged signature opened the page"
227 + );
228 + }
@@ -1,0 +1,245 @@
1 + //! The recipient resolver, and the legacy writes that feed it (step 3 of
2 + //! wiki `mnw-mailing-lists`).
3 + //!
4 + //! Sends now read the unified tables, so a write that reaches only the legacy
5 + //! tables is a subscriber no send can see, or worse, an unsubscribe no send
6 + //! honours. These pin the mirroring that closes that gap.
7 +
8 + use crate::harness::TestHarness;
9 + use makenotwork::db::{
10 + ConsentEvent, ListKind, ListScope, SubscriptionSource, SubscriptionState, lists,
11 + };
12 +
13 + /// The resolver requires a verified, unsuspended account, matching the query it
14 + /// replaced. `signup` does not verify, so tests that expect delivery say so.
15 + async fn verify_email(h: &TestHarness, user: makenotwork::db::UserId) {
16 + sqlx::query("UPDATE users SET email_verified = true WHERE id = $1")
17 + .bind(user)
18 + .execute(&h.db)
19 + .await
20 + .expect("verify email");
21 + }
22 +
23 + /// Create a project through the API and return its unified content list.
24 + async fn project_with_list(h: &mut TestHarness) -> (makenotwork::db::UserId, uuid::Uuid) {
25 + let creator_id = h.signup("mirror", "mirror@test.com", "password123").await;
26 + h.grant_creator(creator_id).await;
27 + h.client.post_form("/logout", "").await;
28 + h.login("mirror", "password123").await;
29 + let resp = h
30 + .client
31 + .post_form("/api/projects", "slug=mirrorproj&title=Mirror+Project")
32 + .await;
33 + assert_eq!(resp.status, 200, "create project: {}", resp.text);
34 + let project: serde_json::Value = resp.json();
35 + let project_id: uuid::Uuid = project["id"].as_str().unwrap().parse().unwrap();
36 + (creator_id, project_id)
37 + }
38 +
39 + /// Creating a project mirrors its default lists, so an announcement has
40 + /// somewhere to resolve. Without this the send errors rather than silently
41 + /// mailing nobody.
42 + #[tokio::test]
43 + async fn creating_a_project_mirrors_its_lists() {
44 + let mut h = TestHarness::new().await;
45 + let (_creator, project_id) = project_with_list(&mut h).await;
46 +
47 + let content = lists::find_list(
48 + &h.db,
49 + ListScope::Project,
50 + Some(project_id),
51 + ListKind::Content,
52 + )
53 + .await
54 + .expect("query");
55 + assert!(content.is_some(), "content list was not mirrored");
56 +
57 + let devlog = lists::find_list(
58 + &h.db,
59 + ListScope::Project,
60 + Some(project_id),
61 + ListKind::Devlog,
62 + )
63 + .await
64 + .expect("query");
65 + assert!(devlog.is_some(), "devlog list was not mirrored");
66 + }
67 +
68 + /// A subscribe through the legacy path reaches the audience the resolver
69 + /// returns. This is the dual-write hazard: writes still go to the old tables,
70 + /// and sends now read the new ones.
71 + #[tokio::test]
72 + async fn a_legacy_subscribe_reaches_the_resolved_audience() {
73 + let mut h = TestHarness::new().await;
74 + let (_creator, project_id) = project_with_list(&mut h).await;
75 + let fan = h
76 + .signup("mirrorfan", "mirrorfan@test.com", "password123")
77 + .await;
78 + verify_email(&h, fan).await;
79 +
80 + let legacy = makenotwork::db::mailing_lists::get_list_by_project_and_type(
81 + &h.db,
82 + project_id.into(),
83 + makenotwork::db::MailingListType::Content,
84 + )
85 + .await
86 + .unwrap()
87 + .expect("legacy list");
88 + makenotwork::db::mailing_lists::subscribe(&h.db, legacy.id, fan)
89 + .await
90 + .expect("subscribe");
91 +
92 + let unified = lists::find_list(
93 + &h.db,
94 + ListScope::Project,
95 + Some(project_id),
96 + ListKind::Content,
97 + )
98 + .await
99 + .unwrap()
100 + .unwrap();
101 + let audience = lists::resolve_audience(&h.db, unified)
102 + .await
103 + .expect("resolve");
104 + assert!(
105 + audience.recipients.iter().any(|r| r.user_id == Some(fan)),
106 + "a subscriber added through the legacy path is invisible to sends"
107 + );
108 + }
109 +
110 + /// The one that matters most: an unsubscribe through the legacy path must
111 + /// remove them from the audience. A missed mirror here means mailing somebody
112 + /// who asked us not to.
113 + #[tokio::test]
114 + async fn a_legacy_unsubscribe_removes_them_from_the_audience() {
115 + let mut h = TestHarness::new().await;
116 + let (_creator, project_id) = project_with_list(&mut h).await;
117 + let fan = h.signup("leaver", "leaver@test.com", "password123").await;
118 + verify_email(&h, fan).await;
119 +
120 + let legacy = makenotwork::db::mailing_lists::get_list_by_project_and_type(
121 + &h.db,
122 + project_id.into(),
123 + makenotwork::db::MailingListType::Content,
124 + )
125 + .await
126 + .unwrap()
127 + .unwrap();
128 + makenotwork::db::mailing_lists::subscribe(&h.db, legacy.id, fan)
129 + .await
130 + .unwrap();
131 +
132 + let unified = lists::find_list(
133 + &h.db,
134 + ListScope::Project,
135 + Some(project_id),
136 + ListKind::Content,
137 + )
138 + .await
139 + .unwrap()
140 + .unwrap();
141 +
142 + // Present first, so this cannot pass by never having been subscribed.
143 + let before = lists::resolve_audience(&h.db, unified).await.unwrap();
144 + assert!(
145 + before.recipients.iter().any(|r| r.user_id == Some(fan)),
146 + "test setup: the subscriber never reached the audience"
147 + );
148 +
149 + makenotwork::db::mailing_lists::unsubscribe(&h.db, legacy.id, fan)
150 + .await
151 + .unwrap();
152 +
153 + let audience = lists::resolve_audience(&h.db, unified).await.unwrap();
154 + assert!(
155 + !audience.recipients.iter().any(|r| r.user_id == Some(fan)),
156 + "an unsubscribed user is still in the send audience"
157 + );
158 +
159 + // And the opt-out is on the record, not just absent from the audience.
160 + let events: Vec<String> = sqlx::query_scalar(
161 + "SELECT ce.event FROM consent_events ce \
162 + JOIN list_subscriptions ls ON ls.id = ce.subscription_id \
163 + WHERE ls.user_id = $1 ORDER BY ce.at",
164 + )
165 + .bind(fan)
166 + .fetch_all(&h.db)
167 + .await
168 + .unwrap();
169 + assert!(events.contains(&"opt_out".to_string()));
170 + }
171 +
172 + /// A suppressed address never resolves, whatever its subscription says.
173 + /// Bounces and complaints are the one rule that was already applied
174 + /// consistently, and it stays that way.
175 + #[tokio::test]
176 + async fn suppressed_addresses_are_never_in_the_audience() {
177 + let h = TestHarness::new().await;
178 + let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
179 + .await
180 + .unwrap()
181 + .unwrap();
182 +
183 + lists::subscribe(
184 + &h.db,
185 + list,
186 + &lists::Subscriber::Email("bounced@example.com".to_string()),
187 + SubscriptionState::Confirmed,
188 + SubscriptionSource::LandingForm,
189 + ConsentEvent::OptIn,
190 + None,
191 + )
192 + .await
193 + .unwrap();
194 +
195 + let before = lists::resolve_audience(&h.db, list).await.unwrap();
196 + assert_eq!(before.recipients.len(), 1);
197 +
198 + sqlx::query("INSERT INTO email_suppressions (email, reason) VALUES ($1, 'bounce')")
199 + .bind("bounced@example.com")
200 + .execute(&h.db)
201 + .await
202 + .unwrap();
203 +
204 + let after = lists::resolve_audience(&h.db, list).await.unwrap();
205 + assert!(
206 + after.recipients.is_empty(),
207 + "a suppressed address resolved as deliverable"
208 + );
209 + }
210 +
211 + /// An unsubscribed subscription is not sendable, and neither is a pending one:
212 + /// nothing may be mailed on the strength of a double opt-in that never
213 + /// completed.
214 + #[tokio::test]
215 + async fn unsendable_states_stay_out_of_the_audience() {
216 + let h = TestHarness::new().await;
217 + let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
218 + .await
219 + .unwrap()
220 + .unwrap();
221 +
222 + for (addr, state) in [
223 + ("pending@example.com", SubscriptionState::Pending),
224 + ("bounced2@example.com", SubscriptionState::Bounced),
225 + ] {
226 + lists::subscribe(
227 + &h.db,
228 + list,
229 + &lists::Subscriber::Email(addr.to_string()),
230 + state,
231 + SubscriptionSource::LandingForm,
232 + ConsentEvent::OptIn,
233 + None,
234 + )
235 + .await
236 + .unwrap();
237 + }
238 +
239 + let audience = lists::resolve_audience(&h.db, list).await.unwrap();
240 + assert!(
241 + audience.recipients.is_empty(),
242 + "pending or bounced subscriptions resolved as deliverable: {:?}",
243 + audience.recipients
244 + );
245 + }