//! Tests for [`super`]. use super::*; use crate::email::EmailTransport; use std::sync::{Arc, Mutex}; /// One captured email: (to, subject, html_body, text_body). type SentEmail = (String, String, String, Option); /// In-memory transport that captures sent emails for assertion in tests. struct CapturingTransport { sent: Mutex>, } impl CapturingTransport { fn new() -> Self { Self { sent: Mutex::new(Vec::new()), } } fn last(&self) -> (String, String, String, Option) { self.sent .lock() .unwrap() .last() .cloned() .expect("no email captured") } } #[async_trait::async_trait] impl EmailTransport for CapturingTransport { async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()> { self.sent.lock().unwrap().push(( to.to_string(), subject.to_string(), body.to_string(), None, )); Ok(()) } async fn send_email_with_headers_and_unsub( &self, to: &str, subject: &str, body: &str, _extra_headers: &[(&str, String)], unsub_url: Option<&str>, ) -> Result<()> { self.sent.lock().unwrap().push(( to.to_string(), subject.to_string(), body.to_string(), unsub_url.map(String::from), )); Ok(()) } async fn send_email_broadcast_with_unsub( &self, to: &str, subject: &str, body: &str, unsub_url: Option<&str>, _send: Option, ) -> Result<()> { self.sent.lock().unwrap().push(( to.to_string(), subject.to_string(), body.to_string(), unsub_url.map(String::from), )); Ok(()) } } /// A stand-in recipient id for the Optional senders. `client_with_capture` /// builds a pool-less client, so `dispatch` sends without consulting a /// preference and these tests stay about the composed message. fn recipient_id() -> crate::db::UserId { crate::db::UserId::from(uuid::Uuid::nil()) } fn client_with_capture() -> (EmailClient, Arc) { let transport = Arc::new(CapturingTransport::new()); let client = EmailClient::with_transport(transport.clone()); (client, transport) } // ── Creator activity ── #[tokio::test] async fn sale_notification_carries_buyer_item_price() { let (client, captured) = client_with_capture(); client .send_sale_notification( recipient_id(), "seller@example.com", Some("Sasha"), "buyer42", "Cool Album", "$10.00", Some("https://x/unsub"), ) .await .unwrap(); let (to, subject, body, unsub) = captured.last(); assert_eq!(to, "seller@example.com"); assert!(subject.contains("New sale")); assert!(subject.contains("Cool Album")); assert!(body.contains("Hi Sasha")); assert!(body.contains("buyer42")); assert!(body.contains("Cool Album")); assert!(body.contains("$10.00")); assert_eq!(unsub.as_deref(), Some("https://x/unsub")); } #[tokio::test] async fn sale_notification_handles_none_name() { // greeting(None) → empty; body should still build coherently. let (client, captured) = client_with_capture(); client .send_sale_notification(recipient_id(), "s@x", None, "buyer", "Item", "$5", None) .await .unwrap(); let (_, _, body, unsub) = captured.last(); assert!( body.starts_with("Hi,") || body.starts_with("Hi "), "body: {body}" ); assert!(unsub.is_none()); } #[tokio::test] async fn tip_notification_with_message_includes_quoted_message() { let (client, captured) = client_with_capture(); client .send_tip_notification( recipient_id(), "c@x", None, "Alex", "$3", Some("Loved it!"), None, ) .await .unwrap(); let (_, subject, body, _) = captured.last(); assert!(subject.contains("Alex tipped you $3")); assert!(body.contains("Loved it!")); assert!(body.contains("$3")); } #[tokio::test] async fn tip_notification_without_message_omits_quote_block() { // Pins the `match message { Some => ..., None => ... }` arm split, // without-message branch must NOT include the "with a message:" preamble. let (client, captured) = client_with_capture(); client .send_tip_notification(recipient_id(), "c@x", None, "Alex", "$3", None, None) .await .unwrap(); let (_, _, body, _) = captured.last(); assert!( !body.contains("with a message"), "without-message branch leaked: {body}" ); assert!(body.contains("$3")); } // ── Platform notices: suspension / appeal / termination / shutdown ── #[tokio::test] async fn suspension_includes_reason() { let (client, captured) = client_with_capture(); client .send_suspension_notification("u@x", Some("Sam"), "Spam reports") .await .unwrap(); let (_, subject, body, _) = captured.last(); assert_eq!(subject, "Your account has been suspended"); assert!(body.contains("Hi Sam")); assert!(body.contains("Reason: Spam reports")); assert!(body.contains("appeal")); assert!(body.contains("export your data")); } #[tokio::test] async fn appeal_decision_approved_uses_reinstated_outcome() { // Pins the `if decision == "approved"` branch. let (client, captured) = client_with_capture(); client .send_appeal_decision("u@x", None, "approved", "Reviewed and reversed.") .await .unwrap(); let (_, _, body, _) = captured.last(); assert!( body.contains("Your account has been reinstated"), "approved branch should say reinstated: {body}" ); assert!( !body.contains("Your appeal has been denied"), "approved branch must NOT also say denied: {body}" ); assert!(body.contains("Reviewed and reversed.")); } #[tokio::test] async fn appeal_decision_denied_uses_denied_outcome() { let (client, captured) = client_with_capture(); client .send_appeal_decision("u@x", None, "denied", "Reviewed and upheld.") .await .unwrap(); let (_, _, body, _) = captured.last(); assert!(body.contains("Your appeal has been denied")); assert!(!body.contains("Your account has been reinstated")); } #[tokio::test] async fn appeal_decision_anything_other_than_approved_is_denied() { // Pins `decision == "approved"` (exact match, case-sensitive). let (client, captured) = client_with_capture(); client .send_appeal_decision("u@x", None, "APPROVED", "uppercase") .await .unwrap(); let (_, _, body, _) = captured.last(); assert!( body.contains("Your appeal has been denied"), "case-sensitive `approved`, uppercase must NOT pass: {body}" ); } #[tokio::test] async fn content_removal_subjects_with_title() { let (client, captured) = client_with_capture(); client .send_content_removal("c@x", Some("Dev"), "Beat Pack 1", "Copyright claim") .await .unwrap(); let (_, subject, body, _) = captured.last(); assert_eq!(subject, "Content removed: Beat Pack 1"); assert!(body.contains("Hi Dev")); assert!(body.contains("Beat Pack 1")); assert!(body.contains("Reason: Copyright claim")); assert!(body.contains("appeal")); } #[tokio::test] async fn content_restored_subjects_with_title() { let (client, captured) = client_with_capture(); client .send_content_restored("c@x", None, "Beat Pack 1") .await .unwrap(); let (_, subject, body, _) = captured.last(); assert_eq!(subject, "Content restored: Beat Pack 1"); assert!(body.contains("Beat Pack 1")); assert!(body.contains("restored")); } #[tokio::test] async fn account_termination_has_30_day_window_message() { let (client, captured) = client_with_capture(); client .send_account_termination("u@x", Some("Pat")) .await .unwrap(); let (_, subject, body, _) = captured.last(); assert!(subject.contains("terminated")); assert!(body.contains("Hi Pat")); assert!(body.contains("30 days")); assert!(body.contains("export your data")); } #[tokio::test] async fn shutdown_notice_includes_date() { let (client, captured) = client_with_capture(); client .send_shutdown_notice("u@x", None, "2027-06-15") .await .unwrap(); let (_, subject, body, _) = captured.last(); assert!(subject.contains("shutting down")); assert!(body.contains("2027-06-15")); assert!(body.contains("90 days")); assert!(body.contains("no lock-in")); } #[tokio::test] async fn creator_departure_mentions_creator_and_90_days() { let (client, captured) = client_with_capture(); client .send_creator_departure_notification("buyer@x", None, "Alex") .await .unwrap(); let (_, subject, body, _) = captured.last(); assert!(subject.contains("Alex")); assert!(subject.contains("leaving")); assert!(body.contains("Alex")); assert!(body.contains("90 days")); assert!(body.contains("library")); } // ── Issue tracking ── #[tokio::test] async fn new_issue_notification_includes_repo_path_and_url() { let (client, captured) = client_with_capture(); client .send_new_issue_notification( recipient_id(), "owner@x", Some("Jordan"), "alex", "audio-tools", 42, "Crash on startup", "bob", "https://makenot.work/p/alex/audio-tools/issues/42", Some("https://unsub"), Some("reply@x"), Some(""), ) .await .unwrap(); let (_, subject, body, unsub) = captured.last(); assert_eq!(subject, "New issue on alex/audio-tools: Crash on startup"); assert!(body.contains("Hi Jordan")); assert!(body.contains("bob opened issue #42")); assert!(body.contains("alex/audio-tools")); assert!(body.contains("Crash on startup")); assert!(body.contains("https://makenot.work/p/alex/audio-tools/issues/42")); assert_eq!(unsub.as_deref(), Some("https://unsub")); } #[tokio::test] async fn issue_comment_subject_uses_re_prefix() { // Pins the "Re: " prefix that threads the email reply. let (client, captured) = client_with_capture(); client .send_issue_comment_notification( recipient_id(), "owner@x", None, "alex", "audio-tools", 42, "Crash on startup", "carol", "Looked into this, see PR #5", "https://makenot.work/p/alex/audio-tools/issues/42", None, None, None, None, ) .await .unwrap(); let (_, subject, body, _) = captured.last(); assert!( subject.starts_with("Re: "), "comment must be Re:-prefixed: {subject}" ); assert!(body.contains("carol commented on issue #42")); assert!(body.contains("Looked into this")); } // ── Status notifications: per-status subject mapping ── #[tokio::test] async fn status_notification_operational_subject() { let (client, captured) = client_with_capture(); client .send_status_notification( recipient_id(), "u@x", None, "operational", "degraded", "https://unsub", ) .await .unwrap(); let (_, subject, _, _) = captured.last(); assert!(subject.contains("recovered")); assert!(subject.contains("all services operational")); } #[tokio::test] async fn status_notification_degraded_subject() { let (client, captured) = client_with_capture(); client .send_status_notification( recipient_id(), "u@x", None, "degraded", "operational", "https://unsub", ) .await .unwrap(); let (_, subject, _, _) = captured.last(); assert!(subject.contains("partial service degradation")); } #[tokio::test] async fn status_notification_unknown_falls_back_to_disruption() { // Pins the `_ => "...service disruption"` arm. let (client, captured) = client_with_capture(); client .send_status_notification( recipient_id(), "u@x", None, "outage", "operational", "https://unsub", ) .await .unwrap(); let (_, subject, body, _) = captured.last(); assert!(subject.contains("service disruption")); // Body interpolates the actual status string regardless. assert!(body.contains("outage")); }