Skip to main content

max / makenotwork

16.6 KB · 519 lines History Blame Raw
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!(
501 makenotwork::db::users::disable_notification(&h.db, user, "notify_sale")
502 .await
503 .unwrap(),
504 "a link carrying the old column name stopped working"
505 );
506 assert!(
507 !lists::may_notify(&h.db, user, ListKind::Sale)
508 .await
509 .unwrap()
510 );
511
512 assert!(
513 !makenotwork::db::users::disable_notification(&h.db, user, "not_a_preference")
514 .await
515 .unwrap(),
516 "an unknown preference name should report no change"
517 );
518 }
519