Skip to main content

max / makenotwork

12.1 KB · 378 lines History Blame Raw
1 //! Scripted payment providers, for tests that need one that behaves on cue.
2 //!
3 //! `pub(crate)` rather than private: `payments/fan_ops.rs` builds a
4 //! `ScriptedProvider` too, and a fixture two modules share is a module, not a
5 //! copy in each.
6
7 //! A crate-visible [`PaymentProvider`] double for lib tests.
8 //!
9 //! The integration suite already has `MockPaymentProvider`
10 //! (`tests/harness/stripe.rs`), which is richer: it captures checkout
11 //! sessions and signs webhooks. It lives in a separate test binary, so a
12 //! `--lib` test cannot reach it, and this is deliberately the smaller
13 //! thing. It answers the subscription-lifecycle calls and panics on
14 //! everything else, which is enough to test the code that fans those out
15 //! without a database, a router or a Stripe key.
16 //!
17 //! Implement a method here when a lib test needs it. Growing this toward
18 //! the harness's copy would give the crate two mocks to keep in agreement,
19 //! which is the imitation-oracle failure wiki `testing-posture` describes.
20
21 use std::collections::HashSet;
22 use std::sync::Mutex;
23
24 use super::*;
25
26 /// Records every subscription op it is asked for, and fails the ones whose
27 /// subscription id was listed as failing.
28 #[derive(Default)]
29 pub(crate) struct ScriptedProvider {
30 failing: HashSet<String>,
31 calls: Mutex<Vec<(&'static str, String)>>,
32 }
33
34 impl ScriptedProvider {
35 /// Every call succeeds.
36 pub(crate) fn healthy() -> Self {
37 Self::default()
38 }
39
40 /// Every call succeeds except those naming one of `sub_ids`.
41 pub(crate) fn failing(sub_ids: impl IntoIterator<Item = &'static str>) -> Self {
42 Self {
43 failing: sub_ids.into_iter().map(str::to_owned).collect(),
44 calls: Mutex::new(Vec::new()),
45 }
46 }
47
48 /// `(op, subscription id)` in the order they were applied.
49 pub(crate) fn calls(&self) -> Vec<(&'static str, String)> {
50 self.calls
51 .lock()
52 .expect("no test panics while holding this")
53 .clone()
54 }
55
56 fn record(&self, op: &'static str, sub_id: &str) -> crate::error::Result<()> {
57 self.calls
58 .lock()
59 .expect("no test panics while holding this")
60 .push((op, sub_id.to_string()));
61 if self.failing.contains(sub_id) {
62 return Err(crate::error::AppError::BadRequest(format!(
63 "scripted failure for {sub_id}"
64 )));
65 }
66 Ok(())
67 }
68 }
69
70 /// The methods no lib test drives yet. A call is a bug in the test, not a
71 /// condition to handle, so it panics rather than returning an error the
72 /// code under test would quietly count as a Stripe failure.
73 macro_rules! unused {
74 ($($name:ident),+ $(,)?) => {
75 $(
76 #[allow(unused_variables)]
77 fn $name(&self) -> ! {
78 unimplemented!(
79 "ScriptedProvider::{} is not implemented; add it if a lib test needs it",
80 stringify!($name)
81 )
82 }
83 )+
84 };
85 }
86
87 impl ScriptedProvider {
88 unused!(
89 create_checkout_session,
90 create_guest_checkout_session,
91 create_subscription_checkout_session,
92 create_tip_checkout_session,
93 create_fan_plus_checkout_session,
94 create_creator_tier_checkout_session,
95 create_synckit_app_sub_checkout_session,
96 create_cart_checkout_session,
97 create_connect_account,
98 create_account_link,
99 fetch_account,
100 create_subscription_product_and_price,
101 get_balance,
102 cancel_platform_subscription,
103 set_platform_cancel_at_period_end,
104 create_billing_portal_session,
105 create_refund_for_transaction,
106 create_platform_credit_transfer,
107 create_platform_credit_reversal,
108 verify_webhook,
109 verify_webhook_v2,
110 normalize_webhook,
111 create_synckit_customer,
112 create_synckit_subscription,
113 update_synckit_subscription_price,
114 update_synckit_app_sub_price,
115 cancel_synckit_subscription,
116 create_synckit_billing_portal,
117 );
118 }
119
120 #[async_trait::async_trait]
121 impl PaymentProvider for ScriptedProvider {
122 // ── what the fan-out drives ──
123
124 async fn pause_subscription(&self, sub: &str, _account: &str) -> crate::error::Result<()> {
125 self.record("pause", sub)
126 }
127
128 async fn resume_subscription(&self, sub: &str, _account: &str) -> crate::error::Result<()> {
129 self.record("resume", sub)
130 }
131
132 async fn cancel_subscription(&self, sub: &str, _account: &str) -> crate::error::Result<()> {
133 self.record("cancel", sub)
134 }
135
136 async fn set_cancel_at_period_end(
137 &self,
138 sub: &str,
139 _account: &str,
140 cancel: bool,
141 ) -> crate::error::Result<()> {
142 self.record(
143 if cancel {
144 "set_cancel_at_period_end"
145 } else {
146 "clear_cancel_at_period_end"
147 },
148 sub,
149 )
150 }
151
152 // ── everything else ──
153
154 async fn create_checkout_session(
155 &self,
156 _params: &CheckoutParams<'_>,
157 ) -> crate::error::Result<CheckoutResult> {
158 ScriptedProvider::create_checkout_session(self)
159 }
160 async fn create_guest_checkout_session(
161 &self,
162 _params: &GuestCheckoutParams<'_>,
163 ) -> crate::error::Result<CheckoutResult> {
164 ScriptedProvider::create_guest_checkout_session(self)
165 }
166 async fn create_subscription_checkout_session(
167 &self,
168 _params: &SubscriptionCheckoutParams<'_>,
169 ) -> crate::error::Result<CheckoutResult> {
170 ScriptedProvider::create_subscription_checkout_session(self)
171 }
172 async fn create_tip_checkout_session(
173 &self,
174 _params: &TipCheckoutParams<'_>,
175 ) -> crate::error::Result<CheckoutResult> {
176 ScriptedProvider::create_tip_checkout_session(self)
177 }
178 async fn create_fan_plus_checkout_session(
179 &self,
180 _price_id: &str,
181 _user_id: crate::db::UserId,
182 _success_url: &str,
183 _cancel_url: &str,
184 ) -> crate::error::Result<CheckoutResult> {
185 ScriptedProvider::create_fan_plus_checkout_session(self)
186 }
187 async fn create_creator_tier_checkout_session(
188 &self,
189 _price_id: &str,
190 _user_id: crate::db::UserId,
191 _tier: &str,
192 _success_url: &str,
193 _cancel_url: &str,
194 _trial_days: Option<i32>,
195 ) -> crate::error::Result<CheckoutResult> {
196 ScriptedProvider::create_creator_tier_checkout_session(self)
197 }
198 async fn create_synckit_app_sub_checkout_session(
199 &self,
200 _params: &SynckitAppSubCheckoutParams<'_>,
201 ) -> crate::error::Result<CheckoutResult> {
202 ScriptedProvider::create_synckit_app_sub_checkout_session(self)
203 }
204 async fn create_cart_checkout_session(
205 &self,
206 _params: &CartCheckoutParams<'_>,
207 ) -> crate::error::Result<CheckoutResult> {
208 ScriptedProvider::create_cart_checkout_session(self)
209 }
210 async fn create_connect_account(
211 &self,
212 _email: &str,
213 ) -> crate::error::Result<ProviderAccountId> {
214 ScriptedProvider::create_connect_account(self)
215 }
216 async fn get_balance(
217 &self,
218 _account_id: &str,
219 _currency: crate::currency::SettlementCurrency,
220 ) -> crate::error::Result<BalanceSummary> {
221 ScriptedProvider::get_balance(self)
222 }
223 async fn cancel_platform_subscription(&self, _sub: &str) -> crate::error::Result<()> {
224 ScriptedProvider::cancel_platform_subscription(self)
225 }
226 async fn set_platform_cancel_at_period_end(
227 &self,
228 _sub: &str,
229 _cancel: bool,
230 ) -> crate::error::Result<()> {
231 ScriptedProvider::set_platform_cancel_at_period_end(self)
232 }
233 fn verify_webhook(
234 &self,
235 _payload: &str,
236 _signature: &str,
237 ) -> crate::error::Result<UntypedEvent> {
238 ScriptedProvider::verify_webhook(self)
239 }
240 fn verify_webhook_v2(
241 &self,
242 _payload: &str,
243 _signature: &str,
244 ) -> crate::error::Result<serde_json::Value> {
245 ScriptedProvider::verify_webhook_v2(self)
246 }
247 fn normalize_webhook(&self, _event: UntypedEvent) -> crate::error::Result<MnwEvent> {
248 ScriptedProvider::normalize_webhook(self)
249 }
250 async fn update_synckit_subscription_price(
251 &self,
252 _subscription_id: &str,
253 _new_price_cents: i64,
254 _app_name: &str,
255 ) -> crate::error::Result<()> {
256 ScriptedProvider::update_synckit_subscription_price(self)
257 }
258 async fn update_synckit_app_sub_price(
259 &self,
260 _subscription_id: &str,
261 _new_price_cents: i64,
262 _interval: SyncBillingInterval,
263 _product_name: &str,
264 ) -> crate::error::Result<()> {
265 ScriptedProvider::update_synckit_app_sub_price(self)
266 }
267 async fn cancel_synckit_subscription(&self, _sub: &str) -> crate::error::Result<()> {
268 ScriptedProvider::cancel_synckit_subscription(self)
269 }
270 }
271
272 // ── The capability extensions, every one of them a panic: no lib test
273 // drives an extension yet, and `ScriptedProvider` implements them so the
274 // double stays wirable wherever a full provider is expected.
275
276 #[async_trait::async_trait]
277 impl HostedPortal for ScriptedProvider {
278 async fn create_billing_portal_session(
279 &self,
280 _customer_id: &str,
281 _return_url: &str,
282 ) -> crate::error::Result<String> {
283 ScriptedProvider::create_billing_portal_session(self)
284 }
285 async fn create_synckit_billing_portal(
286 &self,
287 _customer_id: &str,
288 _return_url: &str,
289 ) -> crate::error::Result<String> {
290 ScriptedProvider::create_synckit_billing_portal(self)
291 }
292 }
293
294 #[async_trait::async_trait]
295 impl ConnectOnboarding for ScriptedProvider {
296 async fn create_account_link(
297 &self,
298 _account_id: &str,
299 _return_url: &str,
300 _refresh_url: &str,
301 ) -> crate::error::Result<String> {
302 ScriptedProvider::create_account_link(self)
303 }
304 async fn fetch_account(&self, _account_id: &str) -> crate::error::Result<AccountUpdate> {
305 ScriptedProvider::fetch_account(self)
306 }
307 }
308
309 #[async_trait::async_trait]
310 impl Catalogue for ScriptedProvider {
311 async fn create_subscription_product_and_price(
312 &self,
313 _connected_account_id: &str,
314 _tier_name: &str,
315 _tier_description: Option<&str>,
316 _price_cents: i64,
317 _currency: crate::currency::SettlementCurrency,
318 ) -> crate::error::Result<(String, String)> {
319 ScriptedProvider::create_subscription_product_and_price(self)
320 }
321 }
322
323 #[async_trait::async_trait]
324 impl Refundable for ScriptedProvider {
325 async fn create_refund_for_transaction(
326 &self,
327 _payment_intent_id: &str,
328 _connected_account_id: &str,
329 _amount_cents: i64,
330 _transaction_id: crate::db::TransactionId,
331 ) -> crate::error::Result<()> {
332 ScriptedProvider::create_refund_for_transaction(self)
333 }
334 }
335
336 #[async_trait::async_trait]
337 impl PlatformTransfers for ScriptedProvider {
338 async fn create_platform_credit_transfer(
339 &self,
340 _connected_account_id: &str,
341 _amount_cents: i64,
342 _transaction_id: crate::db::TransactionId,
343 _currency: crate::currency::SettlementCurrency,
344 ) -> crate::error::Result<String> {
345 ScriptedProvider::create_platform_credit_transfer(self)
346 }
347 async fn create_platform_credit_reversal(
348 &self,
349 _transfer_id: &str,
350 _amount_cents: i64,
351 _transaction_id: crate::db::TransactionId,
352 ) -> crate::error::Result<()> {
353 ScriptedProvider::create_platform_credit_reversal(self)
354 }
355 }
356
357 #[async_trait::async_trait]
358 impl CustodialCustomers for ScriptedProvider {
359 async fn create_synckit_customer(
360 &self,
361 _developer_user_id: crate::db::UserId,
362 _app_id: crate::db::SyncAppId,
363 _email: &str,
364 _app_name: &str,
365 ) -> crate::error::Result<String> {
366 ScriptedProvider::create_synckit_customer(self)
367 }
368 async fn create_synckit_subscription(
369 &self,
370 _customer_id: &str,
371 _app_id: crate::db::SyncAppId,
372 _app_name: &str,
373 _price_cents: i64,
374 ) -> crate::error::Result<SynckitSubResult> {
375 ScriptedProvider::create_synckit_subscription(self)
376 }
377 }
378