//! The recipient resolver, and the legacy writes that feed it (step 3 of //! wiki `mnw-mailing-lists`). //! //! Sends now read the unified tables, so a write that reaches only the legacy //! tables is a subscriber no send can see, or worse, an unsubscribe no send //! honours. These pin the mirroring that closes that gap. use crate::harness::TestHarness; use makenotwork::db::{ ConsentEvent, ListKind, ListScope, SubscriptionSource, SubscriptionState, lists, }; /// The resolver requires a verified, unsuspended account, matching the query it /// replaced. `signup` does not verify, so tests that expect delivery say so. async fn verify_email(h: &TestHarness, user: makenotwork::db::UserId) { sqlx::query("UPDATE users SET email_verified = true WHERE id = $1") .bind(user) .execute(&h.db) .await .expect("verify email"); } /// Create a project through the API and return its unified content list. async fn project_with_list(h: &mut TestHarness) -> (makenotwork::db::UserId, uuid::Uuid) { let creator_id = h.signup("mirror", "mirror@test.com", "password123").await; h.grant_creator(creator_id).await; h.client.post_form("/logout", "").await; h.login("mirror", "password123").await; let resp = h .client .post_form("/api/projects", "slug=mirrorproj&title=Mirror+Project") .await; assert_eq!(resp.status, 200, "create project: {}", resp.text); let project: serde_json::Value = resp.json(); let project_id: uuid::Uuid = project["id"].as_str().unwrap().parse().unwrap(); (creator_id, project_id) } /// Creating a project mirrors its default lists, so an announcement has /// somewhere to resolve. Without this the send errors rather than silently /// mailing nobody. #[tokio::test] async fn creating_a_project_mirrors_its_lists() { let mut h = TestHarness::new().await; let (_creator, project_id) = project_with_list(&mut h).await; let content = lists::find_list( &h.db, ListScope::Project, Some(project_id), ListKind::Content, ) .await .expect("query"); assert!(content.is_some(), "content list was not mirrored"); let devlog = lists::find_list( &h.db, ListScope::Project, Some(project_id), ListKind::Devlog, ) .await .expect("query"); assert!(devlog.is_some(), "devlog list was not mirrored"); } /// A subscribe through the legacy path reaches the audience the resolver /// returns. This is the dual-write hazard: writes still go to the old tables, /// and sends now read the new ones. #[tokio::test] async fn a_legacy_subscribe_reaches_the_resolved_audience() { let mut h = TestHarness::new().await; let (_creator, project_id) = project_with_list(&mut h).await; let fan = h .signup("mirrorfan", "mirrorfan@test.com", "password123") .await; verify_email(&h, fan).await; let legacy = makenotwork::db::mailing_lists::get_list_by_project_and_type( &h.db, project_id.into(), makenotwork::db::MailingListType::Content, ) .await .unwrap() .expect("legacy list"); makenotwork::db::mailing_lists::subscribe(&h.db, legacy.id, fan) .await .expect("subscribe"); let unified = lists::find_list( &h.db, ListScope::Project, Some(project_id), ListKind::Content, ) .await .unwrap() .unwrap(); let audience = lists::resolve_audience(&h.db, unified) .await .expect("resolve"); assert!( audience.recipients.iter().any(|r| r.user_id == Some(fan)), "a subscriber added through the legacy path is invisible to sends" ); } /// The one that matters most: an unsubscribe through the legacy path must /// remove them from the audience. A missed mirror here means mailing somebody /// who asked us not to. #[tokio::test] async fn a_legacy_unsubscribe_removes_them_from_the_audience() { let mut h = TestHarness::new().await; let (_creator, project_id) = project_with_list(&mut h).await; let fan = h.signup("leaver", "leaver@test.com", "password123").await; verify_email(&h, fan).await; let legacy = makenotwork::db::mailing_lists::get_list_by_project_and_type( &h.db, project_id.into(), makenotwork::db::MailingListType::Content, ) .await .unwrap() .unwrap(); makenotwork::db::mailing_lists::subscribe(&h.db, legacy.id, fan) .await .unwrap(); let unified = lists::find_list( &h.db, ListScope::Project, Some(project_id), ListKind::Content, ) .await .unwrap() .unwrap(); // Present first, so this cannot pass by never having been subscribed. let before = lists::resolve_audience(&h.db, unified).await.unwrap(); assert!( before.recipients.iter().any(|r| r.user_id == Some(fan)), "test setup: the subscriber never reached the audience" ); makenotwork::db::mailing_lists::unsubscribe(&h.db, legacy.id, fan) .await .unwrap(); let audience = lists::resolve_audience(&h.db, unified).await.unwrap(); assert!( !audience.recipients.iter().any(|r| r.user_id == Some(fan)), "an unsubscribed user is still in the send audience" ); // And the opt-out is on the record, not just absent from the audience. let events: Vec = sqlx::query_scalar( "SELECT ce.event FROM consent_events ce \ JOIN list_subscriptions ls ON ls.id = ce.subscription_id \ WHERE ls.user_id = $1 ORDER BY ce.at", ) .bind(fan) .fetch_all(&h.db) .await .unwrap(); assert!(events.contains(&"opt_out".to_string())); } /// A suppressed address never resolves, whatever its subscription says. /// Bounces and complaints are the one rule that was already applied /// consistently, and it stays that way. #[tokio::test] async fn suppressed_addresses_are_never_in_the_audience() { let h = TestHarness::new().await; let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing) .await .unwrap() .unwrap(); lists::subscribe( &h.db, list, &lists::Subscriber::Email("bounced@example.com".to_string()), SubscriptionState::Confirmed, SubscriptionSource::LandingForm, ConsentEvent::OptIn, None, ) .await .unwrap(); let before = lists::resolve_audience(&h.db, list).await.unwrap(); assert_eq!(before.recipients.len(), 1); sqlx::query("INSERT INTO email_suppressions (email, reason) VALUES ($1, 'bounce')") .bind("bounced@example.com") .execute(&h.db) .await .unwrap(); let after = lists::resolve_audience(&h.db, list).await.unwrap(); assert!( after.recipients.is_empty(), "a suppressed address resolved as deliverable" ); } /// An unsubscribed subscription is not sendable, and neither is a pending one: /// nothing may be mailed on the strength of a double opt-in that never /// completed. #[tokio::test] async fn unsendable_states_stay_out_of_the_audience() { let h = TestHarness::new().await; let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing) .await .unwrap() .unwrap(); for (addr, state) in [ ("pending@example.com", SubscriptionState::Pending), ("bounced2@example.com", SubscriptionState::Bounced), ] { lists::subscribe( &h.db, list, &lists::Subscriber::Email(addr.to_string()), state, SubscriptionSource::LandingForm, ConsentEvent::OptIn, None, ) .await .unwrap(); } let audience = lists::resolve_audience(&h.db, list).await.unwrap(); assert!( audience.recipients.is_empty(), "pending or bounced subscriptions resolved as deliverable: {:?}", audience.recipients ); }