//! Notification preferences as subscriptions (steps 5, 5b, 5c and 6 of wiki //! `mnw-mailing-lists`). //! //! The `users.notify_*` columns are gone and the subscription rows are the //! only record, so these cover both directions of the move: every write still //! reaches the row the settings screen reads back, and the reads that used to //! consult a column now resolve through the list. use super::lists_preferences::prefs_url; use crate::harness::TestHarness; use makenotwork::db::{ ConsentEvent, ListKind, ListScope, SubscriptionSource, SubscriptionState, lists, }; async fn notification_subscription( h: &TestHarness, user: makenotwork::db::UserId, kind: ListKind, ) -> makenotwork::db::ListSubscriptionId { let list = lists::find_list(&h.db, ListScope::Platform, None, kind) .await .unwrap() .expect("notification list exists"); sqlx::query_scalar("SELECT id FROM list_subscriptions WHERE list_id = $1 AND user_id = $2") .bind(list) .bind(user) .fetch_one(&h.db) .await .expect("backfilled subscription") } /// A new account gets a subscription per preference, in the state the default /// calls for. Status alerts are the one that starts off. #[tokio::test] async fn signup_lands_a_subscription_for_each_notification() { let mut h = TestHarness::new().await; let user = h.signup("prefs1", "prefs1@test.com", "password123").await; for (kind, _legacy) in makenotwork::db::lists::NOTIFICATION_LISTS { let list = lists::find_list( &h.db, ListScope::Platform, None, kind.parse::().unwrap(), ) .await .unwrap() .expect("list exists"); let state: Option = sqlx::query_scalar( "SELECT state FROM list_subscriptions WHERE list_id = $1 AND user_id = $2", ) .bind(list) .bind(user) .fetch_optional(&h.db) .await .unwrap(); let expected = if *kind == "status" { "unsubscribed" } else { "confirmed" }; assert_eq!(state.as_deref(), Some(expected), "{kind}: wrong seed state"); } } /// Changing a preference in settings moves the subscription with it. #[tokio::test] async fn settings_changes_reach_the_subscription() { let mut h = TestHarness::new().await; let user = h.signup("prefs2", "prefs2@test.com", "password123").await; makenotwork::db::users::disable_notification(&h.db, user, "notify_sale") .await .unwrap(); let sub = notification_subscription(&h, user, ListKind::Sale).await; let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1") .bind(sub) .fetch_one(&h.db) .await .unwrap(); assert_eq!(state, "unsubscribed"); } /// Sign-in alerts are a required list. Opting out of being told your account /// was accessed is not on offer, so one-click refuses and unsubscribe-from-all /// leaves it alone. #[tokio::test] async fn sign_in_alerts_cannot_be_unsubscribed() { let mut h = TestHarness::new().await; let user = h.signup("prefs4", "prefs4@test.com", "password123").await; let login_sub = notification_subscription(&h, user, ListKind::Login).await; let resp = h .client .post_form(&prefs_url(login_sub), "List-Unsubscribe=One-Click") .await; assert_eq!( resp.status, 400, "one-click unsubscribed a security notification" ); assert!( lists::may_notify(&h.db, user, ListKind::Login) .await .unwrap() ); // And unsubscribe-from-all skips it while taking the rest. let sale_sub = notification_subscription(&h, user, ListKind::Sale).await; let url = prefs_url(sale_sub); let token = url.split("sub=").nth(1).unwrap(); let (sub, sig) = token.split_once("&sig=").unwrap(); h.client .post_form("/unsubscribe/all", &format!("sub={sub}&sig={sig}")) .await; assert!( lists::may_notify(&h.db, user, ListKind::Login) .await .unwrap(), "unsubscribe-from-all silenced sign-in alerts" ); assert!( !lists::may_notify(&h.db, user, ListKind::Sale) .await .unwrap(), "unsubscribe-from-all left an ordinary preference on" ); } // ── Step 6: per-repo issue notifications ── /// Create a repo through the API and return (owner_id, owner_name, repo_id). async fn repo_with_list(h: &mut TestHarness) -> (makenotwork::db::UserId, String, uuid::Uuid) { let owner = h .signup("repoowner", "repoowner@test.com", "password123") .await; h.grant_creator(owner).await; h.client.post_form("/logout", "").await; h.login("repoowner", "password123").await; let repo_id: uuid::Uuid = sqlx::query_scalar( "INSERT INTO git_repos (user_id, name, visibility) VALUES ($1, 'noisy', 'public') RETURNING id", ) .bind(owner) .fetch_one(&h.db) .await .expect("create repo"); (owner, "repoowner".to_string(), repo_id) } /// Creating a repository mirrors its issues list, whichever path made it. The /// trigger is what covers the SSH push and seed paths that never touch Rust. #[tokio::test] async fn creating_a_repo_seeds_its_issues_list() { let mut h = TestHarness::new().await; let (_owner, _name, repo_id) = repo_with_list(&mut h).await; let list = lists::find_list(&h.db, ListScope::Repo, Some(repo_id), ListKind::Issues) .await .expect("query"); assert!(list.is_some(), "repo has no issues list"); } /// Muting is per repository. The default is unmuted, which keeps the behaviour /// the account-wide bool had: eligibility decides who is mailed, and the mute /// only takes people out. #[tokio::test] async fn muting_one_repo_leaves_another_alone() { let mut h = TestHarness::new().await; let (owner, _name, noisy) = repo_with_list(&mut h).await; let quiet: uuid::Uuid = sqlx::query_scalar( "INSERT INTO git_repos (user_id, name, visibility) VALUES ($1, 'quiet', 'public') RETURNING id", ) .bind(owner) .fetch_one(&h.db) .await .unwrap(); assert!( !lists::repo_notifications_muted(&h.db, noisy, owner, ListKind::Issues) .await .unwrap(), "default should be unmuted" ); lists::set_repo_muted(&h.db, noisy, owner, ListKind::Issues, true) .await .unwrap(); assert!( lists::repo_notifications_muted(&h.db, noisy, owner, ListKind::Issues) .await .unwrap() ); assert!( !lists::repo_notifications_muted(&h.db, quiet, owner, ListKind::Issues) .await .unwrap(), "muting one repository silenced another" ); } /// Unmuting moves the row back, and the consent log keeps both events. #[tokio::test] async fn unmuting_restores_and_records_both_events() { let mut h = TestHarness::new().await; let (owner, _name, repo_id) = repo_with_list(&mut h).await; lists::set_repo_muted(&h.db, repo_id, owner, ListKind::Issues, true) .await .unwrap(); lists::set_repo_muted(&h.db, repo_id, owner, ListKind::Issues, false) .await .unwrap(); assert!( !lists::repo_notifications_muted(&h.db, repo_id, owner, ListKind::Issues) .await .unwrap() ); let list = lists::find_list(&h.db, ListScope::Repo, Some(repo_id), ListKind::Issues) .await .unwrap() .unwrap(); 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.list_id = $1 AND ls.user_id = $2 ORDER BY ce.at", ) .bind(list) .bind(owner) .fetch_all(&h.db) .await .unwrap(); assert_eq!(events, vec!["opt_out".to_string(), "opt_in".to_string()]); } /// An unsubscribed row records when it happened. A row that says unsubscribed /// with no timestamp cannot answer the first question asked of an opt-out. #[tokio::test] async fn muting_records_when_it_happened() { let mut h = TestHarness::new().await; let (owner, _name, repo_id) = repo_with_list(&mut h).await; lists::set_repo_muted(&h.db, repo_id, owner, ListKind::Issues, true) .await .unwrap(); let list = lists::find_list(&h.db, ListScope::Repo, Some(repo_id), ListKind::Issues) .await .unwrap() .unwrap(); let at: Option> = sqlx::query_scalar( "SELECT unsubscribed_at FROM list_subscriptions WHERE list_id = $1 AND user_id = $2", ) .bind(list) .bind(owner) .fetch_one(&h.db) .await .unwrap(); assert!(at.is_some(), "unsubscribed row has no unsubscribed_at"); } /// Renaming a repository renames its list, which is the one place a subscriber /// reads that name. #[tokio::test] async fn renaming_a_repo_renames_its_list() { let mut h = TestHarness::new().await; let (_owner, _name, repo_id) = repo_with_list(&mut h).await; sqlx::query("UPDATE git_repos SET name = 'renamed' WHERE id = $1") .bind(repo_id) .execute(&h.db) .await .unwrap(); let title: String = sqlx::query_scalar("SELECT title FROM lists WHERE scope = 'repo' AND scope_id = $1") .bind(repo_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(title, "renamed: issues"); } // ── Step 5b: the notification reads ── /// The invariant every read now rests on. `may_notify` falls back to a default /// when a subscription is missing, and that fallback should be unreachable: the /// 186 backfill covered the accounts that existed and the 187 trigger covers /// every one created since, whichever path created it. #[tokio::test] async fn notification_rows_exist_for_every_account() { let mut h = TestHarness::new().await; h.signup("viahandler", "viahandler@test.com", "password123") .await; // A direct insert, the path the seed flow and the harness itself use. sqlx::query( "INSERT INTO users (username, email, password_hash, email_verified) \ VALUES ('viasql', 'viasql@test.com', 'x', true)", ) .execute(&h.db) .await .unwrap(); let missing: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM users u \ CROSS JOIN lists l \ WHERE l.scope = 'platform' \ AND l.kind IN ('sale','follower','releases','issues','status','tip','login') \ AND NOT EXISTS ( \ SELECT 1 FROM list_subscriptions ls \ WHERE ls.list_id = l.id AND ls.user_id = u.id)", ) .fetch_one(&h.db) .await .unwrap(); assert_eq!( missing, 0, "accounts are missing notification subscriptions" ); } /// Status alerts are the one opt-in default, and the read has to preserve that. /// Everything else defaults to on. #[tokio::test] async fn notification_defaults_survive_the_move() { let mut h = TestHarness::new().await; let user = h .signup("defaults", "defaults@test.com", "password123") .await; for kind in [ ListKind::Sale, ListKind::Follower, ListKind::Releases, ListKind::Issues, ListKind::Tip, ListKind::Login, ] { assert!( lists::may_notify(&h.db, user, kind).await.unwrap(), "{kind} should default on" ); } assert!( !lists::may_notify(&h.db, user, ListKind::Status) .await .unwrap(), "status alerts should default off" ); } /// The read follows the subscription, which is the whole point of the move. #[tokio::test] async fn may_notify_follows_the_subscription() { let mut h = TestHarness::new().await; let user = h.signup("follows", "follows@test.com", "password123").await; assert!( lists::may_notify(&h.db, user, ListKind::Sale) .await .unwrap() ); let sub = notification_subscription(&h, user, ListKind::Sale).await; lists::unsubscribe(&h.db, sub, ConsentEvent::OptOut) .await .unwrap(); assert!( !lists::may_notify(&h.db, user, ListKind::Sale) .await .unwrap(), "a send would still fire for somebody who opted out" ); } /// Unsubscribing on the preferences page reaches the read. Before 5b this /// worked only because the page also wrote the column; now it is the /// subscription itself doing the work. #[tokio::test] async fn the_preferences_page_now_drives_the_read_directly() { let mut h = TestHarness::new().await; let user = h.signup("viapage", "viapage@test.com", "password123").await; let sub = notification_subscription(&h, user, ListKind::Sale).await; let resp = h .client .post_form(&prefs_url(sub), "List-Unsubscribe=One-Click") .await; assert_eq!(resp.status, 200); assert!( !lists::may_notify(&h.db, user, ListKind::Sale) .await .unwrap(), "the page unsubscribed them but the send would still fire" ); } /// Status alerts are drawn from subscriptions now, not the column. #[tokio::test] async fn status_alert_recipients_come_from_subscriptions() { let mut h = TestHarness::new().await; let user = h .signup("statusfan", "statusfan@test.com", "password123") .await; let before = makenotwork::db::users::get_status_alert_subscribers(&h.db) .await .unwrap(); assert!( !before.iter().any(|s| s.id == user), "status alerts default off" ); let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Status) .await .unwrap() .unwrap(); lists::subscribe( &h.db, list, &lists::Subscriber::User(user), SubscriptionState::Confirmed, SubscriptionSource::Admin, ConsentEvent::OptIn, None, ) .await .unwrap(); let after = makenotwork::db::users::get_status_alert_subscribers(&h.db) .await .unwrap(); assert!( after.iter().any(|s| s.id == user), "opting in did not reach the status-alert query" ); } // ── Step 5c: the columns are gone ── /// The settings screen still round-trips every toggle, now against /// subscriptions. This is the check that the column drop did not quietly turn /// the notification tab into a set of controls that render but do not stick. #[tokio::test] async fn the_settings_screen_round_trips_every_toggle() { let mut h = TestHarness::new().await; let user = h .signup("roundtrip", "roundtrip@test.com", "password123") .await; // Turn everything off except status, which starts off and goes on, so the // test would fail if the handler ignored the form and wrote defaults. let resp = h .client .put_form("/api/users/me/preferences", "notify_status=on") .await; assert_eq!(resp.status, 200, "save failed: {}", resp.text); let prefs = lists::notification_prefs(&h.db, user).await.unwrap(); assert!(prefs.status, "status did not turn on"); for (name, value) in [ ("sale", prefs.sale), ("follower", prefs.follower), ("release", prefs.release), ("issues", prefs.issues), ("login", prefs.login), ] { assert!(!value, "{name} should have been turned off"); } // And the tab renders the saved state rather than the defaults. let tab = h.client.htmx_get("/dashboard/tabs/account").await; assert_eq!(tab.status, 200); let checked_status = tab .text .split("id=\"notify-status\"") .nth(1) .map(|s| s[..80.min(s.len())].contains("checked")); assert_eq!( checked_status, Some(true), "the status toggle rendered unchecked after being saved on" ); } /// The legacy preference names in already-sent unsubscribe links still work, /// even though the columns they were named after are gone. #[tokio::test] async fn legacy_unsubscribe_names_still_resolve() { let mut h = TestHarness::new().await; let user = h .signup("legacyname", "legacyname@test.com", "password123") .await; assert!( makenotwork::db::users::disable_notification(&h.db, user, "notify_sale") .await .unwrap(), "a link carrying the old column name stopped working" ); assert!( !lists::may_notify(&h.db, user, ListKind::Sale) .await .unwrap() ); assert!( !makenotwork::db::users::disable_notification(&h.db, user, "not_a_preference") .await .unwrap(), "an unknown preference name should report no change" ); }