Skip to main content

max / makenotwork

16.3 KB · 472 lines History Blame Raw
1 //! Negative paths: what the server does when Stripe or email fails.
2 //!
3 //! Split out of the former `failure_paths.rs` on 2026-08-05, which reached 868
4 //! lines and tripped the oversized-module ratchet in `tests/test_hygiene.rs`.
5 //! The split is by the dependency that fails, which is also how you look these
6 //! up: the money path and the mail that follows it share a setup and a blast radius.
7 //!
8 //! These tests exist because the mocks used to be infallible, so the retry and
9 //! compensation machinery the server carries had no test that could reach it.
10 //! Retry logic no test can enter is worse than none, because it reads as
11 //! handled. Each test installs a failure policy on a mock (see
12 //! `harness::faults`) and asserts the compensating behaviour, not just that the
13 //! request failed.
14 //!
15 //! Rationale: wiki `testing-posture`, the "absent oracle" section.
16
17 use crate::harness::TestHarness;
18 use crate::harness::faults::{email_unavailable, stripe_unavailable};
19 use makenotwork::db;
20 use serde_json::Value;
21 use std::collections::HashMap;
22
23 // Checkout compensation when Stripe is down
24
25 /// Create a creator with Stripe connected and a published paid item, logged in
26 /// as the creator afterwards. Mirrors the helper in `promo_codes_checkout`.
27 async fn setup_paid_item(h: &mut TestHarness, price_cents: i32) -> (db::UserId, String) {
28 let seller_id = h.signup("fpseller", "fpseller@test.com", "pass1234").await;
29 h.grant_creator(seller_id).await;
30
31 sqlx::query("UPDATE users SET stripe_account_id = 'acct_mock_fpseller', stripe_charges_enabled = true WHERE id = $1")
32 .bind(seller_id)
33 .execute(&h.db)
34 .await
35 .unwrap();
36
37 h.client.post_form("/logout", "").await;
38 h.login("fpseller", "pass1234").await;
39
40 let resp = h
41 .client
42 .post_form("/api/projects", "slug=fpshop&title=FP+Shop")
43 .await;
44 let project: Value = resp.json();
45 let project_id = project["id"].as_str().unwrap().to_string();
46
47 let resp = h
48 .client
49 .post_form(
50 &format!("/api/projects/{project_id}/items"),
51 &format!("title=FP+Track&price_cents={price_cents}&item_type=audio"),
52 )
53 .await;
54 let item: Value = resp.json();
55 let item_id = item["id"].as_str().unwrap().to_string();
56
57 h.client
58 .put_form(&format!("/api/projects/{project_id}"), "is_public=true")
59 .await;
60 h.client
61 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
62 .await;
63
64 (seller_id, item_id)
65 }
66
67 /// A promo code is reserved (its `use_count` incremented) before the Stripe
68 /// call, so a Stripe failure has to release it. Without that, every outage
69 /// burns uses off a creator's code and the last buyers are told it is exhausted
70 /// when it never was. `routes/stripe/checkout/item.rs` compensates for this and
71 /// no test could reach the branch.
72 #[tokio::test]
73 async fn stripe_failure_at_checkout_releases_the_promo_reservation() {
74 let mut h = TestHarness::with_mocks().await;
75 let (seller_id, item_id) = setup_paid_item(&mut h, 1000).await;
76
77 sqlx::query(
78 "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, max_uses) \
79 VALUES ($1, 'OUTAGE', 'discount', 'percentage', 25, 0, 5)",
80 )
81 .bind(seller_id)
82 .execute(&h.db)
83 .await
84 .unwrap();
85
86 h.client.post_form("/logout", "").await;
87 let _buyer_id = h.signup("fpbuyer", "fpbuyer@test.com", "pass1234").await;
88
89 h.mock_stripe
90 .as_ref()
91 .expect("with_mocks provides a payment provider")
92 .faults()
93 .fail_always("create_checkout_session", stripe_unavailable);
94
95 let resp = h
96 .client
97 .post_form(
98 &format!("/stripe/checkout/{item_id}"),
99 "share_contact=false&promo_code=OUTAGE",
100 )
101 .await;
102 assert_eq!(
103 resp.status.as_u16(),
104 500,
105 "a Stripe outage is not the buyer's fault, got {}",
106 resp.status
107 );
108
109 let use_count: i32 = sqlx::query_scalar(
110 "SELECT use_count FROM promo_codes WHERE creator_id = $1 AND upper(code) = 'OUTAGE'",
111 )
112 .bind(seller_id)
113 .fetch_one(&h.db)
114 .await
115 .unwrap();
116 assert_eq!(
117 use_count, 0,
118 "the reservation must be released when Stripe fails, or an outage burns the code"
119 );
120
121 let pending: i64 = sqlx::query_scalar(
122 "SELECT COUNT(*) FROM transactions WHERE item_id = $1::uuid AND status = 'pending'",
123 )
124 .bind(&item_id)
125 .fetch_one(&h.db)
126 .await
127 .unwrap();
128 assert_eq!(
129 pending, 0,
130 "no session means no transaction, a pending row here would block the buyer's retry"
131 );
132 }
133
134 /// The recovery half: once Stripe is back, the same buyer and the same code go
135 /// through. This is what proves the release above actually restored the code
136 /// rather than merely decrementing a counter.
137 #[tokio::test]
138 async fn checkout_succeeds_on_retry_after_a_stripe_outage() {
139 let mut h = TestHarness::with_mocks().await;
140 let (seller_id, item_id) = setup_paid_item(&mut h, 1000).await;
141
142 sqlx::query(
143 "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, max_uses) \
144 VALUES ($1, 'RETRY', 'discount', 'percentage', 25, 0, 1)",
145 )
146 .bind(seller_id)
147 .execute(&h.db)
148 .await
149 .unwrap();
150
151 h.client.post_form("/logout", "").await;
152 let _buyer_id = h.signup("fpbuyer2", "fpbuyer2@test.com", "pass1234").await;
153
154 let stripe = h
155 .mock_stripe
156 .clone()
157 .expect("with_mocks provides a payment provider");
158
159 // max_uses is 1, so a burned reservation makes the retry below impossible.
160 stripe
161 .faults()
162 .fail_until("create_checkout_session", 2, stripe_unavailable);
163
164 let failed = h
165 .client
166 .post_form(
167 &format!("/stripe/checkout/{item_id}"),
168 "share_contact=false&promo_code=RETRY",
169 )
170 .await;
171 assert_eq!(failed.status.as_u16(), 500, "first attempt fails");
172
173 let resp = h
174 .client
175 .post_form(
176 &format!("/stripe/checkout/{item_id}"),
177 "share_contact=false&promo_code=RETRY",
178 )
179 .await;
180 assert_eq!(
181 resp.status.as_u16(),
182 303,
183 "second attempt redirects to the Stripe session, got {} {}",
184 resp.status,
185 resp.text
186 );
187
188 let amount: i32 = sqlx::query_scalar(
189 "SELECT amount_cents FROM transactions WHERE item_id = $1::uuid AND status = 'pending'",
190 )
191 .bind(&item_id)
192 .fetch_one(&h.db)
193 .await
194 .unwrap();
195 assert_eq!(amount, 750, "the discount still applied on the retry");
196
197 assert_eq!(
198 stripe.faults().calls("create_checkout_session"),
199 2,
200 "exactly two Stripe attempts, the route does not retry internally"
201 );
202 }
203
204 // Email is best-effort, and has to actually be best-effort
205
206 /// A failed buyer receipt must not swallow the seller's sale notification. The
207 /// two sends are guarded separately in `checkout_helpers.rs` precisely so one
208 /// bad address or one transport hiccup does not take out the other, and with an
209 /// infallible transport nothing checked that they really are independent.
210 ///
211 /// The purchase itself is settled before either email is queued, so this also
212 /// asserts the money outcome is untouched by an email outage.
213 #[tokio::test]
214 async fn a_failed_buyer_receipt_still_notifies_the_seller() {
215 let mut h = TestHarness::with_mocks().await;
216 let (seller_id, item_id) = setup_paid_item(&mut h, 500).await;
217
218 h.client.post_form("/logout", "").await;
219 let buyer_id = h.signup("fpbuyer3", "fpbuyer3@test.com", "pass1234").await;
220
221 let session_id = "cs_failure_path_email";
222 sqlx::query(
223 r"INSERT INTO transactions
224 (buyer_id, seller_id, item_id, amount_cents, status,
225 stripe_checkout_session_id, item_title, seller_username)
226 VALUES ($1, $2, $3::uuid, 500, 'pending', $4, 'FP Track', 'fpseller')",
227 )
228 .bind(buyer_id)
229 .bind(seller_id)
230 .bind(&item_id)
231 .bind(session_id)
232 .execute(&h.db)
233 .await
234 .unwrap();
235
236 // Signup already sent this buyer mail, so clear the log and the call counts
237 // before arming the policy. After this the only sends are the webhook's two,
238 // and the buyer receipt is call 1.
239 let email = h
240 .mock_email
241 .clone()
242 .expect("with_mocks provides an email transport");
243 email.clear();
244 email.faults().reset_calls();
245 email.faults().fail_nth("send_email", 1, email_unavailable);
246
247 let mut meta = HashMap::new();
248 meta.insert("buyer_id".to_string(), buyer_id.to_string());
249 meta.insert("seller_id".to_string(), seller_id.to_string());
250 meta.insert("item_id".to_string(), item_id.clone());
251 let session = serde_json::json!({
252 "id": session_id,
253 "object": "checkout_session",
254 "mode": "payment",
255 "metadata": meta,
256 "payment_intent": "pi_failure_path_email",
257 });
258
259 let payload = serde_json::json!({
260 "id": "evt_failure_path_email",
261 "type": "checkout.session.completed",
262 "data": { "object": session },
263 })
264 .to_string();
265 let signature = crate::harness::stripe::sign_webhook_payload(
266 &payload,
267 crate::harness::stripe::TEST_WEBHOOK_SECRET,
268 );
269 let resp = h
270 .client
271 .request_with_headers(
272 "POST",
273 "/stripe/webhook",
274 Some(&payload),
275 &[
276 ("stripe-signature", &signature),
277 ("content-type", "application/json"),
278 ],
279 )
280 .await;
281 assert_eq!(resp.status.as_u16(), 200, "webhook accepted: {}", resp.text);
282
283 // Fire-and-forget email tasks, same wait the sibling email test uses.
284 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
285
286 assert_eq!(
287 email.sent_to("fpbuyer3@test.com").len(),
288 0,
289 "the buyer receipt was the injected failure"
290 );
291 let seller_emails = email.sent_to("fpseller@test.com");
292 assert!(
293 seller_emails
294 .iter()
295 .any(|e| e.subject.to_lowercase().contains("sale")),
296 "the sale notification must still go out, got: {:?}",
297 seller_emails.iter().map(|e| &e.subject).collect::<Vec<_>>()
298 );
299
300 let status: String =
301 sqlx::query_scalar("SELECT status FROM transactions WHERE stripe_checkout_session_id = $1")
302 .bind(session_id)
303 .fetch_one(&h.db)
304 .await
305 .unwrap();
306 assert_eq!(
307 status, "completed",
308 "an email outage must not touch the purchase"
309 );
310 }
311
312 // The pending-refund crash window (PAY-S1)
313
314 /// A refund's claim is deliberately not the same thing as its completion. The
315 /// gap between them is a crash window, and which side of it a row is on decides
316 /// whether the row may be retried automatically or must reach a human. These
317 /// assert that distinction, which is the whole reason the two timestamps are
318 /// separate columns.
319 ///
320 /// The graceful-failure branch in `check_pending_refund` (release the claim so a
321 /// later delivery retries) is NOT covered here: it fires when
322 /// `handle_charge_refunded` returns an error, and that function is pure database
323 /// work, so the seam is the pool rather than any mock the fault harness reaches.
324 use makenotwork::db::Cents;
325 use makenotwork::db::pending_refunds;
326
327 async fn refund_row(h: &TestHarness, pi: &str) -> (bool, bool, bool) {
328 sqlx::query_as(
329 "SELECT matched_at IS NOT NULL, completed_at IS NOT NULL, escalated_at IS NOT NULL
330 FROM pending_refunds WHERE payment_intent_id = $1",
331 )
332 .bind(pi)
333 .fetch_one(&h.db)
334 .await
335 .unwrap()
336 }
337
338 /// A claim marks the row matched and nothing else. Recording completion at claim
339 /// time would erase the crash window: a process killed mid-refund would look
340 /// handled, and the refund would be silently dropped.
341 #[tokio::test]
342 async fn claiming_a_refund_does_not_record_it_as_completed() {
343 let h = TestHarness::new().await;
344 let pi = "pi_claim_only";
345 pending_refunds::insert_pending_refund(&h.db, pi, 1000, 1000)
346 .await
347 .unwrap();
348
349 let claimed = pending_refunds::claim_pending_refund(&h.db, pi)
350 .await
351 .unwrap()
352 .expect("the row is unmatched, so it claims");
353 assert_eq!(claimed.amount, Cents::new(1000));
354
355 let (matched, completed, _) = refund_row(&h, pi).await;
356 assert!(matched, "the claim is recorded");
357 assert!(
358 !completed,
359 "completion must wait for the refund work to succeed"
360 );
361
362 assert!(
363 pending_refunds::claim_pending_refund(&h.db, pi)
364 .await
365 .unwrap()
366 .is_none(),
367 "a claimed refund must not be claimable twice, that would double-refund"
368 );
369
370 pending_refunds::mark_refund_completed(&h.db, claimed.id)
371 .await
372 .unwrap();
373 let (_, completed, _) = refund_row(&h, pi).await;
374 assert!(completed, "completion is recorded separately");
375 }
376
377 /// A graceful failure releases the claim, and the released row must be claimable
378 /// again. Without the re-claim the release accomplishes nothing.
379 #[tokio::test]
380 async fn releasing_a_claim_reopens_the_refund_for_retry() {
381 let h = TestHarness::new().await;
382 let pi = "pi_released";
383 pending_refunds::insert_pending_refund(&h.db, pi, 500, 500)
384 .await
385 .unwrap();
386
387 let first = pending_refunds::claim_pending_refund(&h.db, pi)
388 .await
389 .unwrap()
390 .unwrap();
391 pending_refunds::unclaim_pending_refund(&h.db, first.id)
392 .await
393 .unwrap();
394
395 let (matched, completed, _) = refund_row(&h, pi).await;
396 assert!(!matched, "the release clears the claim");
397 assert!(!completed, "and it is still not complete");
398
399 let second = pending_refunds::claim_pending_refund(&h.db, pi)
400 .await
401 .unwrap()
402 .expect("a released refund must be re-claimable");
403 assert_eq!(second.id, first.id, "the same row, retried");
404 }
405
406 /// The sweep's whole job is to catch what neither the webhook nor the retry
407 /// caught. Both shapes of unfinished refund must surface: never matched, and
408 /// matched-but-incomplete (the process died mid-refund, PAY-S1). A completed one
409 /// must not, or every settled refund would be escalated to a human forever.
410 #[tokio::test]
411 async fn the_stale_sweep_surfaces_both_unfinished_shapes_and_not_completed_ones() {
412 let h = TestHarness::new().await;
413 for (pi, amount) in [("pi_never", 100), ("pi_crashed", 200), ("pi_done", 300)] {
414 pending_refunds::insert_pending_refund(&h.db, pi, amount, amount)
415 .await
416 .unwrap();
417 }
418 // Age them all past the sweep's window.
419 sqlx::query("UPDATE pending_refunds SET created_at = NOW() - INTERVAL '48 hours'")
420 .execute(&h.db)
421 .await
422 .unwrap();
423
424 // pi_crashed: claimed, then the process died before completion.
425 let crashed = pending_refunds::claim_pending_refund(&h.db, "pi_crashed")
426 .await
427 .unwrap()
428 .unwrap();
429 // pi_done: claimed and completed, the settled case.
430 let done = pending_refunds::claim_pending_refund(&h.db, "pi_done")
431 .await
432 .unwrap()
433 .unwrap();
434 pending_refunds::mark_refund_completed(&h.db, done.id)
435 .await
436 .unwrap();
437
438 let stale = pending_refunds::get_stale_refunds(&h.db, chrono::Duration::hours(24))
439 .await
440 .unwrap();
441 let ids: Vec<&str> = stale.iter().map(|r| r.payment_intent_id.as_str()).collect();
442
443 assert!(
444 ids.contains(&"pi_never"),
445 "a refund that never matched a payment needs attention"
446 );
447 assert!(
448 ids.contains(&"pi_crashed"),
449 "matched-but-incomplete is the crash window and must reach a human, \
450 it is deliberately not auto-retried because re-issuing could double-refund"
451 );
452 assert!(
453 !ids.contains(&"pi_done"),
454 "a completed refund is settled and must not be escalated"
455 );
456
457 // Escalation is idempotent: an escalated row stops being surfaced, so the
458 // sweep alerts once rather than every tick until someone acts.
459 pending_refunds::mark_escalated(&h.db, crashed.id)
460 .await
461 .unwrap();
462 let after = pending_refunds::get_stale_refunds(&h.db, chrono::Duration::hours(24))
463 .await
464 .unwrap();
465 assert!(
466 !after.iter().any(|r| r.payment_intent_id == "pi_crashed"),
467 "an escalated refund must not be re-alerted every tick"
468 );
469 let (_, _, escalated) = refund_row(&h, "pi_crashed").await;
470 assert!(escalated, "and the escalation is recorded on the row");
471 }
472