Skip to main content

max / makenotwork

12.8 KB · 429 lines History Blame Raw
1 //! Tests for [`super`].
2
3 use super::*;
4 use crate::email::EmailTransport;
5 use std::sync::{Arc, Mutex};
6
7 /// One captured email: (to, subject, html_body, text_body).
8 type SentEmail = (String, String, String, Option<String>);
9
10 /// In-memory transport that captures sent emails for assertion in tests.
11 struct CapturingTransport {
12 sent: Mutex<Vec<SentEmail>>,
13 }
14
15 impl CapturingTransport {
16 fn new() -> Self {
17 Self {
18 sent: Mutex::new(Vec::new()),
19 }
20 }
21 fn last(&self) -> (String, String, String, Option<String>) {
22 self.sent
23 .lock()
24 .unwrap()
25 .last()
26 .cloned()
27 .expect("no email captured")
28 }
29 }
30
31 #[async_trait::async_trait]
32 impl EmailTransport for CapturingTransport {
33 async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()> {
34 self.sent.lock().unwrap().push((
35 to.to_string(),
36 subject.to_string(),
37 body.to_string(),
38 None,
39 ));
40 Ok(())
41 }
42 async fn send_email_with_headers_and_unsub(
43 &self,
44 to: &str,
45 subject: &str,
46 body: &str,
47 _extra_headers: &[(&str, String)],
48 unsub_url: Option<&str>,
49 ) -> Result<()> {
50 self.sent.lock().unwrap().push((
51 to.to_string(),
52 subject.to_string(),
53 body.to_string(),
54 unsub_url.map(String::from),
55 ));
56 Ok(())
57 }
58 async fn send_email_broadcast_with_unsub(
59 &self,
60 to: &str,
61 subject: &str,
62 body: &str,
63 unsub_url: Option<&str>,
64 _send: Option<crate::db::EmailSendId>,
65 ) -> Result<()> {
66 self.sent.lock().unwrap().push((
67 to.to_string(),
68 subject.to_string(),
69 body.to_string(),
70 unsub_url.map(String::from),
71 ));
72 Ok(())
73 }
74 }
75
76 /// A stand-in recipient id for the Optional senders. `client_with_capture`
77 /// builds a pool-less client, so `dispatch` sends without consulting a
78 /// preference and these tests stay about the composed message.
79 fn recipient_id() -> crate::db::UserId {
80 crate::db::UserId::from(uuid::Uuid::nil())
81 }
82
83 fn client_with_capture() -> (EmailClient, Arc<CapturingTransport>) {
84 let transport = Arc::new(CapturingTransport::new());
85 let client = EmailClient::with_transport(transport.clone());
86 (client, transport)
87 }
88
89 // ── Creator activity ──
90
91 #[tokio::test]
92 async fn sale_notification_carries_buyer_item_price() {
93 let (client, captured) = client_with_capture();
94 client
95 .send_sale_notification(
96 recipient_id(),
97 "seller@example.com",
98 Some("Sasha"),
99 "buyer42",
100 "Cool Album",
101 "$10.00",
102 Some("https://x/unsub"),
103 )
104 .await
105 .unwrap();
106 let (to, subject, body, unsub) = captured.last();
107 assert_eq!(to, "seller@example.com");
108 assert!(subject.contains("New sale"));
109 assert!(subject.contains("Cool Album"));
110 assert!(body.contains("Hi Sasha"));
111 assert!(body.contains("buyer42"));
112 assert!(body.contains("Cool Album"));
113 assert!(body.contains("$10.00"));
114 assert_eq!(unsub.as_deref(), Some("https://x/unsub"));
115 }
116
117 #[tokio::test]
118 async fn sale_notification_handles_none_name() {
119 // greeting(None) → empty; body should still build coherently.
120 let (client, captured) = client_with_capture();
121 client
122 .send_sale_notification(recipient_id(), "s@x", None, "buyer", "Item", "$5", None)
123 .await
124 .unwrap();
125 let (_, _, body, unsub) = captured.last();
126 assert!(
127 body.starts_with("Hi,") || body.starts_with("Hi "),
128 "body: {body}"
129 );
130 assert!(unsub.is_none());
131 }
132
133 #[tokio::test]
134 async fn tip_notification_with_message_includes_quoted_message() {
135 let (client, captured) = client_with_capture();
136 client
137 .send_tip_notification(
138 recipient_id(),
139 "c@x",
140 None,
141 "Alex",
142 "$3",
143 Some("Loved it!"),
144 None,
145 )
146 .await
147 .unwrap();
148 let (_, subject, body, _) = captured.last();
149 assert!(subject.contains("Alex tipped you $3"));
150 assert!(body.contains("Loved it!"));
151 assert!(body.contains("$3"));
152 }
153
154 #[tokio::test]
155 async fn tip_notification_without_message_omits_quote_block() {
156 // Pins the `match message { Some => ..., None => ... }` arm split,
157 // without-message branch must NOT include the "with a message:" preamble.
158 let (client, captured) = client_with_capture();
159 client
160 .send_tip_notification(recipient_id(), "c@x", None, "Alex", "$3", None, None)
161 .await
162 .unwrap();
163 let (_, _, body, _) = captured.last();
164 assert!(
165 !body.contains("with a message"),
166 "without-message branch leaked: {body}"
167 );
168 assert!(body.contains("$3"));
169 }
170
171 // ── Platform notices: suspension / appeal / termination / shutdown ──
172
173 #[tokio::test]
174 async fn suspension_includes_reason() {
175 let (client, captured) = client_with_capture();
176 client
177 .send_suspension_notification("u@x", Some("Sam"), "Spam reports")
178 .await
179 .unwrap();
180 let (_, subject, body, _) = captured.last();
181 assert_eq!(subject, "Your account has been suspended");
182 assert!(body.contains("Hi Sam"));
183 assert!(body.contains("Reason: Spam reports"));
184 assert!(body.contains("appeal"));
185 assert!(body.contains("export your data"));
186 }
187
188 #[tokio::test]
189 async fn appeal_decision_approved_uses_reinstated_outcome() {
190 // Pins the `if decision == "approved"` branch.
191 let (client, captured) = client_with_capture();
192 client
193 .send_appeal_decision("u@x", None, "approved", "Reviewed and reversed.")
194 .await
195 .unwrap();
196 let (_, _, body, _) = captured.last();
197 assert!(
198 body.contains("Your account has been reinstated"),
199 "approved branch should say reinstated: {body}"
200 );
201 assert!(
202 !body.contains("Your appeal has been denied"),
203 "approved branch must NOT also say denied: {body}"
204 );
205 assert!(body.contains("Reviewed and reversed."));
206 }
207
208 #[tokio::test]
209 async fn appeal_decision_denied_uses_denied_outcome() {
210 let (client, captured) = client_with_capture();
211 client
212 .send_appeal_decision("u@x", None, "denied", "Reviewed and upheld.")
213 .await
214 .unwrap();
215 let (_, _, body, _) = captured.last();
216 assert!(body.contains("Your appeal has been denied"));
217 assert!(!body.contains("Your account has been reinstated"));
218 }
219
220 #[tokio::test]
221 async fn appeal_decision_anything_other_than_approved_is_denied() {
222 // Pins `decision == "approved"` (exact match, case-sensitive).
223 let (client, captured) = client_with_capture();
224 client
225 .send_appeal_decision("u@x", None, "APPROVED", "uppercase")
226 .await
227 .unwrap();
228 let (_, _, body, _) = captured.last();
229 assert!(
230 body.contains("Your appeal has been denied"),
231 "case-sensitive `approved`, uppercase must NOT pass: {body}"
232 );
233 }
234
235 #[tokio::test]
236 async fn content_removal_subjects_with_title() {
237 let (client, captured) = client_with_capture();
238 client
239 .send_content_removal("c@x", Some("Dev"), "Beat Pack 1", "Copyright claim")
240 .await
241 .unwrap();
242 let (_, subject, body, _) = captured.last();
243 assert_eq!(subject, "Content removed: Beat Pack 1");
244 assert!(body.contains("Hi Dev"));
245 assert!(body.contains("Beat Pack 1"));
246 assert!(body.contains("Reason: Copyright claim"));
247 assert!(body.contains("appeal"));
248 }
249
250 #[tokio::test]
251 async fn content_restored_subjects_with_title() {
252 let (client, captured) = client_with_capture();
253 client
254 .send_content_restored("c@x", None, "Beat Pack 1")
255 .await
256 .unwrap();
257 let (_, subject, body, _) = captured.last();
258 assert_eq!(subject, "Content restored: Beat Pack 1");
259 assert!(body.contains("Beat Pack 1"));
260 assert!(body.contains("restored"));
261 }
262
263 #[tokio::test]
264 async fn account_termination_has_30_day_window_message() {
265 let (client, captured) = client_with_capture();
266 client
267 .send_account_termination("u@x", Some("Pat"))
268 .await
269 .unwrap();
270 let (_, subject, body, _) = captured.last();
271 assert!(subject.contains("terminated"));
272 assert!(body.contains("Hi Pat"));
273 assert!(body.contains("30 days"));
274 assert!(body.contains("export your data"));
275 }
276
277 #[tokio::test]
278 async fn shutdown_notice_includes_date() {
279 let (client, captured) = client_with_capture();
280 client
281 .send_shutdown_notice("u@x", None, "2027-06-15")
282 .await
283 .unwrap();
284 let (_, subject, body, _) = captured.last();
285 assert!(subject.contains("shutting down"));
286 assert!(body.contains("2027-06-15"));
287 assert!(body.contains("90 days"));
288 assert!(body.contains("no lock-in"));
289 }
290
291 #[tokio::test]
292 async fn creator_departure_mentions_creator_and_90_days() {
293 let (client, captured) = client_with_capture();
294 client
295 .send_creator_departure_notification("buyer@x", None, "Alex")
296 .await
297 .unwrap();
298 let (_, subject, body, _) = captured.last();
299 assert!(subject.contains("Alex"));
300 assert!(subject.contains("leaving"));
301 assert!(body.contains("Alex"));
302 assert!(body.contains("90 days"));
303 assert!(body.contains("library"));
304 }
305
306 // ── Issue tracking ──
307
308 #[tokio::test]
309 async fn new_issue_notification_includes_repo_path_and_url() {
310 let (client, captured) = client_with_capture();
311 client
312 .send_new_issue_notification(
313 recipient_id(),
314 "owner@x",
315 Some("Jordan"),
316 "alex",
317 "audio-tools",
318 42,
319 "Crash on startup",
320 "bob",
321 "https://makenot.work/p/alex/audio-tools/issues/42",
322 Some("https://unsub"),
323 Some("reply@x"),
324 Some("<msgid@x>"),
325 )
326 .await
327 .unwrap();
328 let (_, subject, body, unsub) = captured.last();
329 assert_eq!(subject, "New issue on alex/audio-tools: Crash on startup");
330 assert!(body.contains("Hi Jordan"));
331 assert!(body.contains("bob opened issue #42"));
332 assert!(body.contains("alex/audio-tools"));
333 assert!(body.contains("Crash on startup"));
334 assert!(body.contains("https://makenot.work/p/alex/audio-tools/issues/42"));
335 assert_eq!(unsub.as_deref(), Some("https://unsub"));
336 }
337
338 #[tokio::test]
339 async fn issue_comment_subject_uses_re_prefix() {
340 // Pins the "Re: " prefix that threads the email reply.
341 let (client, captured) = client_with_capture();
342 client
343 .send_issue_comment_notification(
344 recipient_id(),
345 "owner@x",
346 None,
347 "alex",
348 "audio-tools",
349 42,
350 "Crash on startup",
351 "carol",
352 "Looked into this, see PR #5",
353 "https://makenot.work/p/alex/audio-tools/issues/42",
354 None,
355 None,
356 None,
357 None,
358 )
359 .await
360 .unwrap();
361 let (_, subject, body, _) = captured.last();
362 assert!(
363 subject.starts_with("Re: "),
364 "comment must be Re:-prefixed: {subject}"
365 );
366 assert!(body.contains("carol commented on issue #42"));
367 assert!(body.contains("Looked into this"));
368 }
369
370 // ── Status notifications: per-status subject mapping ──
371
372 #[tokio::test]
373 async fn status_notification_operational_subject() {
374 let (client, captured) = client_with_capture();
375 client
376 .send_status_notification(
377 recipient_id(),
378 "u@x",
379 None,
380 "operational",
381 "degraded",
382 "https://unsub",
383 )
384 .await
385 .unwrap();
386 let (_, subject, _, _) = captured.last();
387 assert!(subject.contains("recovered"));
388 assert!(subject.contains("all services operational"));
389 }
390
391 #[tokio::test]
392 async fn status_notification_degraded_subject() {
393 let (client, captured) = client_with_capture();
394 client
395 .send_status_notification(
396 recipient_id(),
397 "u@x",
398 None,
399 "degraded",
400 "operational",
401 "https://unsub",
402 )
403 .await
404 .unwrap();
405 let (_, subject, _, _) = captured.last();
406 assert!(subject.contains("partial service degradation"));
407 }
408
409 #[tokio::test]
410 async fn status_notification_unknown_falls_back_to_disruption() {
411 // Pins the `_ => "...service disruption"` arm.
412 let (client, captured) = client_with_capture();
413 client
414 .send_status_notification(
415 recipient_id(),
416 "u@x",
417 None,
418 "outage",
419 "operational",
420 "https://unsub",
421 )
422 .await
423 .unwrap();
424 let (_, subject, body, _) = captured.last();
425 assert!(subject.contains("service disruption"));
426 // Body interpolates the actual status string regardless.
427 assert!(body.contains("outage"));
428 }
429