max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+727 insertions,
-1 deletion
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | //! Records all sent emails so tests can assert on recipients, subjects, and bodies | |
| 4 | 4 | //! without hitting any external service. | |
| 5 | 5 | ||
| 6 | + | use super::faults::Faults; | |
| 6 | 7 | use makenotwork::email::EmailTransport; | |
| 7 | 8 | use makenotwork::error::Result; | |
| 8 | 9 | use std::sync::Mutex; | |
| @@ -21,6 +22,8 @@ | |||
| 21 | 22 | /// In-memory email transport that records all sent emails. | |
| 22 | 23 | pub(crate) struct MockEmailTransport { | |
| 23 | 24 | sent: Mutex<Vec<SentEmail>>, | |
| 25 | + | /// Injected send failures. Empty by default. | |
| 26 | + | faults: Faults, | |
| 24 | 27 | } | |
| 25 | 28 | ||
| 26 | 29 | #[allow(dead_code)] | |
| @@ -28,9 +31,17 @@ | |||
| 28 | 31 | pub(crate) fn new() -> Self { | |
| 29 | 32 | MockEmailTransport { | |
| 30 | 33 | sent: Mutex::new(Vec::new()), | |
| 34 | + | faults: Faults::new(), | |
| 31 | 35 | } | |
| 32 | 36 | } | |
| 33 | 37 | ||
| 38 | + | /// The failure policy. All four send methods share the operation name | |
| 39 | + | /// `send_email`, because a caller picks one by what it needs to include and | |
| 40 | + | /// a transport outage takes out all of them together. | |
| 41 | + | pub(crate) fn faults(&self) -> &Faults { | |
| 42 | + | &self.faults | |
| 43 | + | } | |
| 44 | + | ||
| 34 | 45 | /// Return all emails sent so far. | |
| 35 | 46 | pub(crate) fn sent(&self) -> Vec<SentEmail> { | |
| 36 | 47 | self.sent.lock().unwrap().clone() | |
| @@ -61,6 +72,7 @@ | |||
| 61 | 72 | #[async_trait::async_trait] | |
| 62 | 73 | impl EmailTransport for MockEmailTransport { | |
| 63 | 74 | async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()> { | |
| 75 | + | self.faults.check("send_email")?; | |
| 64 | 76 | self.sent.lock().unwrap().push(SentEmail { | |
| 65 | 77 | to: to.to_string(), | |
| 66 | 78 | subject: subject.to_string(), | |
| @@ -78,6 +90,7 @@ | |||
| 78 | 90 | body: &str, | |
| 79 | 91 | unsub_url: Option<&str>, | |
| 80 | 92 | ) -> Result<()> { | |
| 93 | + | self.faults.check("send_email")?; | |
| 81 | 94 | self.sent.lock().unwrap().push(SentEmail { | |
| 82 | 95 | to: to.to_string(), | |
| 83 | 96 | subject: subject.to_string(), | |
| @@ -96,6 +109,7 @@ | |||
| 96 | 109 | _extra_headers: &[(&str, String)], | |
| 97 | 110 | unsub_url: Option<&str>, | |
| 98 | 111 | ) -> Result<()> { | |
| 112 | + | self.faults.check("send_email")?; | |
| 99 | 113 | self.sent.lock().unwrap().push(SentEmail { | |
| 100 | 114 | to: to.to_string(), | |
| 101 | 115 | subject: subject.to_string(), | |
| @@ -113,6 +127,7 @@ | |||
| 113 | 127 | body: &str, | |
| 114 | 128 | unsub_url: Option<&str>, | |
| 115 | 129 | ) -> Result<()> { | |
| 130 | + | self.faults.check("send_email")?; | |
| 116 | 131 | self.sent.lock().unwrap().push(SentEmail { | |
| 117 | 132 | to: to.to_string(), | |
| 118 | 133 | subject: subject.to_string(), |
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | pub(crate) mod client; | |
| 4 | 4 | pub(crate) mod db; | |
| 5 | 5 | pub(crate) mod email; | |
| 6 | + | pub(crate) mod faults; | |
| 6 | 7 | pub(crate) mod gitfixture; | |
| 7 | 8 | pub(crate) mod seed; | |
| 8 | 9 | pub(crate) mod storage; |
| @@ -1,5 +1,6 @@ | |||
| 1 | 1 | //! In-memory storage backend for integration tests. | |
| 2 | 2 | ||
| 3 | + | use super::faults::Faults; | |
| 3 | 4 | use makenotwork::error::{AppError, Result}; | |
| 4 | 5 | use makenotwork::storage::{S3DeleteAuthority, S3Key, StorageBackend}; | |
| 5 | 6 | use std::collections::HashMap; | |
| @@ -14,6 +15,9 @@ | |||
| 14 | 15 | /// unaborted session is exactly the leak this models. | |
| 15 | 16 | multipart: Mutex<HashMap<String, String>>, | |
| 16 | 17 | bucket: String, | |
| 18 | + | /// Injected transport failures. Empty by default, so a backend with no | |
| 19 | + | /// policy installed behaves exactly as it did before this existed. | |
| 20 | + | faults: Faults, | |
| 17 | 21 | } | |
| 18 | 22 | ||
| 19 | 23 | #[allow(dead_code)] | |
| @@ -23,9 +27,16 @@ | |||
| 23 | 27 | objects: Mutex::new(HashMap::new()), | |
| 24 | 28 | multipart: Mutex::new(HashMap::new()), | |
| 25 | 29 | bucket: "test-bucket".to_string(), | |
| 30 | + | faults: Faults::new(), | |
| 26 | 31 | } | |
| 27 | 32 | } | |
| 28 | 33 | ||
| 34 | + | /// The failure policy. Install rules on it to reach the retry and | |
| 35 | + | /// compensation paths that no test could otherwise enter. | |
| 36 | + | pub(crate) fn faults(&self) -> &Faults { | |
| 37 | + | &self.faults | |
| 38 | + | } | |
| 39 | + | ||
| 29 | 40 | /// Number of multipart sessions still open. Zero after a clean reap. | |
| 30 | 41 | pub(crate) fn open_multipart_count(&self) -> usize { | |
| 31 | 42 | self.multipart.lock().unwrap().len() | |
| @@ -67,10 +78,12 @@ | |||
| 67 | 78 | _cache_control: Option<&str>, | |
| 68 | 79 | _max_bytes: Option<i64>, | |
| 69 | 80 | ) -> Result<String> { | |
| 81 | + | self.faults.check("presign_upload")?; | |
| 70 | 82 | Ok(format!("http://test-storage/{s3_key}")) | |
| 71 | 83 | } | |
| 72 | 84 | ||
| 73 | 85 | async fn presign_download(&self, s3_key: &S3Key, _expiry_secs: Option<u64>) -> Result<String> { | |
| 86 | + | self.faults.check("presign_download")?; | |
| 74 | 87 | if self.objects.lock().unwrap().contains_key(s3_key.as_str()) { | |
| 75 | 88 | Ok(format!("http://test-storage/{s3_key}")) | |
| 76 | 89 | } else { | |
| @@ -79,10 +92,12 @@ | |||
| 79 | 92 | } | |
| 80 | 93 | ||
| 81 | 94 | async fn object_exists(&self, s3_key: &str) -> Result<bool> { | |
| 95 | + | self.faults.check("object_exists")?; | |
| 82 | 96 | Ok(self.objects.lock().unwrap().contains_key(s3_key)) | |
| 83 | 97 | } | |
| 84 | 98 | ||
| 85 | 99 | async fn object_size(&self, s3_key: &str) -> Result<Option<i64>> { | |
| 100 | + | self.faults.check("object_size")?; | |
| 86 | 101 | Ok(self | |
| 87 | 102 | .objects | |
| 88 | 103 | .lock() | |
| @@ -91,7 +106,12 @@ | |||
| 91 | 106 | .map(|v| v.len() as i64)) | |
| 92 | 107 | } | |
| 93 | 108 | ||
| 109 | + | // `download_object_buf` and the two `copy_object_*` wrappers delegate to the | |
| 110 | + | // methods below them, so a policy on the delegate is what fires and the call | |
| 111 | + | // count is recorded once. Name the underlying operation in a rule, not the | |
| 112 | + | // wrapper. | |
| 94 | 113 | async fn download_object(&self, s3_key: &str) -> Result<Vec<u8>> { | |
| 114 | + | self.faults.check("download_object")?; | |
| 95 | 115 | self.objects | |
| 96 | 116 | .lock() | |
| 97 | 117 | .unwrap() | |
| @@ -105,6 +125,7 @@ | |||
| 105 | 125 | } | |
| 106 | 126 | ||
| 107 | 127 | async fn download_stream(&self, s3_key: &str) -> Result<s3_storage::ByteStream> { | |
| 128 | + | self.faults.check("download_stream")?; | |
| 108 | 129 | let bytes = self | |
| 109 | 130 | .objects | |
| 110 | 131 | .lock() | |
| @@ -122,6 +143,7 @@ | |||
| 122 | 143 | data: Vec<u8>, | |
| 123 | 144 | _cache_control: Option<&str>, | |
| 124 | 145 | ) -> Result<()> { | |
| 146 | + | self.faults.check("upload_object")?; | |
| 125 | 147 | self.objects | |
| 126 | 148 | .lock() | |
| 127 | 149 | .unwrap() | |
| @@ -135,6 +157,7 @@ | |||
| 135 | 157 | _content_type: &str, | |
| 136 | 158 | file_path: &std::path::Path, | |
| 137 | 159 | ) -> Result<()> { | |
| 160 | + | self.faults.check("upload_multipart")?; | |
| 138 | 161 | let data = tokio::fs::read(file_path) | |
| 139 | 162 | .await | |
| 140 | 163 | .map_err(|e| AppError::Storage(format!("read multipart source: {e}")))?; | |
| @@ -146,11 +169,13 @@ | |||
| 146 | 169 | } | |
| 147 | 170 | ||
| 148 | 171 | async fn delete_object(&self, _auth: &S3DeleteAuthority, s3_key: &S3Key) -> Result<()> { | |
| 172 | + | self.faults.check("delete_object")?; | |
| 149 | 173 | self.objects.lock().unwrap().remove(s3_key.as_str()); | |
| 150 | 174 | Ok(()) | |
| 151 | 175 | } | |
| 152 | 176 | ||
| 153 | 177 | async fn copy_object(&self, src_key: &S3Key, dst_key: &S3Key) -> Result<()> { | |
| 178 | + | self.faults.check("copy_object")?; | |
| 154 | 179 | let mut objects = self.objects.lock().unwrap(); | |
| 155 | 180 | let bytes = objects | |
| 156 | 181 | .get(src_key.as_str()) | |
| @@ -195,6 +220,7 @@ | |||
| 195 | 220 | // confirm path, exactly as they do for `presign_upload` + confirm. | |
| 196 | 221 | ||
| 197 | 222 | async fn create_multipart_upload(&self, s3_key: &S3Key, _content_type: &str) -> Result<String> { | |
| 223 | + | self.faults.check("create_multipart_upload")?; | |
| 198 | 224 | let upload_id = format!("test-upload-id/{s3_key}"); | |
| 199 | 225 | self.put_open_multipart(&upload_id, s3_key.as_str()); | |
| 200 | 226 | Ok(upload_id) | |
| @@ -209,6 +235,7 @@ | |||
| 209 | 235 | _max_bytes: Option<i64>, | |
| 210 | 236 | checksum_sha256: Option<&str>, | |
| 211 | 237 | ) -> Result<String> { | |
| 238 | + | self.faults.check("presign_upload_part")?; | |
| 212 | 239 | // Mirror the production range check so a bad part number fails in tests | |
| 213 | 240 | // the same way it would against S3. | |
| 214 | 241 | if !(1..=s3_storage::MULTIPART_MAX_PARTS as i32).contains(&part_number) { | |
| @@ -233,6 +260,7 @@ | |||
| 233 | 260 | upload_id: &str, | |
| 234 | 261 | parts: &[(i32, String)], | |
| 235 | 262 | ) -> Result<()> { | |
| 263 | + | self.faults.check("complete_multipart_upload")?; | |
| 236 | 264 | if parts.is_empty() { | |
| 237 | 265 | return Err(AppError::Storage( | |
| 238 | 266 | "cannot complete a multipart upload with no parts".to_string(), | |
| @@ -244,12 +272,14 @@ | |||
| 244 | 272 | } | |
| 245 | 273 | ||
| 246 | 274 | async fn abort_multipart_upload(&self, _s3_key: &S3Key, upload_id: &str) -> Result<()> { | |
| 275 | + | self.faults.check("abort_multipart_upload")?; | |
| 247 | 276 | // Idempotent: aborting an unknown session is fine. | |
| 248 | 277 | self.multipart.lock().unwrap().remove(upload_id); | |
| 249 | 278 | Ok(()) | |
| 250 | 279 | } | |
| 251 | 280 | ||
| 252 | 281 | async fn list_multipart_uploads_for_key(&self, s3_key: &str) -> Result<Vec<String>> { | |
| 282 | + | self.faults.check("list_multipart_uploads_for_key")?; | |
| 253 | 283 | Ok(self | |
| 254 | 284 | .multipart | |
| 255 | 285 | .lock() | |
| @@ -261,7 +291,11 @@ | |||
| 261 | 291 | } | |
| 262 | 292 | ||
| 263 | 293 | async fn check_connectivity(&self) -> std::result::Result<(), String> { | |
| 264 | - | Ok(()) | |
| 294 | + | // Health checks report a string, not an `AppError`, so the injected | |
| 295 | + | // error is rendered rather than propagated. | |
| 296 | + | self.faults | |
| 297 | + | .check("check_connectivity") | |
| 298 | + | .map_err(|e| e.to_string()) | |
| 265 | 299 | } | |
| 266 | 300 | ||
| 267 | 301 | fn bucket(&self) -> &str { |
| @@ -1,5 +1,6 @@ | |||
| 1 | 1 | //! Stripe test helpers, webhook signature computation and mock payment provider. | |
| 2 | 2 | ||
| 3 | + | use super::faults::Faults; | |
| 3 | 4 | use hmac::{Hmac, KeyInit, Mac}; | |
| 4 | 5 | use sha2::Sha256; | |
| 5 | 6 | use std::sync::Mutex; | |
| @@ -84,6 +85,9 @@ | |||
| 84 | 85 | /// Platform-credit reversals requested, in call order. Lets refund tests | |
| 85 | 86 | /// assert a settled credit was clawed back. | |
| 86 | 87 | reversals: Mutex<Vec<MockReversal>>, | |
| 88 | + | /// Injected Stripe failures. Empty by default. Operations are named for the | |
| 89 | + | /// trait method, so a rule reads as the Stripe call it breaks. | |
| 90 | + | faults: Faults, | |
| 87 | 91 | } | |
| 88 | 92 | ||
| 89 | 93 | /// A line-scoped refund captured by the mock. | |
| @@ -123,9 +127,17 @@ | |||
| 123 | 127 | refunds: Mutex::new(Vec::new()), | |
| 124 | 128 | transfers: Mutex::new(Vec::new()), | |
| 125 | 129 | reversals: Mutex::new(Vec::new()), | |
| 130 | + | faults: Faults::new(), | |
| 126 | 131 | } | |
| 127 | 132 | } | |
| 128 | 133 | ||
| 134 | + | /// The failure policy. Install rules on it to reach the compensation paths, | |
| 135 | + | /// `db/pending_refunds.rs` above all, that a provider which never fails | |
| 136 | + | /// leaves unobserved. | |
| 137 | + | pub(crate) fn faults(&self) -> &Faults { | |
| 138 | + | &self.faults | |
| 139 | + | } | |
| 140 | + | ||
| 129 | 141 | /// All line-scoped refunds requested so far. | |
| 130 | 142 | pub(crate) fn refunds(&self) -> Vec<MockRefund> { | |
| 131 | 143 | self.refunds.lock().unwrap().clone() | |
| @@ -170,6 +182,7 @@ | |||
| 170 | 182 | &self, | |
| 171 | 183 | _params: &CheckoutParams<'_>, | |
| 172 | 184 | ) -> Result<CheckoutResult> { | |
| 185 | + | self.faults.check("create_checkout_session")?; | |
| 173 | 186 | Ok(self.next_session()) | |
| 174 | 187 | } | |
| 175 | 188 | ||
| @@ -177,6 +190,7 @@ | |||
| 177 | 190 | &self, | |
| 178 | 191 | _params: &makenotwork::payments::GuestCheckoutParams<'_>, | |
| 179 | 192 | ) -> Result<CheckoutResult> { | |
| 193 | + | self.faults.check("create_guest_checkout_session")?; | |
| 180 | 194 | Ok(self.next_session()) | |
| 181 | 195 | } | |
| 182 | 196 | ||
| @@ -184,6 +198,7 @@ | |||
| 184 | 198 | &self, | |
| 185 | 199 | _params: &SubscriptionCheckoutParams<'_>, | |
| 186 | 200 | ) -> Result<CheckoutResult> { | |
| 201 | + | self.faults.check("create_subscription_checkout_session")?; | |
| 187 | 202 | Ok(self.next_session()) | |
| 188 | 203 | } | |
| 189 | 204 | ||
| @@ -191,6 +206,7 @@ | |||
| 191 | 206 | &self, | |
| 192 | 207 | _params: &TipCheckoutParams<'_>, | |
| 193 | 208 | ) -> Result<CheckoutResult> { | |
| 209 | + | self.faults.check("create_tip_checkout_session")?; | |
| 194 | 210 | Ok(self.next_session()) | |
| 195 | 211 | } | |
| 196 | 212 | ||
| @@ -201,6 +217,7 @@ | |||
| 201 | 217 | _success_url: &str, | |
| 202 | 218 | _cancel_url: &str, | |
| 203 | 219 | ) -> Result<CheckoutResult> { | |
| 220 | + | self.faults.check("create_fan_plus_checkout_session")?; | |
| 204 | 221 | Ok(self.next_session()) | |
| 205 | 222 | } | |
| 206 | 223 | ||
| @@ -213,6 +230,7 @@ | |||
| 213 | 230 | _cancel_url: &str, | |
| 214 | 231 | trial_days: Option<i32>, | |
| 215 | 232 | ) -> Result<CheckoutResult> { | |
| 233 | + | self.faults.check("create_creator_tier_checkout_session")?; | |
| 216 | 234 | self.creator_tier_trial_days | |
| 217 | 235 | .lock() | |
| 218 | 236 | .unwrap() | |
| @@ -224,6 +242,7 @@ | |||
| 224 | 242 | &self, | |
| 225 | 243 | _params: &makenotwork::payments::CartCheckoutParams<'_>, | |
| 226 | 244 | ) -> Result<CheckoutResult> { | |
| 245 | + | self.faults.check("create_cart_checkout_session")?; | |
| 227 | 246 | Ok(self.next_session()) | |
| 228 | 247 | } | |
| 229 | 248 | ||
| @@ -231,6 +250,7 @@ | |||
| 231 | 250 | &self, | |
| 232 | 251 | _email: &str, | |
| 233 | 252 | ) -> Result<makenotwork::db::StripeAccountId> { | |
| 253 | + | self.faults.check("create_connect_account")?; | |
| 234 | 254 | Ok(makenotwork::db::StripeAccountId::from_trusted( | |
| 235 | 255 | "acct_test_mock".to_string(), | |
| 236 | 256 | )) | |
| @@ -242,10 +262,12 @@ | |||
| 242 | 262 | _return_url: &str, | |
| 243 | 263 | _refresh_url: &str, | |
| 244 | 264 | ) -> Result<String> { | |
| 265 | + | self.faults.check("create_account_link")?; | |
| 245 | 266 | Ok("https://connect.stripe.com/test/onboarding".to_string()) | |
| 246 | 267 | } | |
| 247 | 268 | ||
| 248 | 269 | async fn fetch_account(&self, account_id: &str) -> Result<AccountUpdate> { | |
| 270 | + | self.faults.check("fetch_account")?; | |
| 249 | 271 | Ok(AccountUpdate { | |
| 250 | 272 | account_id: account_id.to_string(), | |
| 251 | 273 | charges_enabled: true, | |
| @@ -261,10 +283,12 @@ | |||
| 261 | 283 | _tier_description: Option<&str>, | |
| 262 | 284 | _price_cents: i64, | |
| 263 | 285 | ) -> Result<(String, String)> { | |
| 286 | + | self.faults.check("create_subscription_product_and_price")?; | |
| 264 | 287 | Ok(("prod_test_mock".to_string(), "price_test_mock".to_string())) | |
| 265 | 288 | } | |
| 266 | 289 | ||
| 267 | 290 | async fn get_balance(&self, _account_id: &str) -> Result<BalanceSummary> { | |
| 291 | + | self.faults.check("get_balance")?; | |
| 268 | 292 | Ok(BalanceSummary { | |
| 269 | 293 | available_cents: 0, | |
| 270 | 294 | pending_cents: 0, | |
| @@ -276,12 +300,14 @@ | |||
| 276 | 300 | payload: &str, | |
| 277 | 301 | signature: &str, | |
| 278 | 302 | ) -> Result<makenotwork::payments::UntypedEvent> { | |
| 303 | + | self.faults.check("verify_webhook")?; | |
| 279 | 304 | makenotwork::payments::verify_signature(payload, signature, TEST_WEBHOOK_SECRET) | |
| 280 | 305 | .map_err(AppError::BadRequest)?; | |
| 281 | 306 | makenotwork::payments::UntypedEvent::from_payload(payload) | |
| 282 | 307 | } | |
| 283 | 308 | ||
| 284 | 309 | fn verify_webhook_v2(&self, payload: &str, signature: &str) -> Result<serde_json::Value> { | |
| 310 | + | self.faults.check("verify_webhook_v2")?; | |
| 285 | 311 | makenotwork::payments::verify_signature(payload, signature, TEST_WEBHOOK_SECRET_V2) | |
| 286 | 312 | .map_err(AppError::BadRequest)?; | |
| 287 | 313 | serde_json::from_str(payload) | |
| @@ -293,6 +319,7 @@ | |||
| 293 | 319 | _stripe_sub_id: &str, | |
| 294 | 320 | _connected_account_id: &str, | |
| 295 | 321 | ) -> Result<()> { | |
| 322 | + | self.faults.check("pause_subscription")?; | |
| 296 | 323 | Ok(()) | |
| 297 | 324 | } | |
| 298 | 325 | ||
| @@ -301,6 +328,7 @@ | |||
| 301 | 328 | _stripe_sub_id: &str, | |
| 302 | 329 | _connected_account_id: &str, | |
| 303 | 330 | ) -> Result<()> { | |
| 331 | + | self.faults.check("resume_subscription")?; | |
| 304 | 332 | Ok(()) | |
| 305 | 333 | } | |
| 306 | 334 | ||
| @@ -309,6 +337,7 @@ | |||
| 309 | 337 | _stripe_sub_id: &str, | |
| 310 | 338 | _connected_account_id: &str, | |
| 311 | 339 | ) -> Result<()> { | |
| 340 | + | self.faults.check("cancel_subscription")?; | |
| 312 | 341 | Ok(()) | |
| 313 | 342 | } | |
| 314 | 343 | ||
| @@ -318,10 +347,12 @@ | |||
| 318 | 347 | _connected_account_id: &str, | |
| 319 | 348 | _cancel: bool, | |
| 320 | 349 | ) -> Result<()> { | |
| 350 | + | self.faults.check("set_cancel_at_period_end")?; | |
| 321 | 351 | Ok(()) | |
| 322 | 352 | } | |
| 323 | 353 | ||
| 324 | 354 | async fn cancel_platform_subscription(&self, _stripe_sub_id: &str) -> Result<()> { | |
| 355 | + | self.faults.check("cancel_platform_subscription")?; | |
| 325 | 356 | Ok(()) | |
| 326 | 357 | } | |
| 327 | 358 | ||
| @@ -330,6 +361,7 @@ | |||
| 330 | 361 | _stripe_sub_id: &str, | |
| 331 | 362 | _cancel: bool, | |
| 332 | 363 | ) -> Result<()> { | |
| 364 | + | self.faults.check("set_platform_cancel_at_period_end")?; | |
| 333 | 365 | Ok(()) | |
| 334 | 366 | } | |
| 335 | 367 | ||
| @@ -338,6 +370,7 @@ | |||
| 338 | 370 | _stripe_customer_id: &str, | |
| 339 | 371 | return_url: &str, | |
| 340 | 372 | ) -> Result<String> { | |
| 373 | + | self.faults.check("create_billing_portal_session")?; | |
| 341 | 374 | // Echo a deterministic URL so tests can assert the redirect target. | |
| 342 | 375 | Ok(format!( | |
| 343 | 376 | "https://billing.stripe.test/portal?return={}", | |
| @@ -352,6 +385,7 @@ | |||
| 352 | 385 | amount_cents: i64, | |
| 353 | 386 | transaction_id: makenotwork::db::TransactionId, | |
| 354 | 387 | ) -> Result<()> { | |
| 388 | + | self.faults.check("create_refund_for_transaction")?; | |
| 355 | 389 | self.refunds.lock().unwrap().push(MockRefund { | |
| 356 | 390 | payment_intent_id: payment_intent_id.to_string(), | |
| 357 | 391 | amount_cents, | |
| @@ -366,6 +400,7 @@ | |||
| 366 | 400 | amount_cents: i64, | |
| 367 | 401 | transaction_id: makenotwork::db::TransactionId, | |
| 368 | 402 | ) -> Result<String> { | |
| 403 | + | self.faults.check("create_platform_credit_transfer")?; | |
| 369 | 404 | self.transfers.lock().unwrap().push(MockTransfer { | |
| 370 | 405 | connected_account_id: connected_account_id.to_string(), | |
| 371 | 406 | amount_cents, | |
| @@ -381,6 +416,7 @@ | |||
| 381 | 416 | amount_cents: i64, | |
| 382 | 417 | transaction_id: makenotwork::db::TransactionId, | |
| 383 | 418 | ) -> Result<()> { | |
| 419 | + | self.faults.check("create_platform_credit_reversal")?; | |
| 384 | 420 | self.reversals.lock().unwrap().push(MockReversal { | |
| 385 | 421 | transfer_id: transfer_id.to_string(), | |
| 386 | 422 | amount_cents, | |
| @@ -396,6 +432,7 @@ | |||
| 396 | 432 | _email: &str, | |
| 397 | 433 | _app_name: &str, | |
| 398 | 434 | ) -> Result<String> { | |
| 435 | + | self.faults.check("create_synckit_customer")?; | |
| 399 | 436 | // Deterministic dummy; tests assert on shape, not content. | |
| 400 | 437 | Ok("cus_test_synckit".to_string()) | |
| 401 | 438 | } | |
| @@ -407,6 +444,7 @@ | |||
| 407 | 444 | _app_name: &str, | |
| 408 | 445 | _price_cents: i64, | |
| 409 | 446 | ) -> Result<makenotwork::payments::SynckitSubResult> { | |
| 447 | + | self.faults.check("create_synckit_subscription")?; | |
| 410 | 448 | let now = SystemTime::now() | |
| 411 | 449 | .duration_since(UNIX_EPOCH) | |
| 412 | 450 | .unwrap() | |
| @@ -424,6 +462,7 @@ | |||
| 424 | 462 | _new_price_cents: i64, | |
| 425 | 463 | _app_name: &str, | |
| 426 | 464 | ) -> Result<()> { | |
| 465 | + | self.faults.check("update_synckit_subscription_price")?; | |
| 427 | 466 | Ok(()) | |
| 428 | 467 | } | |
| 429 | 468 | ||
| @@ -434,6 +473,7 @@ | |||
| 434 | 473 | _interval: makenotwork::payments::SyncBillingInterval, | |
| 435 | 474 | _product_name: &str, | |
| 436 | 475 | ) -> Result<()> { | |
| 476 | + | self.faults.check("update_synckit_app_sub_price")?; | |
| 437 | 477 | Ok(()) | |
| 438 | 478 | } | |
| 439 | 479 | ||
| @@ -441,10 +481,13 @@ | |||
| 441 | 481 | &self, | |
| 442 | 482 | _params: &makenotwork::payments::SynckitAppSubCheckoutParams<'_>, | |
| 443 | 483 | ) -> Result<CheckoutResult> { | |
| 484 | + | self.faults | |
| 485 | + | .check("create_synckit_app_sub_checkout_session")?; | |
| 444 | 486 | Ok(self.next_session()) | |
| 445 | 487 | } | |
| 446 | 488 | ||
| 447 | 489 | async fn cancel_synckit_subscription(&self, _subscription_id: &str) -> Result<()> { | |
| 490 | + | self.faults.check("cancel_synckit_subscription")?; | |
| 448 | 491 | Ok(()) | |
| 449 | 492 | } | |
| 450 | 493 | ||
| @@ -453,6 +496,7 @@ | |||
| 453 | 496 | _customer_id: &str, | |
| 454 | 497 | return_url: &str, | |
| 455 | 498 | ) -> Result<String> { | |
| 499 | + | self.faults.check("create_synckit_billing_portal")?; | |
| 456 | 500 | Ok(format!( | |
| 457 | 501 | "https://billing.stripe.test/portal?return={}", | |
| 458 | 502 | urlencoding::encode(return_url) |
| @@ -50,6 +50,7 @@ | |||
| 50 | 50 | mod embeds; | |
| 51 | 51 | mod enum_drift; | |
| 52 | 52 | mod exports; | |
| 53 | + | mod failure_paths; | |
| 53 | 54 | mod fan_plus; | |
| 54 | 55 | mod fingerprinting; | |
| 55 | 56 | mod follows; |
| @@ -1,0 +1,230 @@ | |||
| 1 | + | //! Injectable failure policy for the test mocks. | |
| 2 | + | //! | |
| 3 | + | //! The mocks were infallible: `InMemoryStorage` errored only on genuine domain | |
| 4 | + | //! conditions (object missing, bad part number), and the payment and email | |
| 5 | + | //! mocks had no error path at all. Meanwhile the server carries real failure | |
| 6 | + | //! machinery, `db/pending_refunds.rs`, `db/pending_uploads.rs`, | |
| 7 | + | //! `db/scan_jobs.rs` retry, `storage.rs` retry, and the scheduler's S3 deletion | |
| 8 | + | //! drain. None of it was reachable from a test, which reads as handled while | |
| 9 | + | //! being unobserved. | |
| 10 | + | //! | |
| 11 | + | //! A mock holds one `Faults` and calls `check()` at the top of each trait | |
| 12 | + | //! method. A test with no policy installed sees exactly the old behaviour, so | |
| 13 | + | //! this is additive and the existing suites are untouched. | |
| 14 | + | //! | |
| 15 | + | //! Method and rationale: wiki `testing-posture`, the "absent oracle" section. | |
| 16 | + | ||
| 17 | + | use makenotwork::error::{AppError, Result}; | |
| 18 | + | use std::collections::HashMap; | |
| 19 | + | use std::sync::Mutex; | |
| 20 | + | ||
| 21 | + | /// When a rule fires. Call numbers are 1-based. | |
| 22 | + | #[derive(Debug, Clone, Copy)] | |
| 23 | + | enum Trigger { | |
| 24 | + | /// Every call. A backend that is simply down. | |
| 25 | + | Always, | |
| 26 | + | /// The Nth call only. Isolates one step of a multi-call flow, so a test can | |
| 27 | + | /// say which call failed rather than that something did. | |
| 28 | + | Nth(u32), | |
| 29 | + | /// Calls 1 through N-1, so the Nth call is the first to succeed. This is the | |
| 30 | + | /// shape the retry paths exist to serve: down, then recovered. | |
| 31 | + | Until(u32), | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /// Builds the error to return. `AppError` is not `Clone` (it wraps `sqlx::Error` | |
| 35 | + | /// and `anyhow::Error`), so a rule stores a factory rather than a value. | |
| 36 | + | type ErrorFactory = Box<dyn Fn() -> AppError + Send + Sync>; | |
| 37 | + | ||
| 38 | + | struct Rule { | |
| 39 | + | trigger: Trigger, | |
| 40 | + | error: ErrorFactory, | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /// Failure policy for one mock. | |
| 44 | + | /// | |
| 45 | + | /// Operations are named by the trait method they guard, so a rule reads as the | |
| 46 | + | /// call it breaks. An unregistered operation never fails. | |
| 47 | + | #[derive(Default)] | |
| 48 | + | pub(crate) struct Faults { | |
| 49 | + | rules: Mutex<HashMap<&'static str, Rule>>, | |
| 50 | + | calls: Mutex<HashMap<&'static str, u32>>, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | #[allow(dead_code)] | |
| 54 | + | impl Faults { | |
| 55 | + | pub(crate) fn new() -> Self { | |
| 56 | + | Self::default() | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// Fail every call to `op`. | |
| 60 | + | pub(crate) fn fail_always( | |
| 61 | + | &self, | |
| 62 | + | op: &'static str, | |
| 63 | + | error: impl Fn() -> AppError + Send + Sync + 'static, | |
| 64 | + | ) { | |
| 65 | + | self.install(op, Trigger::Always, error); | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /// Fail the `n`th call to `op` and no other. | |
| 69 | + | pub(crate) fn fail_nth( | |
| 70 | + | &self, | |
| 71 | + | op: &'static str, | |
| 72 | + | n: u32, | |
| 73 | + | error: impl Fn() -> AppError + Send + Sync + 'static, | |
| 74 | + | ) { | |
| 75 | + | self.install(op, Trigger::Nth(n), error); | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// Fail calls to `op` until the `n`th, which is the first to succeed. | |
| 79 | + | /// | |
| 80 | + | /// `fail_until(op, 3, ..)` fails calls 1 and 2. A retry path with two | |
| 81 | + | /// retries budgeted therefore succeeds, and one with fewer does not, which | |
| 82 | + | /// is the assertion worth making. | |
| 83 | + | pub(crate) fn fail_until( | |
| 84 | + | &self, | |
| 85 | + | op: &'static str, | |
| 86 | + | n: u32, | |
| 87 | + | error: impl Fn() -> AppError + Send + Sync + 'static, | |
| 88 | + | ) { | |
| 89 | + | self.install(op, Trigger::Until(n), error); | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | fn install( | |
| 93 | + | &self, | |
| 94 | + | op: &'static str, | |
| 95 | + | trigger: Trigger, | |
| 96 | + | error: impl Fn() -> AppError + Send + Sync + 'static, | |
| 97 | + | ) { | |
| 98 | + | self.rules.lock().unwrap().insert( | |
| 99 | + | op, | |
| 100 | + | Rule { | |
| 101 | + | trigger, | |
| 102 | + | error: Box::new(error), | |
| 103 | + | }, | |
| 104 | + | ); | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /// Drop the rule for `op`, leaving its call count intact. | |
| 108 | + | pub(crate) fn clear(&self, op: &'static str) { | |
| 109 | + | self.rules.lock().unwrap().remove(op); | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /// Drop every rule. Call counts survive, so a test can install a policy, | |
| 113 | + | /// clear it, and still assert how many times the failing call was made. | |
| 114 | + | pub(crate) fn clear_all(&self) { | |
| 115 | + | self.rules.lock().unwrap().clear(); | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// How many times `op` has been called, whether it failed or not. This is | |
| 119 | + | /// how a test counts retries. | |
| 120 | + | pub(crate) fn calls(&self, op: &str) -> u32 { | |
| 121 | + | self.calls.lock().unwrap().get(op).copied().unwrap_or(0) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /// Reset every call count. | |
| 125 | + | pub(crate) fn reset_calls(&self) { | |
| 126 | + | self.calls.lock().unwrap().clear(); | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /// Record a call to `op` and return its configured error, if this call is | |
| 130 | + | /// one the policy fails. Mocks call this at the top of each trait method. | |
| 131 | + | pub(crate) fn check(&self, op: &'static str) -> Result<()> { | |
| 132 | + | let n = { | |
| 133 | + | let mut calls = self.calls.lock().unwrap(); | |
| 134 | + | let entry = calls.entry(op).or_insert(0); | |
| 135 | + | *entry += 1; | |
| 136 | + | *entry | |
| 137 | + | }; | |
| 138 | + | ||
| 139 | + | let rules = self.rules.lock().unwrap(); | |
| 140 | + | let Some(rule) = rules.get(op) else { | |
| 141 | + | return Ok(()); | |
| 142 | + | }; | |
| 143 | + | ||
| 144 | + | let fires = match rule.trigger { | |
| 145 | + | Trigger::Always => true, | |
| 146 | + | Trigger::Nth(target) => n == target, | |
| 147 | + | Trigger::Until(target) => n < target, | |
| 148 | + | }; | |
| 149 | + | ||
| 150 | + | if fires { Err((rule.error)()) } else { Ok(()) } | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | /// The error a storage backend returns when the service is unreachable, as | |
| 155 | + | /// opposed to the object being absent. Retry paths key on this distinction. | |
| 156 | + | #[allow(dead_code)] | |
| 157 | + | pub(crate) fn storage_unavailable() -> AppError { | |
| 158 | + | AppError::Storage("test-injected: S3 unavailable".to_string()) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | /// The error the payment provider returns when Stripe is unreachable. | |
| 162 | + | #[allow(dead_code)] | |
| 163 | + | pub(crate) fn stripe_unavailable() -> AppError { | |
| 164 | + | AppError::ServiceUnavailable("test-injected: Stripe unavailable".to_string()) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// The error the email transport returns when the sending service rejects. | |
| 168 | + | #[allow(dead_code)] | |
| 169 | + | pub(crate) fn email_unavailable() -> AppError { | |
| 170 | + | AppError::ServiceUnavailable("test-injected: email transport unavailable".to_string()) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | #[cfg(test)] | |
| 174 | + | mod tests { | |
| 175 | + | use super::*; | |
| 176 | + | ||
| 177 | + | #[test] | |
| 178 | + | fn no_rule_never_fails() { | |
| 179 | + | let faults = Faults::new(); | |
| 180 | + | assert!(faults.check("op").is_ok()); | |
| 181 | + | assert!(faults.check("op").is_ok()); | |
| 182 | + | assert_eq!(faults.calls("op"), 2); | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | #[test] | |
| 186 | + | fn always_fails_every_call() { | |
| 187 | + | let faults = Faults::new(); | |
| 188 | + | faults.fail_always("op", storage_unavailable); | |
| 189 | + | assert!(faults.check("op").is_err()); | |
| 190 | + | assert!(faults.check("op").is_err()); | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | #[test] | |
| 194 | + | fn nth_fails_only_that_call() { | |
| 195 | + | let faults = Faults::new(); | |
| 196 | + | faults.fail_nth("op", 2, storage_unavailable); | |
| 197 | + | assert!(faults.check("op").is_ok(), "call 1 succeeds"); | |
| 198 | + | assert!(faults.check("op").is_err(), "call 2 fails"); | |
| 199 | + | assert!(faults.check("op").is_ok(), "call 3 succeeds"); | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | #[test] | |
| 203 | + | fn until_fails_up_to_that_call() { | |
| 204 | + | let faults = Faults::new(); | |
| 205 | + | faults.fail_until("op", 3, storage_unavailable); | |
| 206 | + | assert!(faults.check("op").is_err(), "call 1 fails"); | |
| 207 | + | assert!(faults.check("op").is_err(), "call 2 fails"); | |
| 208 | + | assert!(faults.check("op").is_ok(), "call 3 is the first to succeed"); | |
| 209 | + | assert!(faults.check("op").is_ok()); | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | #[test] | |
| 213 | + | fn rules_are_scoped_to_one_operation() { | |
| 214 | + | let faults = Faults::new(); | |
| 215 | + | faults.fail_always("broken", storage_unavailable); | |
| 216 | + | assert!(faults.check("broken").is_err()); | |
| 217 | + | assert!(faults.check("fine").is_ok()); | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | #[test] | |
| 221 | + | fn counts_survive_clearing_the_rule() { | |
| 222 | + | let faults = Faults::new(); | |
| 223 | + | faults.fail_always("op", storage_unavailable); | |
| 224 | + | let _ = faults.check("op"); | |
| 225 | + | let _ = faults.check("op"); | |
| 226 | + | faults.clear_all(); | |
| 227 | + | assert!(faults.check("op").is_ok()); | |
| 228 | + | assert_eq!(faults.calls("op"), 3, "counts every call, failed or not"); | |
| 229 | + | } | |
| 230 | + | } |
| @@ -1,0 +1,401 @@ | |||
| 1 | + | //! Negative paths: what the server does when a dependency fails. | |
| 2 | + | //! | |
| 3 | + | //! These tests exist because the mocks used to be infallible, so the retry and | |
| 4 | + | //! compensation machinery the server carries had no test that could reach it. | |
| 5 | + | //! Retry logic no test can enter is worse than none, because it reads as | |
| 6 | + | //! handled. Each test here installs a failure policy on a mock (see | |
| 7 | + | //! `harness::faults`) and asserts the compensating behaviour, not just that the | |
| 8 | + | //! request failed. | |
| 9 | + | //! | |
| 10 | + | //! Rationale: wiki `testing-posture`, the "absent oracle" section. | |
| 11 | + | ||
| 12 | + | use crate::harness::TestHarness; | |
| 13 | + | use crate::harness::faults::{email_unavailable, storage_unavailable, stripe_unavailable}; | |
| 14 | + | use makenotwork::db; | |
| 15 | + | use makenotwork::storage::StorageBackend; | |
| 16 | + | use serde_json::Value; | |
| 17 | + | use std::collections::HashMap; | |
| 18 | + | ||
| 19 | + | // The durable S3 deletion queue | |
| 20 | + | ||
| 21 | + | /// Count rows still queued for deletion of `key`. | |
| 22 | + | async fn queued_deletions(h: &TestHarness, key: &str) -> i64 { | |
| 23 | + | sqlx::query_scalar("SELECT COUNT(*) FROM pending_s3_deletions WHERE s3_key = $1") | |
| 24 | + | .bind(key) | |
| 25 | + | .fetch_one(&h.db) | |
| 26 | + | .await | |
| 27 | + | .unwrap() | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /// A delete that fails must leave the row queued. Dequeuing it would orphan the | |
| 31 | + | /// S3 object with no durable record, which is the leak the queue exists to | |
| 32 | + | /// prevent. | |
| 33 | + | #[tokio::test] | |
| 34 | + | async fn s3_delete_failure_keeps_the_row_queued_for_retry() { | |
| 35 | + | let h = TestHarness::with_storage().await; | |
| 36 | + | let storage = h.storage.clone().expect("with_storage provides a backend"); | |
| 37 | + | let key = "test/orphan-retry.bin"; | |
| 38 | + | ||
| 39 | + | storage.put(key, b"payload".to_vec()); | |
| 40 | + | db::pending_s3_deletions::enqueue_deletions( | |
| 41 | + | &h.db, | |
| 42 | + | &[(key.to_string(), "main".to_string())], | |
| 43 | + | "test_failure_path", | |
| 44 | + | ) | |
| 45 | + | .await | |
| 46 | + | .unwrap(); | |
| 47 | + | assert_eq!(queued_deletions(&h, key).await, 1, "row starts queued"); | |
| 48 | + | ||
| 49 | + | storage | |
| 50 | + | .faults() | |
| 51 | + | .fail_always("delete_object", storage_unavailable); | |
| 52 | + | let deleted = h.drain_s3_deletions().await; | |
| 53 | + | ||
| 54 | + | assert_eq!(deleted, 0, "a failing backend deletes nothing"); | |
| 55 | + | assert_eq!( | |
| 56 | + | queued_deletions(&h, key).await, | |
| 57 | + | 1, | |
| 58 | + | "the row must survive a failed delete, dropping it would orphan the object" | |
| 59 | + | ); | |
| 60 | + | assert!( | |
| 61 | + | storage.object_exists(key).await.unwrap(), | |
| 62 | + | "the object is still there, which is why the row must be" | |
| 63 | + | ); | |
| 64 | + | assert_eq!( | |
| 65 | + | storage.faults().calls("delete_object"), | |
| 66 | + | 1, | |
| 67 | + | "the drain attempted the delete exactly once" | |
| 68 | + | ); | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /// The point of keeping the row: a later drain finishes the job. This is the | |
| 72 | + | /// whole contract of the durable queue and nothing asserted it before. | |
| 73 | + | #[tokio::test] | |
| 74 | + | async fn s3_delete_queue_recovers_when_the_backend_comes_back() { | |
| 75 | + | let h = TestHarness::with_storage().await; | |
| 76 | + | let storage = h.storage.clone().expect("with_storage provides a backend"); | |
| 77 | + | let key = "test/orphan-recovers.bin"; | |
| 78 | + | ||
| 79 | + | storage.put(key, b"payload".to_vec()); | |
| 80 | + | db::pending_s3_deletions::enqueue_deletions( | |
| 81 | + | &h.db, | |
| 82 | + | &[(key.to_string(), "main".to_string())], | |
| 83 | + | "test_failure_path", | |
| 84 | + | ) | |
| 85 | + | .await | |
| 86 | + | .unwrap(); | |
| 87 | + | ||
| 88 | + | // Down for the first attempt, up for the second. | |
| 89 | + | storage | |
| 90 | + | .faults() | |
| 91 | + | .fail_until("delete_object", 2, storage_unavailable); | |
| 92 | + | ||
| 93 | + | assert_eq!(h.drain_s3_deletions().await, 0, "first drain fails"); | |
| 94 | + | assert_eq!(queued_deletions(&h, key).await, 1, "still queued"); | |
| 95 | + | ||
| 96 | + | assert_eq!(h.drain_s3_deletions().await, 1, "second drain succeeds"); | |
| 97 | + | assert_eq!( | |
| 98 | + | queued_deletions(&h, key).await, | |
| 99 | + | 0, | |
| 100 | + | "a completed delete is dequeued" | |
| 101 | + | ); | |
| 102 | + | assert!( | |
| 103 | + | !storage.object_exists(key).await.unwrap(), | |
| 104 | + | "the object is gone" | |
| 105 | + | ); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // Checkout compensation when Stripe is down | |
| 109 | + | ||
| 110 | + | /// Create a creator with Stripe connected and a published paid item, logged in | |
| 111 | + | /// as the creator afterwards. Mirrors the helper in `promo_codes_checkout`. | |
| 112 | + | async fn setup_paid_item(h: &mut TestHarness, price_cents: i32) -> (db::UserId, String) { | |
| 113 | + | let seller_id = h.signup("fpseller", "fpseller@test.com", "pass1234").await; | |
| 114 | + | h.grant_creator(seller_id).await; | |
| 115 | + | ||
| 116 | + | sqlx::query("UPDATE users SET stripe_account_id = 'acct_mock_fpseller', stripe_charges_enabled = true WHERE id = $1") | |
| 117 | + | .bind(seller_id) | |
| 118 | + | .execute(&h.db) | |
| 119 | + | .await | |
| 120 | + | .unwrap(); | |
| 121 | + | ||
| 122 | + | h.client.post_form("/logout", "").await; | |
| 123 | + | h.login("fpseller", "pass1234").await; | |
| 124 | + | ||
| 125 | + | let resp = h | |
| 126 | + | .client | |
| 127 | + | .post_form("/api/projects", "slug=fpshop&title=FP+Shop") | |
| 128 | + | .await; | |
| 129 | + | let project: Value = resp.json(); | |
| 130 | + | let project_id = project["id"].as_str().unwrap().to_string(); | |
| 131 | + | ||
| 132 | + | let resp = h | |
| 133 | + | .client | |
| 134 | + | .post_form( | |
| 135 | + | &format!("/api/projects/{project_id}/items"), | |
| 136 | + | &format!("title=FP+Track&price_cents={price_cents}&item_type=audio"), | |
| 137 | + | ) | |
| 138 | + | .await; | |
| 139 | + | let item: Value = resp.json(); | |
| 140 | + | let item_id = item["id"].as_str().unwrap().to_string(); | |
| 141 | + | ||
| 142 | + | h.client | |
| 143 | + | .put_form(&format!("/api/projects/{project_id}"), "is_public=true") | |
| 144 | + | .await; | |
| 145 | + | h.client | |
| 146 | + | .put_form(&format!("/api/items/{item_id}"), "is_public=true") | |
| 147 | + | .await; | |
| 148 | + | ||
| 149 | + | (seller_id, item_id) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | /// A promo code is reserved (its `use_count` incremented) before the Stripe | |
| 153 | + | /// call, so a Stripe failure has to release it. Without that, every outage | |
| 154 | + | /// burns uses off a creator's code and the last buyers are told it is exhausted | |
| 155 | + | /// when it never was. `routes/stripe/checkout/item.rs` compensates for this and | |
| 156 | + | /// no test could reach the branch. | |
| 157 | + | #[tokio::test] | |
| 158 | + | async fn stripe_failure_at_checkout_releases_the_promo_reservation() { | |
| 159 | + | let mut h = TestHarness::with_mocks().await; | |
| 160 | + | let (seller_id, item_id) = setup_paid_item(&mut h, 1000).await; | |
| 161 | + | ||
| 162 | + | sqlx::query( | |
| 163 | + | "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, max_uses) \ | |
| 164 | + | VALUES ($1, 'OUTAGE', 'discount', 'percentage', 25, 0, 5)", | |
| 165 | + | ) | |
| 166 | + | .bind(seller_id) | |
| 167 | + | .execute(&h.db) | |
| 168 | + | .await | |
| 169 | + | .unwrap(); | |
| 170 | + | ||
| 171 | + | h.client.post_form("/logout", "").await; | |
| 172 | + | let _buyer_id = h.signup("fpbuyer", "fpbuyer@test.com", "pass1234").await; | |
| 173 | + | ||
| 174 | + | h.mock_stripe | |
| 175 | + | .as_ref() | |
| 176 | + | .expect("with_mocks provides a payment provider") | |
| 177 | + | .faults() | |
| 178 | + | .fail_always("create_checkout_session", stripe_unavailable); | |
| 179 | + | ||
| 180 | + | let resp = h | |
| 181 | + | .client | |
| 182 | + | .post_form( | |
| 183 | + | &format!("/stripe/checkout/{item_id}"), | |
| 184 | + | "share_contact=false&promo_code=OUTAGE", | |
| 185 | + | ) | |
| 186 | + | .await; | |
| 187 | + | assert_eq!( | |
| 188 | + | resp.status.as_u16(), | |
| 189 | + | 500, | |
| 190 | + | "a Stripe outage is not the buyer's fault, got {}", | |
| 191 | + | resp.status | |
| 192 | + | ); | |
| 193 | + | ||
| 194 | + | let use_count: i32 = sqlx::query_scalar( | |
| 195 | + | "SELECT use_count FROM promo_codes WHERE creator_id = $1 AND upper(code) = 'OUTAGE'", | |
| 196 | + | ) | |
| 197 | + | .bind(seller_id) | |
| 198 | + | .fetch_one(&h.db) | |
| 199 | + | .await | |
| 200 | + | .unwrap(); | |
| 201 | + | assert_eq!( | |
| 202 | + | use_count, 0, | |
| 203 | + | "the reservation must be released when Stripe fails, or an outage burns the code" | |
| 204 | + | ); | |
| 205 | + | ||
| 206 | + | let pending: i64 = sqlx::query_scalar( | |
| 207 | + | "SELECT COUNT(*) FROM transactions WHERE item_id = $1::uuid AND status = 'pending'", | |
| 208 | + | ) | |
| 209 | + | .bind(&item_id) | |
| 210 | + | .fetch_one(&h.db) | |
| 211 | + | .await | |
| 212 | + | .unwrap(); | |
| 213 | + | assert_eq!( | |
| 214 | + | pending, 0, | |
| 215 | + | "no session means no transaction, a pending row here would block the buyer's retry" | |
| 216 | + | ); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | /// The recovery half: once Stripe is back, the same buyer and the same code go | |
| 220 | + | /// through. This is what proves the release above actually restored the code | |
| 221 | + | /// rather than merely decrementing a counter. | |
| 222 | + | #[tokio::test] | |
| 223 | + | async fn checkout_succeeds_on_retry_after_a_stripe_outage() { | |
| 224 | + | let mut h = TestHarness::with_mocks().await; | |
| 225 | + | let (seller_id, item_id) = setup_paid_item(&mut h, 1000).await; | |
| 226 | + | ||
| 227 | + | sqlx::query( | |
| 228 | + | "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, max_uses) \ | |
| 229 | + | VALUES ($1, 'RETRY', 'discount', 'percentage', 25, 0, 1)", | |
| 230 | + | ) | |
| 231 | + | .bind(seller_id) | |
| 232 | + | .execute(&h.db) | |
| 233 | + | .await | |
| 234 | + | .unwrap(); | |
| 235 | + | ||
| 236 | + | h.client.post_form("/logout", "").await; | |
| 237 | + | let _buyer_id = h.signup("fpbuyer2", "fpbuyer2@test.com", "pass1234").await; | |
| 238 | + | ||
| 239 | + | let stripe = h | |
| 240 | + | .mock_stripe | |
| 241 | + | .clone() | |
| 242 | + | .expect("with_mocks provides a payment provider"); | |
| 243 | + | ||
| 244 | + | // max_uses is 1, so a burned reservation makes the retry below impossible. | |
| 245 | + | stripe | |
| 246 | + | .faults() | |
| 247 | + | .fail_until("create_checkout_session", 2, stripe_unavailable); | |
| 248 | + | ||
| 249 | + | let failed = h | |
| 250 | + | .client | |
| 251 | + | .post_form( | |
| 252 | + | &format!("/stripe/checkout/{item_id}"), | |
| 253 | + | "share_contact=false&promo_code=RETRY", | |
| 254 | + | ) | |
| 255 | + | .await; | |
| 256 | + | assert_eq!(failed.status.as_u16(), 500, "first attempt fails"); | |
| 257 | + | ||
| 258 | + | let resp = h | |
| 259 | + | .client | |
| 260 | + | .post_form( | |
| 261 | + | &format!("/stripe/checkout/{item_id}"), | |
| 262 | + | "share_contact=false&promo_code=RETRY", | |
| 263 | + | ) | |
| 264 | + | .await; | |
| 265 | + | assert_eq!( | |
| 266 | + | resp.status.as_u16(), | |
| 267 | + | 303, | |
| 268 | + | "second attempt redirects to the Stripe session, got {} {}", | |
| 269 | + | resp.status, | |
| 270 | + | resp.text | |
| 271 | + | ); | |
| 272 | + | ||
| 273 | + | let amount: i32 = sqlx::query_scalar( | |
| 274 | + | "SELECT amount_cents FROM transactions WHERE item_id = $1::uuid AND status = 'pending'", | |
| 275 | + | ) | |
| 276 | + | .bind(&item_id) | |
| 277 | + | .fetch_one(&h.db) | |
| 278 | + | .await | |
| 279 | + | .unwrap(); | |
| 280 | + | assert_eq!(amount, 750, "the discount still applied on the retry"); | |
| 281 | + | ||
| 282 | + | assert_eq!( | |
| 283 | + | stripe.faults().calls("create_checkout_session"), | |
| 284 | + | 2, | |
| 285 | + | "exactly two Stripe attempts, the route does not retry internally" | |
| 286 | + | ); | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | // Email is best-effort, and has to actually be best-effort | |
| 290 | + | ||
| 291 | + | /// A failed buyer receipt must not swallow the seller's sale notification. The | |
| 292 | + | /// two sends are guarded separately in `checkout_helpers.rs` precisely so one | |
| 293 | + | /// bad address or one transport hiccup does not take out the other, and with an | |
| 294 | + | /// infallible transport nothing checked that they really are independent. | |
| 295 | + | /// | |
| 296 | + | /// The purchase itself is settled before either email is queued, so this also | |
| 297 | + | /// asserts the money outcome is untouched by an email outage. | |
| 298 | + | #[tokio::test] | |
| 299 | + | async fn a_failed_buyer_receipt_still_notifies_the_seller() { | |
| 300 | + | let mut h = TestHarness::with_mocks().await; | |
| 301 | + | let (seller_id, item_id) = setup_paid_item(&mut h, 500).await; | |
| 302 | + | ||
| 303 | + | sqlx::query("UPDATE users SET notify_sale = true WHERE id = $1") | |
| 304 | + | .bind(seller_id) | |
| 305 | + | .execute(&h.db) | |
| 306 | + | .await | |
| 307 | + | .unwrap(); | |
| 308 | + | ||
| 309 | + | h.client.post_form("/logout", "").await; | |
| 310 | + | let buyer_id = h.signup("fpbuyer3", "fpbuyer3@test.com", "pass1234").await; | |
| 311 | + | ||
| 312 | + | let session_id = "cs_failure_path_email"; | |
| 313 | + | sqlx::query( | |
| 314 | + | r"INSERT INTO transactions | |
| 315 | + | (buyer_id, seller_id, item_id, amount_cents, status, | |
| 316 | + | stripe_checkout_session_id, item_title, seller_username) | |
| 317 | + | VALUES ($1, $2, $3::uuid, 500, 'pending', $4, 'FP Track', 'fpseller')", | |
| 318 | + | ) | |
| 319 | + | .bind(buyer_id) | |
| 320 | + | .bind(seller_id) | |
| 321 | + | .bind(&item_id) | |
| 322 | + | .bind(session_id) | |
| 323 | + | .execute(&h.db) | |
| 324 | + | .await | |
| 325 | + | .unwrap(); | |
| 326 | + | ||
| 327 | + | // Signup already sent this buyer mail, so clear the log and the call counts | |
| 328 | + | // before arming the policy. After this the only sends are the webhook's two, | |
| 329 | + | // and the buyer receipt is call 1. | |
| 330 | + | let email = h | |
| 331 | + | .mock_email | |
| 332 | + | .clone() | |
| 333 | + | .expect("with_mocks provides an email transport"); | |
| 334 | + | email.clear(); | |
| 335 | + | email.faults().reset_calls(); | |
| 336 | + | email.faults().fail_nth("send_email", 1, email_unavailable); | |
| 337 | + | ||
| 338 | + | let mut meta = HashMap::new(); | |
| 339 | + | meta.insert("buyer_id".to_string(), buyer_id.to_string()); | |
| 340 | + | meta.insert("seller_id".to_string(), seller_id.to_string()); | |
| 341 | + | meta.insert("item_id".to_string(), item_id.clone()); | |
| 342 | + | let session = serde_json::json!({ | |
| 343 | + | "id": session_id, | |
| 344 | + | "object": "checkout_session", | |
| 345 | + | "mode": "payment", | |
| 346 | + | "metadata": meta, | |
| 347 | + | "payment_intent": "pi_failure_path_email", | |
| 348 | + | }); | |
| 349 | + | ||
| 350 | + | let payload = serde_json::json!({ | |
| 351 | + | "id": "evt_failure_path_email", | |
| 352 | + | "type": "checkout.session.completed", | |
| 353 | + | "data": { "object": session }, | |
| 354 | + | }) | |
| 355 | + | .to_string(); | |
| 356 | + | let signature = crate::harness::stripe::sign_webhook_payload( | |
| 357 | + | &payload, | |
| 358 | + | crate::harness::stripe::TEST_WEBHOOK_SECRET, | |
| 359 | + | ); | |
| 360 | + | let resp = h | |
| 361 | + | .client | |
| 362 | + | .request_with_headers( | |
| 363 | + | "POST", | |
| 364 | + | "/stripe/webhook", | |
| 365 | + | Some(&payload), | |
| 366 | + | &[ | |
| 367 | + | ("stripe-signature", &signature), | |
| 368 | + | ("content-type", "application/json"), | |
| 369 | + | ], | |
| 370 | + | ) | |
| 371 | + | .await; | |
| 372 | + | assert_eq!(resp.status.as_u16(), 200, "webhook accepted: {}", resp.text); | |
| 373 | + | ||
| 374 | + | // Fire-and-forget email tasks, same wait the sibling email test uses. | |
| 375 | + | tokio::time::sleep(std::time::Duration::from_millis(200)).await; | |
| 376 | + | ||
| 377 | + | assert_eq!( | |
| 378 | + | email.sent_to("fpbuyer3@test.com").len(), | |
| 379 | + | 0, | |
| 380 | + | "the buyer receipt was the injected failure" | |
| 381 | + | ); | |
| 382 | + | let seller_emails = email.sent_to("fpseller@test.com"); | |
| 383 | + | assert!( | |
| 384 | + | seller_emails | |
| 385 | + | .iter() | |
| 386 | + | .any(|e| e.subject.to_lowercase().contains("sale")), | |
| 387 | + | "the sale notification must still go out, got: {:?}", | |
| 388 | + | seller_emails.iter().map(|e| &e.subject).collect::<Vec<_>>() | |
| 389 | + | ); | |
| 390 | + | ||
| 391 | + | let status: String = | |
| 392 | + | sqlx::query_scalar("SELECT status FROM transactions WHERE stripe_checkout_session_id = $1") | |
| 393 | + | .bind(session_id) | |
| 394 | + | .fetch_one(&h.db) | |
| 395 | + | .await | |
| 396 | + | .unwrap(); | |
| 397 | + | assert_eq!( | |
| 398 | + | status, "completed", | |
| 399 | + | "an email outage must not touch the purchase" | |
| 400 | + | ); | |
| 401 | + | } |