Skip to main content

max / makenotwork

15.3 KB · 506 lines History Blame Raw
1 //! DB-layer contract tests for the webhook-driven lifecycle writes in
2 //! `db::subscriptions`.
3 //!
4 //! These are the writes Stripe drives, and Stripe redelivers, so what is pinned
5 //! is what a second delivery must not do:
6 //!
7 //! - `create_subscription`'s single-live-row cleanup cancels exactly the
8 //! lingering `past_due`/`trialing`/`incomplete` rows of the SAME subscriber
9 //! and project, and nobody else's,
10 //! - `cancel_subscription` under redelivery keeps the first `canceled_at`
11 //! rather than restamping it, which is what `COALESCE(canceled_at, NOW())`
12 //! is for,
13 //! - `apply_stripe_update`'s period funnel writes a zero-length Stripe window
14 //! and drops an inverted one while the status still lands.
15 //!
16 //! The access gate of the same module is `db_subscriptions_layer`.
17 //!
18 //! Reach: every function in `db::subscriptions` is `pub(crate)`, so these drive
19 //! the Stripe webhook route, the module's own production caller, and assert on
20 //! the rows the functions leave behind.
21 //!
22 //! Delete this file and a redelivered cancellation could slide the cancellation
23 //! date forward, and the cleanup's WHERE could widen to another fan's rows,
24 //! with nothing at this layer noticing.
25
26 use crate::harness::stripe::{TEST_WEBHOOK_SECRET, sign_webhook_payload};
27 use crate::harness::{TestHarness, seed_project, seed_user};
28 use chrono::{DateTime, Duration, Utc};
29 use makenotwork::db::{ProjectId, SubscriptionTierId, UserId};
30 use serde_json::Value;
31 use sqlx::PgPool;
32 use std::collections::HashMap;
33
34 // ── seeding ──
35
36 async fn seed_project_tier(pool: &PgPool, project: ProjectId) -> SubscriptionTierId {
37 sqlx::query_scalar::<_, SubscriptionTierId>(
38 "INSERT INTO subscription_tiers (project_id, name, price_cents)
39 VALUES ($1, 'Project Tier', 1500) RETURNING id",
40 )
41 .bind(project)
42 .fetch_one(pool)
43 .await
44 .expect("seed project tier")
45 }
46
47 /// A project-scoped subscription row (`item_id` NULL).
48 async fn seed_project_subscription(
49 pool: &PgPool,
50 subscriber: UserId,
51 tier: SubscriptionTierId,
52 project: ProjectId,
53 stripe_id: &str,
54 status: &str,
55 period_end: Option<DateTime<Utc>>,
56 ) {
57 sqlx::query(
58 "INSERT INTO subscriptions
59 (subscriber_id, tier_id, project_id, stripe_subscription_id, stripe_customer_id,
60 status, current_period_start, current_period_end)
61 VALUES ($1, $2, $3, $4, 'cus_gate_seed', $5, NOW() - interval '1 day', $6)",
62 )
63 .bind(subscriber)
64 .bind(tier)
65 .bind(project)
66 .bind(stripe_id)
67 .bind(status)
68 .bind(period_end)
69 .execute(pool)
70 .await
71 .expect("seed project subscription");
72 }
73
74 /// Read one subscription row's status, canceled_at and period as text-free values.
75 async fn read_row(
76 pool: &PgPool,
77 stripe_id: &str,
78 ) -> (
79 String,
80 Option<DateTime<Utc>>,
81 Option<DateTime<Utc>>,
82 Option<DateTime<Utc>>,
83 ) {
84 sqlx::query_as(
85 "SELECT status, canceled_at, current_period_start, current_period_end
86 FROM subscriptions WHERE stripe_subscription_id = $1",
87 )
88 .bind(stripe_id)
89 .fetch_one(pool)
90 .await
91 .unwrap_or_else(|e| panic!("read subscription {stripe_id}: {e}"))
92 }
93
94 async fn status_of(pool: &PgPool, stripe_id: &str) -> String {
95 read_row(pool, stripe_id).await.0
96 }
97 // ── webhook-driven lifecycle ──
98
99 /// Sign a Stripe event and POST it to the webhook endpoint.
100 async fn post_event(
101 h: &mut TestHarness,
102 event_id: &str,
103 event_type: &str,
104 object: Value,
105 ) -> crate::harness::client::TestResponse {
106 let payload = serde_json::json!({
107 "id": event_id,
108 "type": event_type,
109 "data": {"object": object},
110 })
111 .to_string();
112 let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET);
113 h.client
114 .request_with_headers(
115 "POST",
116 "/stripe/webhook",
117 Some(&payload),
118 &[
119 ("stripe-signature", &signature),
120 ("content-type", "application/json"),
121 ],
122 )
123 .await
124 }
125
126 /// A `customer.subscription.updated` / `.deleted` object with one item carrying
127 /// the given raw Stripe period.
128 fn subscription_object(stripe_sub_id: &str, status: &str, period: Option<(i64, i64)>) -> Value {
129 let items = match period {
130 Some((start, end)) => serde_json::json!([{
131 "id": "si_dbsl",
132 "object": "subscription_item",
133 "subscription": stripe_sub_id,
134 "current_period_start": start,
135 "current_period_end": end,
136 "metadata": {},
137 }]),
138 None => serde_json::json!([]),
139 };
140 serde_json::json!({
141 "id": stripe_sub_id,
142 "object": "subscription",
143 "status": status,
144 "cancel_at_period_end": false,
145 "items": {"object": "list", "data": items},
146 })
147 }
148
149 /// Creator with a project and a tier, plus a fan. Returns
150 /// `(fan_id, project_id, tier_id)`.
151 async fn webhook_fixture(
152 h: &mut TestHarness,
153 tag: &str,
154 ) -> (UserId, ProjectId, SubscriptionTierId) {
155 let creator = h
156 .signup(
157 &format!("wcreator_{tag}"),
158 &format!("wcreator_{tag}@test.com"),
159 "password123",
160 )
161 .await;
162 h.grant_creator(creator).await;
163 h.client.post_form("/logout", "").await;
164 h.login(&format!("wcreator_{tag}"), "password123").await;
165
166 let resp = h
167 .client
168 .post_form(
169 "/api/projects",
170 &format!("slug=whook-{tag}&title=Webhook+{tag}"),
171 )
172 .await;
173 assert_eq!(resp.status, 200, "create project failed: {}", resp.text);
174 let project: Value = resp.json();
175 let project_uuid: ProjectId = project["id"]
176 .as_str()
177 .expect("project id")
178 .parse()
179 .expect("project id parses");
180 let tier = seed_project_tier(&h.db, project_uuid).await;
181
182 h.client.post_form("/logout", "").await;
183 let fan = h
184 .signup(
185 &format!("wfan_{tag}"),
186 &format!("wfan_{tag}@test.com"),
187 "password123",
188 )
189 .await;
190 h.client.post_form("/logout", "").await;
191
192 (fan, project_uuid, tier)
193 }
194
195 #[tokio::test]
196 async fn a_new_subscription_cancels_only_the_same_fans_lingering_rows_for_that_project() {
197 let mut h = TestHarness::with_stripe().await;
198 let (fan, project, tier) = webhook_fixture(&mut h, "cleanup").await;
199
200 // A second project and a second fan, so the cleanup's WHERE has something
201 // to get wrong in each direction. Who owns the second project is irrelevant
202 // here; only the (subscriber, project) pair is.
203 let other_project = seed_project(&h.db, fan, "cleanup-other").await;
204 let other_tier = seed_project_tier(&h.db, other_project).await;
205 let other_fan = seed_user(&h.db, "cleanup_other_fan").await;
206
207 let period = Some(Utc::now() + Duration::days(5));
208 // Cleaned up: this fan, this project, a status in the cleanup set.
209 seed_project_subscription(
210 &h.db,
211 fan,
212 tier,
213 project,
214 "sub_stale_past_due",
215 "past_due",
216 period,
217 )
218 .await;
219 seed_project_subscription(
220 &h.db,
221 fan,
222 tier,
223 project,
224 "sub_stale_trialing",
225 "trialing",
226 period,
227 )
228 .await;
229 // Left alone: 'unpaid' is deliberately NOT in the cleanup set, so this row
230 // is what tells "cancel the three named statuses" apart from "cancel
231 // everything that is not active".
232 seed_project_subscription(
233 &h.db,
234 fan,
235 tier,
236 project,
237 "sub_stale_unpaid",
238 "unpaid",
239 period,
240 )
241 .await;
242 // Left alone: another fan, same project.
243 seed_project_subscription(
244 &h.db,
245 other_fan,
246 tier,
247 project,
248 "sub_other_fan",
249 "past_due",
250 period,
251 )
252 .await;
253 // Left alone: same fan, another project.
254 seed_project_subscription(
255 &h.db,
256 fan,
257 other_tier,
258 other_project,
259 "sub_other_project",
260 "past_due",
261 period,
262 )
263 .await;
264
265 let mut meta = HashMap::new();
266 meta.insert("checkout_type".to_string(), "subscription".to_string());
267 meta.insert("subscriber_id".to_string(), fan.to_string());
268 meta.insert("project_id".to_string(), project.to_string());
269 meta.insert("tier_id".to_string(), tier.to_string());
270 let session = serde_json::json!({
271 "id": "cs_dbsl_cleanup",
272 "object": "checkout_session",
273 "mode": "subscription",
274 "metadata": meta,
275 "subscription": "sub_fresh_cleanup",
276 "customer": "cus_fresh_cleanup",
277 });
278
279 let resp = post_event(
280 &mut h,
281 "evt_dbsl_cleanup",
282 "checkout.session.completed",
283 session,
284 )
285 .await;
286 assert_eq!(
287 resp.status.as_u16(),
288 200,
289 "subscription checkout webhook failed: {}",
290 resp.text
291 );
292
293 let (fresh_status, _, _, _) = read_row(&h.db, "sub_fresh_cleanup").await;
294 assert_eq!(
295 fresh_status, "active",
296 "the new subscription is created active"
297 );
298
299 for stale in ["sub_stale_past_due", "sub_stale_trialing"] {
300 let (status, canceled_at, _, _) = read_row(&h.db, stale).await;
301 assert_eq!(
302 status, "canceled",
303 "{stale} is a lingering live row for the resubscribing fan and must be canceled"
304 );
305 assert!(
306 canceled_at.is_some(),
307 "{stale} was canceled, so canceled_at must be stamped, got {canceled_at:?}"
308 );
309 }
310
311 assert_eq!(
312 status_of(&h.db, "sub_stale_unpaid").await,
313 "unpaid",
314 "'unpaid' is outside the cleanup set and must survive untouched"
315 );
316 assert_eq!(
317 status_of(&h.db, "sub_other_fan").await,
318 "past_due",
319 "another fan's row on the same project must not be canceled"
320 );
321 assert_eq!(
322 status_of(&h.db, "sub_other_project").await,
323 "past_due",
324 "the same fan's row on a different project must not be canceled"
325 );
326 }
327
328 #[tokio::test]
329 async fn a_redelivered_cancellation_keeps_the_first_cancellation_time() {
330 let mut h = TestHarness::with_stripe().await;
331 let (fan, project, tier) = webhook_fixture(&mut h, "cancel").await;
332
333 // Already canceled, with a known cancellation instant: this is the row a
334 // Stripe redelivery of `customer.subscription.deleted` lands on.
335 let first_cancel = DateTime::parse_from_rfc3339("2026-01-05T06:07:08Z")
336 .expect("fixed timestamp parses")
337 .with_timezone(&Utc);
338 seed_project_subscription(
339 &h.db,
340 fan,
341 tier,
342 project,
343 "sub_cancel_replay",
344 "canceled",
345 Some(Utc::now() - Duration::days(10)),
346 )
347 .await;
348 sqlx::query("UPDATE subscriptions SET canceled_at = $1 WHERE stripe_subscription_id = $2")
349 .bind(first_cancel)
350 .bind("sub_cancel_replay")
351 .execute(&h.db)
352 .await
353 .expect("stamp the original cancellation time");
354
355 // A live row, so the same event type is shown to have an effect at all.
356 seed_project_subscription(
357 &h.db,
358 fan,
359 tier,
360 project,
361 "sub_cancel_fresh",
362 "active",
363 Some(Utc::now() + Duration::days(11)),
364 )
365 .await;
366
367 let resp = post_event(
368 &mut h,
369 "evt_dbsl_cancel_replay",
370 "customer.subscription.deleted",
371 subscription_object(
372 "sub_cancel_replay",
373 "canceled",
374 Some((1_700_000_000, 1_702_592_000)),
375 ),
376 )
377 .await;
378 assert_eq!(
379 resp.status.as_u16(),
380 200,
381 "redelivered cancellation webhook failed: {}",
382 resp.text
383 );
384
385 let (status, canceled_at, _, _) = read_row(&h.db, "sub_cancel_replay").await;
386 assert_eq!(status, "canceled", "the row stays canceled on redelivery");
387 assert_eq!(
388 canceled_at,
389 Some(first_cancel),
390 "COALESCE(canceled_at, NOW()) must keep the FIRST cancellation time; a restamp would \
391 move a fan's end-of-access date forward on every Stripe retry"
392 );
393
394 let before = Utc::now();
395 let resp = post_event(
396 &mut h,
397 "evt_dbsl_cancel_fresh",
398 "customer.subscription.deleted",
399 subscription_object(
400 "sub_cancel_fresh",
401 "canceled",
402 Some((1_700_000_000, 1_702_592_000)),
403 ),
404 )
405 .await;
406 assert_eq!(
407 resp.status.as_u16(),
408 200,
409 "first cancellation webhook failed: {}",
410 resp.text
411 );
412 let (status, canceled_at, _, _) = read_row(&h.db, "sub_cancel_fresh").await;
413 assert_eq!(
414 status, "canceled",
415 "a live row is canceled by the same event"
416 );
417 let canceled_at = canceled_at.expect("a first cancellation stamps canceled_at");
418 assert!(
419 canceled_at >= before,
420 "a row with no prior canceled_at is stamped now, got {canceled_at} (test began {before})"
421 );
422 }
423
424 #[tokio::test]
425 async fn a_zero_length_stripe_period_is_written_and_an_inverted_one_is_dropped() {
426 let mut h = TestHarness::with_stripe().await;
427 let (fan, project, tier) = webhook_fixture(&mut h, "period").await;
428
429 seed_project_subscription(
430 &h.db,
431 fan,
432 tier,
433 project,
434 "sub_period_funnel",
435 "active",
436 Some(Utc::now() + Duration::days(4)),
437 )
438 .await;
439
440 // start == end is the boundary the funnel accepts (`end > 0 && start <= end`).
441 // Picking equal values is what separates `<=` from `<`.
442 let boundary = 1_767_225_600_i64; // 2026-01-01T00:00:00Z
443 let resp = post_event(
444 &mut h,
445 "evt_dbsl_period_equal",
446 "customer.subscription.updated",
447 subscription_object("sub_period_funnel", "active", Some((boundary, boundary))),
448 )
449 .await;
450 assert_eq!(
451 resp.status.as_u16(),
452 200,
453 "zero-length period webhook failed: {}",
454 resp.text
455 );
456
457 let (status, _, start, end) = read_row(&h.db, "sub_period_funnel").await;
458 assert_eq!(status, "active", "the status update lands");
459 assert_eq!(
460 start.map(|t| t.timestamp()),
461 Some(boundary),
462 "a zero-length window is a legal Stripe shape and must be written, got {start:?}"
463 );
464 assert_eq!(
465 end.map(|t| t.timestamp()),
466 Some(boundary),
467 "a zero-length window is a legal Stripe shape and must be written, got {end:?}"
468 );
469
470 // Inverted: end one second BEFORE start. The funnel drops the period, and
471 // COALESCE keeps what is already there, but the status half still applies.
472 let resp = post_event(
473 &mut h,
474 "evt_dbsl_period_inverted",
475 "customer.subscription.updated",
476 subscription_object(
477 "sub_period_funnel",
478 "past_due",
479 Some((boundary + 86_400, boundary + 86_399)),
480 ),
481 )
482 .await;
483 assert_eq!(
484 resp.status.as_u16(),
485 200,
486 "inverted period webhook failed: {}",
487 resp.text
488 );
489
490 let (status, _, start, end) = read_row(&h.db, "sub_period_funnel").await;
491 assert_eq!(
492 status, "past_due",
493 "an inverted period drops only the period; the status still lands"
494 );
495 assert_eq!(
496 start.map(|t| t.timestamp()),
497 Some(boundary),
498 "an inverted window must leave the existing period alone, got {start:?}"
499 );
500 assert_eq!(
501 end.map(|t| t.timestamp()),
502 Some(boundary),
503 "an inverted window must leave the existing period alone, got {end:?}"
504 );
505 }
506