Skip to main content

max / makenotwork

12.5 KB · 392 lines History Blame Raw
1 //! DB-layer contract tests for `db::fan_plus`, the Fan+ membership store.
2 //!
3 //! Every function in the module is `pub(crate)`, so it is driven here through
4 //! the Stripe webhook endpoint that is its only production caller, and the
5 //! assertions read the `fan_plus_subscriptions` rows the module writes. What is
6 //! pinned is the part Stripe redelivery can corrupt: a Fan+ checkout creates
7 //! exactly one subscription however many times it is delivered, a fresh
8 //! checkout revives a canceled membership rather than opening a second row, a
9 //! status update writes status, period and the cancel-at-period-end flag
10 //! together, and a repeated cancellation keeps the first `canceled_at` instead
11 //! of sliding it forward.
12 //!
13 //! Existing coverage of the canceled-is-terminal guard lives in
14 //! `stripe_webhooks.rs` and is not repeated here.
15 //!
16 //! Delete this file and nothing checks that a redelivered Fan+ webhook writes
17 //! one row, which is the failure Stripe's at-least-once delivery produces.
18
19 use crate::harness::TestHarness;
20 use crate::harness::stripe::{TEST_WEBHOOK_SECRET, sign_webhook_payload};
21 use makenotwork::db::UserId;
22 // ── db::fan_plus, driven through its only production caller ──
23
24 /// POST a signed Stripe event to the webhook endpoint.
25 async fn post_event(
26 h: &mut TestHarness,
27 event_id: &str,
28 event_type: &str,
29 object: serde_json::Value,
30 ) -> crate::harness::client::TestResponse {
31 let payload = serde_json::json!({
32 "id": event_id,
33 "type": event_type,
34 "data": {"object": object},
35 })
36 .to_string();
37 let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET);
38 h.client
39 .request_with_headers(
40 "POST",
41 "/stripe/webhook",
42 Some(&payload),
43 &[
44 ("stripe-signature", &signature),
45 ("content-type", "application/json"),
46 ],
47 )
48 .await
49 }
50
51 /// A completed Fan+ checkout session. `checkout_type=fan_plus` is what routes
52 /// it to the Fan+ handler; `user_id` is who gets the membership.
53 fn fan_plus_session(session_id: &str, user_id: UserId, sub_id: &str) -> serde_json::Value {
54 serde_json::json!({
55 "id": session_id,
56 "object": "checkout.session",
57 "mode": "subscription",
58 "metadata": {"checkout_type": "fan_plus", "user_id": user_id.to_string()},
59 "subscription": sub_id,
60 "customer": format!("cus_{sub_id}"),
61 "payment_status": "no_payment_required",
62 })
63 }
64
65 /// A `customer.subscription.*` object carrying a period on its first item.
66 fn subscription_object(
67 sub_id: &str,
68 status: &str,
69 cancel_at_period_end: bool,
70 period: (i64, i64),
71 ) -> serde_json::Value {
72 serde_json::json!({
73 "id": sub_id,
74 "object": "subscription",
75 "status": status,
76 "cancel_at_period_end": cancel_at_period_end,
77 "items": {"object": "list", "data": [{
78 "id": "si_fp_layer",
79 "current_period_start": period.0,
80 "current_period_end": period.1,
81 }]},
82 })
83 }
84
85 async fn fan_plus_row_count(pool: &sqlx::PgPool, user_id: UserId) -> i64 {
86 sqlx::query_scalar("SELECT COUNT(*) FROM fan_plus_subscriptions WHERE user_id = $1")
87 .bind(user_id)
88 .fetch_one(pool)
89 .await
90 .expect("count fan plus rows")
91 }
92
93 #[tokio::test]
94 async fn fan_plus_checkout_creates_exactly_one_subscription_under_redelivery() {
95 let mut h = TestHarness::with_stripe().await;
96 let user_id = h
97 .signup("fplayer_new", "fplayer_new@test.com", "password123")
98 .await;
99
100 let session = fan_plus_session("cs_fp_layer_1", user_id, "sub_fp_layer_1");
101 let resp = post_event(
102 &mut h,
103 "evt_fp_layer_create",
104 "checkout.session.completed",
105 session.clone(),
106 )
107 .await;
108 assert_eq!(
109 resp.status.as_u16(),
110 200,
111 "Fan+ checkout webhook failed: {}",
112 resp.text
113 );
114 assert_eq!(
115 fan_plus_row_count(&h.db, user_id).await,
116 1,
117 "one checkout creates one membership"
118 );
119
120 // Stripe's own retry: identical event id. The dedup layer must swallow it.
121 let resp = post_event(
122 &mut h,
123 "evt_fp_layer_create",
124 "checkout.session.completed",
125 session.clone(),
126 )
127 .await;
128 assert_eq!(
129 resp.status.as_u16(),
130 200,
131 "retried delivery must be accepted, not errored: {}",
132 resp.text
133 );
134
135 // A redelivery that escapes the event-id dedup (distinct id, same session)
136 // reaches `create_fan_plus_subscription`, whose ON CONFLICT (user_id) is the
137 // backstop: still one row, still the same Stripe ids.
138 let resp = post_event(
139 &mut h,
140 "evt_fp_layer_create_dup",
141 "checkout.session.completed",
142 session,
143 )
144 .await;
145 assert_eq!(
146 resp.status.as_u16(),
147 200,
148 "duplicate Fan+ checkout must not error: {}",
149 resp.text
150 );
151 assert_eq!(
152 fan_plus_row_count(&h.db, user_id).await,
153 1,
154 "a redelivered Fan+ checkout must never mint a second membership"
155 );
156
157 let (sub_id, customer_id, status): (String, String, String) = sqlx::query_as(
158 "SELECT stripe_subscription_id, stripe_customer_id, status \
159 FROM fan_plus_subscriptions WHERE user_id = $1",
160 )
161 .bind(user_id)
162 .fetch_one(&h.db)
163 .await
164 .unwrap();
165 assert_eq!(sub_id, "sub_fp_layer_1");
166 assert_eq!(customer_id, "cus_sub_fp_layer_1");
167 assert_eq!(status, "active", "a new membership starts active");
168 }
169
170 #[tokio::test]
171 async fn a_new_fan_plus_checkout_reactivates_the_canceled_membership_row() {
172 let mut h = TestHarness::with_stripe().await;
173 let user_id = h
174 .signup("fplayer_re", "fplayer_re@test.com", "password123")
175 .await;
176
177 // A previously canceled membership. The unique constraint is on user_id, so
178 // re-subscribing has to reuse this row rather than insert beside it.
179 sqlx::query(
180 "INSERT INTO fan_plus_subscriptions \
181 (user_id, stripe_subscription_id, stripe_customer_id, status, canceled_at) \
182 VALUES ($1, 'sub_fp_layer_old', 'cus_fp_layer_old', 'canceled', NOW())",
183 )
184 .bind(user_id)
185 .execute(&h.db)
186 .await
187 .unwrap();
188
189 let resp = post_event(
190 &mut h,
191 "evt_fp_layer_resub",
192 "checkout.session.completed",
193 fan_plus_session("cs_fp_layer_re", user_id, "sub_fp_layer_new"),
194 )
195 .await;
196 assert_eq!(
197 resp.status.as_u16(),
198 200,
199 "re-subscribe webhook failed: {}",
200 resp.text
201 );
202
203 assert_eq!(
204 fan_plus_row_count(&h.db, user_id).await,
205 1,
206 "re-subscribing updates the existing row rather than adding one"
207 );
208 let (status, sub_id, canceled_at): (String, String, Option<chrono::DateTime<chrono::Utc>>) =
209 sqlx::query_as(
210 "SELECT status, stripe_subscription_id, canceled_at \
211 FROM fan_plus_subscriptions WHERE user_id = $1",
212 )
213 .bind(user_id)
214 .fetch_one(&h.db)
215 .await
216 .unwrap();
217 assert_eq!(status, "active", "checkout is the reactivation path");
218 assert_eq!(
219 sub_id, "sub_fp_layer_new",
220 "the row now points at the new Stripe subscription"
221 );
222 assert_eq!(
223 canceled_at, None,
224 "reactivation clears the old cancellation stamp"
225 );
226 }
227
228 #[tokio::test]
229 async fn a_stripe_update_writes_fan_plus_status_period_and_cancel_flag_together() {
230 let mut h = TestHarness::with_stripe().await;
231 let user_id = h
232 .signup("fplayer_upd", "fplayer_upd@test.com", "password123")
233 .await;
234 let sub_id = "sub_fp_layer_upd";
235 sqlx::query(
236 "INSERT INTO fan_plus_subscriptions \
237 (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end) \
238 VALUES ($1, $2, 'cus_fp_layer_upd', 'active', to_timestamp(1600000000))",
239 )
240 .bind(user_id)
241 .bind(sub_id)
242 .execute(&h.db)
243 .await
244 .unwrap();
245
246 // Payment fell behind and the fan scheduled a cancellation: one event
247 // carries status, period and the flag, and all three must land.
248 let resp = post_event(
249 &mut h,
250 "evt_fp_layer_upd",
251 "customer.subscription.updated",
252 subscription_object(sub_id, "past_due", true, (1_700_000_000, 1_702_592_000)),
253 )
254 .await;
255 assert_eq!(
256 resp.status.as_u16(),
257 200,
258 "update webhook failed: {}",
259 resp.text
260 );
261
262 let (status, period_end, cancel_pending): (String, i64, bool) = sqlx::query_as(
263 "SELECT status, EXTRACT(EPOCH FROM current_period_end)::BIGINT, cancel_at_period_end \
264 FROM fan_plus_subscriptions WHERE stripe_subscription_id = $1",
265 )
266 .bind(sub_id)
267 .fetch_one(&h.db)
268 .await
269 .unwrap();
270 assert_eq!(status, "past_due", "Stripe's status is written through");
271 assert_eq!(
272 period_end, 1_702_592_000,
273 "the period advances to the one Stripe sent, not the seeded 1600000000"
274 );
275 assert!(
276 cancel_pending,
277 "cancel_at_period_end tracks Stripe, which owns that flag"
278 );
279
280 // And back: resuming in Stripe's portal clears the flag here.
281 let resp = post_event(
282 &mut h,
283 "evt_fp_layer_upd_resume",
284 "customer.subscription.updated",
285 subscription_object(sub_id, "active", false, (1_700_000_000, 1_702_592_000)),
286 )
287 .await;
288 assert_eq!(
289 resp.status.as_u16(),
290 200,
291 "resume update failed: {}",
292 resp.text
293 );
294 let (status, cancel_pending): (String, bool) = sqlx::query_as(
295 "SELECT status, cancel_at_period_end FROM fan_plus_subscriptions \
296 WHERE stripe_subscription_id = $1",
297 )
298 .bind(sub_id)
299 .fetch_one(&h.db)
300 .await
301 .unwrap();
302 assert_eq!(
303 status, "active",
304 "a recovered payment restores the membership"
305 );
306 assert!(!cancel_pending, "the scheduled cancellation is cleared");
307 }
308
309 #[tokio::test]
310 async fn a_repeated_cancellation_keeps_the_first_canceled_at() {
311 let mut h = TestHarness::with_stripe().await;
312 let user_id = h
313 .signup("fplayer_del", "fplayer_del@test.com", "password123")
314 .await;
315 let sub_id = "sub_fp_layer_del";
316 sqlx::query(
317 "INSERT INTO fan_plus_subscriptions \
318 (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end) \
319 VALUES ($1, $2, 'cus_fp_layer_del', 'active', to_timestamp(1702592000))",
320 )
321 .bind(user_id)
322 .bind(sub_id)
323 .execute(&h.db)
324 .await
325 .unwrap();
326
327 let resp = post_event(
328 &mut h,
329 "evt_fp_layer_del",
330 "customer.subscription.deleted",
331 subscription_object(sub_id, "canceled", false, (1_700_000_000, 1_702_592_000)),
332 )
333 .await;
334 assert_eq!(
335 resp.status.as_u16(),
336 200,
337 "cancellation webhook failed: {}",
338 resp.text
339 );
340
341 let (status, first_canceled_at): (String, Option<chrono::DateTime<chrono::Utc>>) =
342 sqlx::query_as(
343 "SELECT status, canceled_at FROM fan_plus_subscriptions \
344 WHERE stripe_subscription_id = $1",
345 )
346 .bind(sub_id)
347 .fetch_one(&h.db)
348 .await
349 .unwrap();
350 assert_eq!(status, "canceled");
351 let first_canceled_at = first_canceled_at.expect("cancellation stamps canceled_at");
352
353 // A second delivery of the cancellation (distinct event id, so it reaches
354 // the handler) must be a no-op on the stamp: `COALESCE(canceled_at, NOW())`
355 // is what stops the cancellation date sliding forward on every redelivery,
356 // and that date is what the fan's remaining access window is judged from.
357 let resp = post_event(
358 &mut h,
359 "evt_fp_layer_del_again",
360 "customer.subscription.deleted",
361 subscription_object(sub_id, "canceled", false, (1_700_000_000, 1_702_592_000)),
362 )
363 .await;
364 assert_eq!(
365 resp.status.as_u16(),
366 200,
367 "repeat cancellation must not error: {}",
368 resp.text
369 );
370
371 let (status, second_canceled_at): (String, Option<chrono::DateTime<chrono::Utc>>) =
372 sqlx::query_as(
373 "SELECT status, canceled_at FROM fan_plus_subscriptions \
374 WHERE stripe_subscription_id = $1",
375 )
376 .bind(sub_id)
377 .fetch_one(&h.db)
378 .await
379 .unwrap();
380 assert_eq!(status, "canceled", "still canceled");
381 assert_eq!(
382 second_canceled_at,
383 Some(first_canceled_at),
384 "a redelivered cancellation keeps the original canceled_at"
385 );
386 assert_eq!(
387 fan_plus_row_count(&h.db, user_id).await,
388 1,
389 "cancellation never adds rows"
390 );
391 }
392