max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 | + | } |