//! Negative paths: what the server does when Stripe or email fails. //! //! Split out of the former `failure_paths.rs` on 2026-08-05, which reached 868 //! lines and tripped the oversized-module ratchet in `tests/test_hygiene.rs`. //! The split is by the dependency that fails, which is also how you look these //! up: the money path and the mail that follows it share a setup and a blast radius. //! //! These tests exist because the mocks used to be infallible, so the retry and //! compensation machinery the server carries had no test that could reach it. //! Retry logic no test can enter is worse than none, because it reads as //! handled. Each test installs a failure policy on a mock (see //! `harness::faults`) and asserts the compensating behaviour, not just that the //! request failed. //! //! Rationale: wiki `testing-posture`, the "absent oracle" section. use crate::harness::TestHarness; use crate::harness::faults::{email_unavailable, stripe_unavailable}; use makenotwork::db; use serde_json::Value; use std::collections::HashMap; // Checkout compensation when Stripe is down /// Create a creator with Stripe connected and a published paid item, logged in /// as the creator afterwards. Mirrors the helper in `promo_codes_checkout`. async fn setup_paid_item(h: &mut TestHarness, price_cents: i32) -> (db::UserId, String) { let seller_id = h.signup("fpseller", "fpseller@test.com", "pass1234").await; h.grant_creator(seller_id).await; sqlx::query("UPDATE users SET stripe_account_id = 'acct_mock_fpseller', stripe_charges_enabled = true WHERE id = $1") .bind(seller_id) .execute(&h.db) .await .unwrap(); h.client.post_form("/logout", "").await; h.login("fpseller", "pass1234").await; let resp = h .client .post_form("/api/projects", "slug=fpshop&title=FP+Shop") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); let resp = h .client .post_form( &format!("/api/projects/{project_id}/items"), &format!("title=FP+Track&price_cents={price_cents}&item_type=audio"), ) .await; let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap().to_string(); h.client .put_form(&format!("/api/projects/{project_id}"), "is_public=true") .await; h.client .put_form(&format!("/api/items/{item_id}"), "is_public=true") .await; (seller_id, item_id) } /// A promo code is reserved (its `use_count` incremented) before the Stripe /// call, so a Stripe failure has to release it. Without that, every outage /// burns uses off a creator's code and the last buyers are told it is exhausted /// when it never was. `routes/stripe/checkout/item.rs` compensates for this and /// no test could reach the branch. #[tokio::test] async fn stripe_failure_at_checkout_releases_the_promo_reservation() { let mut h = TestHarness::with_mocks().await; let (seller_id, item_id) = setup_paid_item(&mut h, 1000).await; sqlx::query( "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, max_uses) \ VALUES ($1, 'OUTAGE', 'discount', 'percentage', 25, 0, 5)", ) .bind(seller_id) .execute(&h.db) .await .unwrap(); h.client.post_form("/logout", "").await; let _buyer_id = h.signup("fpbuyer", "fpbuyer@test.com", "pass1234").await; h.mock_stripe .as_ref() .expect("with_mocks provides a payment provider") .faults() .fail_always("create_checkout_session", stripe_unavailable); let resp = h .client .post_form( &format!("/stripe/checkout/{item_id}"), "share_contact=false&promo_code=OUTAGE", ) .await; assert_eq!( resp.status.as_u16(), 500, "a Stripe outage is not the buyer's fault, got {}", resp.status ); let use_count: i32 = sqlx::query_scalar( "SELECT use_count FROM promo_codes WHERE creator_id = $1 AND upper(code) = 'OUTAGE'", ) .bind(seller_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!( use_count, 0, "the reservation must be released when Stripe fails, or an outage burns the code" ); let pending: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM transactions WHERE item_id = $1::uuid AND status = 'pending'", ) .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!( pending, 0, "no session means no transaction, a pending row here would block the buyer's retry" ); } /// The recovery half: once Stripe is back, the same buyer and the same code go /// through. This is what proves the release above actually restored the code /// rather than merely decrementing a counter. #[tokio::test] async fn checkout_succeeds_on_retry_after_a_stripe_outage() { let mut h = TestHarness::with_mocks().await; let (seller_id, item_id) = setup_paid_item(&mut h, 1000).await; sqlx::query( "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, max_uses) \ VALUES ($1, 'RETRY', 'discount', 'percentage', 25, 0, 1)", ) .bind(seller_id) .execute(&h.db) .await .unwrap(); h.client.post_form("/logout", "").await; let _buyer_id = h.signup("fpbuyer2", "fpbuyer2@test.com", "pass1234").await; let stripe = h .mock_stripe .clone() .expect("with_mocks provides a payment provider"); // max_uses is 1, so a burned reservation makes the retry below impossible. stripe .faults() .fail_until("create_checkout_session", 2, stripe_unavailable); let failed = h .client .post_form( &format!("/stripe/checkout/{item_id}"), "share_contact=false&promo_code=RETRY", ) .await; assert_eq!(failed.status.as_u16(), 500, "first attempt fails"); let resp = h .client .post_form( &format!("/stripe/checkout/{item_id}"), "share_contact=false&promo_code=RETRY", ) .await; assert_eq!( resp.status.as_u16(), 303, "second attempt redirects to the Stripe session, got {} {}", resp.status, resp.text ); let amount: i32 = sqlx::query_scalar( "SELECT amount_cents FROM transactions WHERE item_id = $1::uuid AND status = 'pending'", ) .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(amount, 750, "the discount still applied on the retry"); assert_eq!( stripe.faults().calls("create_checkout_session"), 2, "exactly two Stripe attempts, the route does not retry internally" ); } // Email is best-effort, and has to actually be best-effort /// A failed buyer receipt must not swallow the seller's sale notification. The /// two sends are guarded separately in `checkout_helpers.rs` precisely so one /// bad address or one transport hiccup does not take out the other, and with an /// infallible transport nothing checked that they really are independent. /// /// The purchase itself is settled before either email is queued, so this also /// asserts the money outcome is untouched by an email outage. #[tokio::test] async fn a_failed_buyer_receipt_still_notifies_the_seller() { let mut h = TestHarness::with_mocks().await; let (seller_id, item_id) = setup_paid_item(&mut h, 500).await; h.client.post_form("/logout", "").await; let buyer_id = h.signup("fpbuyer3", "fpbuyer3@test.com", "pass1234").await; let session_id = "cs_failure_path_email"; sqlx::query( r"INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, status, stripe_checkout_session_id, item_title, seller_username) VALUES ($1, $2, $3::uuid, 500, 'pending', $4, 'FP Track', 'fpseller')", ) .bind(buyer_id) .bind(seller_id) .bind(&item_id) .bind(session_id) .execute(&h.db) .await .unwrap(); // Signup already sent this buyer mail, so clear the log and the call counts // before arming the policy. After this the only sends are the webhook's two, // and the buyer receipt is call 1. let email = h .mock_email .clone() .expect("with_mocks provides an email transport"); email.clear(); email.faults().reset_calls(); email.faults().fail_nth("send_email", 1, email_unavailable); let mut meta = HashMap::new(); meta.insert("buyer_id".to_string(), buyer_id.to_string()); meta.insert("seller_id".to_string(), seller_id.to_string()); meta.insert("item_id".to_string(), item_id.clone()); let session = serde_json::json!({ "id": session_id, "object": "checkout_session", "mode": "payment", "metadata": meta, "payment_intent": "pi_failure_path_email", }); let payload = serde_json::json!({ "id": "evt_failure_path_email", "type": "checkout.session.completed", "data": { "object": session }, }) .to_string(); let signature = crate::harness::stripe::sign_webhook_payload( &payload, crate::harness::stripe::TEST_WEBHOOK_SECRET, ); let resp = h .client .request_with_headers( "POST", "/stripe/webhook", Some(&payload), &[ ("stripe-signature", &signature), ("content-type", "application/json"), ], ) .await; assert_eq!(resp.status.as_u16(), 200, "webhook accepted: {}", resp.text); // Fire-and-forget email tasks, same wait the sibling email test uses. tokio::time::sleep(std::time::Duration::from_millis(200)).await; assert_eq!( email.sent_to("fpbuyer3@test.com").len(), 0, "the buyer receipt was the injected failure" ); let seller_emails = email.sent_to("fpseller@test.com"); assert!( seller_emails .iter() .any(|e| e.subject.to_lowercase().contains("sale")), "the sale notification must still go out, got: {:?}", seller_emails.iter().map(|e| &e.subject).collect::>() ); let status: String = sqlx::query_scalar("SELECT status FROM transactions WHERE stripe_checkout_session_id = $1") .bind(session_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!( status, "completed", "an email outage must not touch the purchase" ); } // The pending-refund crash window (PAY-S1) /// A refund's claim is deliberately not the same thing as its completion. The /// gap between them is a crash window, and which side of it a row is on decides /// whether the row may be retried automatically or must reach a human. These /// assert that distinction, which is the whole reason the two timestamps are /// separate columns. /// /// The graceful-failure branch in `check_pending_refund` (release the claim so a /// later delivery retries) is NOT covered here: it fires when /// `handle_charge_refunded` returns an error, and that function is pure database /// work, so the seam is the pool rather than any mock the fault harness reaches. use makenotwork::db::Cents; use makenotwork::db::pending_refunds; async fn refund_row(h: &TestHarness, pi: &str) -> (bool, bool, bool) { sqlx::query_as( "SELECT matched_at IS NOT NULL, completed_at IS NOT NULL, escalated_at IS NOT NULL FROM pending_refunds WHERE payment_intent_id = $1", ) .bind(pi) .fetch_one(&h.db) .await .unwrap() } /// A claim marks the row matched and nothing else. Recording completion at claim /// time would erase the crash window: a process killed mid-refund would look /// handled, and the refund would be silently dropped. #[tokio::test] async fn claiming_a_refund_does_not_record_it_as_completed() { let h = TestHarness::new().await; let pi = "pi_claim_only"; pending_refunds::insert_pending_refund(&h.db, pi, 1000, 1000) .await .unwrap(); let claimed = pending_refunds::claim_pending_refund(&h.db, pi) .await .unwrap() .expect("the row is unmatched, so it claims"); assert_eq!(claimed.amount, Cents::new(1000)); let (matched, completed, _) = refund_row(&h, pi).await; assert!(matched, "the claim is recorded"); assert!( !completed, "completion must wait for the refund work to succeed" ); assert!( pending_refunds::claim_pending_refund(&h.db, pi) .await .unwrap() .is_none(), "a claimed refund must not be claimable twice, that would double-refund" ); pending_refunds::mark_refund_completed(&h.db, claimed.id) .await .unwrap(); let (_, completed, _) = refund_row(&h, pi).await; assert!(completed, "completion is recorded separately"); } /// A graceful failure releases the claim, and the released row must be claimable /// again. Without the re-claim the release accomplishes nothing. #[tokio::test] async fn releasing_a_claim_reopens_the_refund_for_retry() { let h = TestHarness::new().await; let pi = "pi_released"; pending_refunds::insert_pending_refund(&h.db, pi, 500, 500) .await .unwrap(); let first = pending_refunds::claim_pending_refund(&h.db, pi) .await .unwrap() .unwrap(); pending_refunds::unclaim_pending_refund(&h.db, first.id) .await .unwrap(); let (matched, completed, _) = refund_row(&h, pi).await; assert!(!matched, "the release clears the claim"); assert!(!completed, "and it is still not complete"); let second = pending_refunds::claim_pending_refund(&h.db, pi) .await .unwrap() .expect("a released refund must be re-claimable"); assert_eq!(second.id, first.id, "the same row, retried"); } /// The sweep's whole job is to catch what neither the webhook nor the retry /// caught. Both shapes of unfinished refund must surface: never matched, and /// matched-but-incomplete (the process died mid-refund, PAY-S1). A completed one /// must not, or every settled refund would be escalated to a human forever. #[tokio::test] async fn the_stale_sweep_surfaces_both_unfinished_shapes_and_not_completed_ones() { let h = TestHarness::new().await; for (pi, amount) in [("pi_never", 100), ("pi_crashed", 200), ("pi_done", 300)] { pending_refunds::insert_pending_refund(&h.db, pi, amount, amount) .await .unwrap(); } // Age them all past the sweep's window. sqlx::query("UPDATE pending_refunds SET created_at = NOW() - INTERVAL '48 hours'") .execute(&h.db) .await .unwrap(); // pi_crashed: claimed, then the process died before completion. let crashed = pending_refunds::claim_pending_refund(&h.db, "pi_crashed") .await .unwrap() .unwrap(); // pi_done: claimed and completed, the settled case. let done = pending_refunds::claim_pending_refund(&h.db, "pi_done") .await .unwrap() .unwrap(); pending_refunds::mark_refund_completed(&h.db, done.id) .await .unwrap(); let stale = pending_refunds::get_stale_refunds(&h.db, chrono::Duration::hours(24)) .await .unwrap(); let ids: Vec<&str> = stale.iter().map(|r| r.payment_intent_id.as_str()).collect(); assert!( ids.contains(&"pi_never"), "a refund that never matched a payment needs attention" ); assert!( ids.contains(&"pi_crashed"), "matched-but-incomplete is the crash window and must reach a human, \ it is deliberately not auto-retried because re-issuing could double-refund" ); assert!( !ids.contains(&"pi_done"), "a completed refund is settled and must not be escalated" ); // Escalation is idempotent: an escalated row stops being surfaced, so the // sweep alerts once rather than every tick until someone acts. pending_refunds::mark_escalated(&h.db, crashed.id) .await .unwrap(); let after = pending_refunds::get_stale_refunds(&h.db, chrono::Duration::hours(24)) .await .unwrap(); assert!( !after.iter().any(|r| r.payment_intent_id == "pi_crashed"), "an escalated refund must not be re-alerted every tick" ); let (_, _, escalated) = refund_row(&h, "pi_crashed").await; assert!(escalated, "and the escalation is recorded on the row"); }