//! The unified subscription model (step 2 of wiki `mnw-mailing-lists`). //! //! Nothing sends through these tables yet, so what is worth pinning is the //! shape: the constraints that make bad states unrepresentable, and the //! append-only guarantee the consent log rests on. Those are the parts a later //! step will lean on without re-checking. //! //! The later steps live beside this one: `lists_resolver` for the audience, //! `lists_preferences` for the unsubscribe surface, `lists_notifications` for //! the per-account and per-repo notification lists. use crate::harness::TestHarness; use makenotwork::db::{ ConsentEvent, ListKind, ListScope, SubscriptionSource, SubscriptionState, lists, }; /// The backfill creates the platform marketing list unconditionally, so the /// landing form has somewhere to point once step 3 wires it up. #[tokio::test] async fn migration_creates_the_platform_marketing_list() { let h = TestHarness::new().await; let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing) .await .expect("query") .expect("platform marketing list should exist after the backfill"); let count = lists::count_in_state(&h.db, list, SubscriptionState::Imported) .await .expect("count"); assert_eq!(count, 0, "a fresh database has nothing to import"); } /// A subscriber is an account or a bare address, never both and never neither. /// The old mailing_list_subscribers allowed both at once, which left "which is /// authoritative" to whoever read the row next. #[tokio::test] async fn a_subscription_cannot_have_both_identities_or_neither() { let h = TestHarness::new().await; let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing) .await .unwrap() .unwrap(); let both = sqlx::query( "INSERT INTO list_subscriptions (list_id, user_id, email, state, source) \ VALUES ($1, gen_random_uuid(), 'both@example.com', 'confirmed', 'api')", ) .bind(list) .execute(&h.db) .await; assert!(both.is_err(), "a row with both identities was accepted"); let neither = sqlx::query( "INSERT INTO list_subscriptions (list_id, state, source) VALUES ($1, 'confirmed', 'api')", ) .bind(list) .execute(&h.db) .await; assert!(neither.is_err(), "a row with no identity was accepted"); } /// scope_id is NULL exactly for platform lists. A project list without a /// project, or a platform list that acquired one, is not a representable state. #[tokio::test] async fn list_scope_and_scope_id_must_agree() { let h = TestHarness::new().await; let platform_with_id = sqlx::query( "INSERT INTO lists (scope, scope_id, kind, title) \ VALUES ('platform', gen_random_uuid(), 'announce', 'bad')", ) .execute(&h.db) .await; assert!(platform_with_id.is_err()); let project_without_id = sqlx::query("INSERT INTO lists (scope, kind, title) VALUES ('project', 'content', 'bad')") .execute(&h.db) .await; assert!(project_without_id.is_err()); } /// Subscribing writes the subscription and its consent event together. A /// subscription with no recorded reason is the exact state this model exists to /// eliminate. #[tokio::test] async fn subscribing_records_the_consent_event_with_it() { let h = TestHarness::new().await; let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing) .await .unwrap() .unwrap(); let sub = lists::subscribe( &h.db, list, &lists::Subscriber::Email("consent@example.com".to_string()), SubscriptionState::Confirmed, SubscriptionSource::LandingForm, ConsentEvent::OptIn, Some("Get notified when something ships."), ) .await .expect("subscribe"); let (event, evidence): (String, Option) = sqlx::query_as("SELECT event, evidence FROM consent_events WHERE subscription_id = $1") .bind(sub) .fetch_one(&h.db) .await .expect("consent event"); assert_eq!(event, "opt_in"); assert_eq!( evidence.as_deref(), Some("Get notified when something ships."), "the copy shown at opt-in is what makes the consent evidenceable" ); } /// Unsubscribing appends rather than edits, and is idempotent because one-click /// POSTs get retried. #[tokio::test] async fn unsubscribing_appends_an_event_and_is_idempotent() { let h = TestHarness::new().await; let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing) .await .unwrap() .unwrap(); let sub = lists::subscribe( &h.db, list, &lists::Subscriber::Email("leaving@example.com".to_string()), SubscriptionState::Confirmed, SubscriptionSource::LandingForm, ConsentEvent::OptIn, None, ) .await .unwrap(); assert!( lists::unsubscribe(&h.db, sub, ConsentEvent::OptOut) .await .unwrap() ); assert!( !lists::unsubscribe(&h.db, sub, ConsentEvent::OptOut) .await .unwrap(), "a retried unsubscribe must report no change rather than erroring" ); // The opt_in survives the opt_out: the record of what was agreed to is the // point of the table. let events: Vec = sqlx::query_scalar( "SELECT event FROM consent_events WHERE subscription_id = $1 ORDER BY at", ) .bind(sub) .fetch_all(&h.db) .await .unwrap(); assert_eq!(events, vec!["opt_in".to_string(), "opt_out".to_string()]); } /// Re-subscribing moves the row back and appends a fresh event, rather than /// rewriting the history that says they once left. #[tokio::test] async fn resubscribing_restores_the_row_and_keeps_the_history() { let h = TestHarness::new().await; let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing) .await .unwrap() .unwrap(); let subscriber = lists::Subscriber::Email("returning@example.com".to_string()); let sub = lists::subscribe( &h.db, list, &subscriber, SubscriptionState::Confirmed, SubscriptionSource::LandingForm, ConsentEvent::OptIn, None, ) .await .unwrap(); lists::unsubscribe(&h.db, sub, ConsentEvent::OptOut) .await .unwrap(); let again = lists::subscribe( &h.db, list, &subscriber, SubscriptionState::Confirmed, SubscriptionSource::LandingForm, ConsentEvent::OptIn, None, ) .await .expect("re-subscribe should update rather than conflict"); assert_eq!(again, sub, "re-subscribing should reuse the row"); assert_eq!( lists::count_in_state(&h.db, list, SubscriptionState::Confirmed) .await .unwrap(), 1 ); let events: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM consent_events WHERE subscription_id = $1") .bind(sub) .fetch_one(&h.db) .await .unwrap(); assert_eq!(events, 3, "opt_in, opt_out, opt_in"); } /// The append-only guarantee is enforced by the database, not by convention. /// Every consent claim rests on the log not having been edited after the fact. #[tokio::test] async fn consent_events_reject_updates() { let h = TestHarness::new().await; let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing) .await .unwrap() .unwrap(); let sub = lists::subscribe( &h.db, list, &lists::Subscriber::Email("immutable@example.com".to_string()), SubscriptionState::Confirmed, SubscriptionSource::LandingForm, ConsentEvent::OptIn, Some("original copy"), ) .await .unwrap(); let rewrite = sqlx::query("UPDATE consent_events SET evidence = 'rewritten' WHERE subscription_id = $1") .bind(sub) .execute(&h.db) .await; assert!(rewrite.is_err(), "consent history was editable"); let evidence: String = sqlx::query_scalar("SELECT evidence FROM consent_events WHERE subscription_id = $1") .bind(sub) .fetch_one(&h.db) .await .unwrap(); assert_eq!(evidence, "original copy"); } /// Erasure has to be able to remove the person, so DELETE cascades even though /// UPDATE is blocked. This is the one way consent rows legitimately go away. #[tokio::test] async fn erasing_a_subscription_takes_its_consent_history_with_it() { let h = TestHarness::new().await; let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing) .await .unwrap() .unwrap(); let sub = lists::subscribe( &h.db, list, &lists::Subscriber::Email("erase@example.com".to_string()), SubscriptionState::Confirmed, SubscriptionSource::LandingForm, ConsentEvent::OptIn, None, ) .await .unwrap(); sqlx::query("DELETE FROM list_subscriptions WHERE id = $1") .bind(sub) .execute(&h.db) .await .expect("erasure must be possible"); let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM consent_events WHERE subscription_id = $1") .bind(sub) .fetch_one(&h.db) .await .unwrap(); assert_eq!( left, 0, "consent rows outlived the subscription they described" ); }