Skip to main content

max / makenotwork

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