Skip to main content

max / makenotwork

18.0 KB · 532 lines History Blame Raw
1 //! DB-layer contract tests for the money core (`db::transactions`,
2 //! `db::transactions::purchases`).
3 //!
4 //! `db::transactions` is the largest money module and its contracts were asserted
5 //! only indirectly through HTTP/webhook flows. These call
6 //! the `db::` functions directly so the invariants the payment safety leans on,
7 //! completion idempotency (the webhook-dedup backstop), cart completion across
8 //! every line of one session, guest-purchase attachment, single-shot refund
9 //! claiming, free-claim dedup (single and batch), and buyer/seller scoping, are
10 //! pinned at the layer they live in.
11 //!
12 //! Every function under test lives in `purchases.rs`, which the header above
13 //! names so the coverage seal credits it.
14
15 use crate::harness::TestHarness;
16 use makenotwork::db::{
17 self, Cents, ItemId, TransactionStatus, transactions::CreateTransactionParams,
18 };
19
20 /// Create a seller (with an item) and a separate buyer, returning
21 /// `(seller_id, item_id, buyer_id)`.
22 async fn seller_item_buyer(h: &mut TestHarness, tag: &str) -> (db::UserId, ItemId, db::UserId) {
23 let setup = h
24 .create_creator_with_item(&format!("seller_{tag}"), "audio", 1000)
25 .await;
26 let item_id: ItemId = setup.item_id.parse().expect("item id parses");
27 let buyer_id = h
28 .signup(
29 &format!("buyer_{tag}"),
30 &format!("buyer_{tag}@test.com"),
31 "password123",
32 )
33 .await;
34 (setup.user_id, item_id, buyer_id)
35 }
36
37 /// Add another item to an existing project. The creator must be logged in.
38 async fn extra_item(h: &mut TestHarness, project_id: &str, title: &str) -> ItemId {
39 let resp = h
40 .client
41 .post_form(
42 &format!("/api/projects/{project_id}/items"),
43 &format!("title={title}&item_type=audio&price_cents=1000"),
44 )
45 .await;
46 assert_eq!(resp.status, 200, "create extra item failed: {}", resp.text);
47 let item: serde_json::Value = resp.json();
48 item["id"]
49 .as_str()
50 .unwrap()
51 .parse()
52 .expect("item id parses")
53 }
54
55 fn tx_params(
56 buyer_id: db::UserId,
57 seller_id: db::UserId,
58 item_id: ItemId,
59 session: &str,
60 ) -> CreateTransactionParams<'_> {
61 CreateTransactionParams {
62 buyer_id: Some(buyer_id),
63 seller_id,
64 item_id: Some(item_id),
65 amount_cents: Cents::new(1000),
66 platform_fee_cents: Cents::ZERO,
67 stripe_checkout_session_id: session,
68 item_title: "Test Item",
69 seller_username: "seller",
70 share_contact: false,
71 project_id: None,
72 promo_code_id: None,
73 guest_email: None,
74 platform_credit_cents: 0,
75 }
76 }
77
78 // ── complete_transaction: the webhook-dedup idempotency backstop ──
79
80 #[tokio::test]
81 async fn complete_transaction_is_idempotent_across_duplicate_webhooks() {
82 let mut h = TestHarness::new().await;
83 let (seller_id, item_id, buyer_id) = seller_item_buyer(&mut h, "cmpl").await;
84 let session = "cs_dbtl_complete_001";
85
86 db::transactions::create_transaction(&h.db, &tx_params(buyer_id, seller_id, item_id, session))
87 .await
88 .expect("create pending transaction");
89
90 // First completion transitions pending -> completed and returns the row.
91 let first = db::transactions::complete_transaction(&h.db, session, Some("pi_dbtl_001"), None)
92 .await
93 .expect("first complete ok");
94 let completed = first.expect("first completion returns the row");
95 assert_eq!(completed.status, TransactionStatus::Completed);
96
97 // A duplicate webhook delivery must be a no-op: the guard is `WHERE status =
98 // 'pending'`, so the second call returns None rather than re-completing.
99 let second = db::transactions::complete_transaction(&h.db, session, Some("pi_dbtl_001"), None)
100 .await
101 .expect("second complete ok");
102 assert!(
103 second.is_none(),
104 "a duplicate completion webhook must be a no-op"
105 );
106 }
107
108 // ── has_purchased_item / transaction_exists: pre/post completion ──
109
110 #[tokio::test]
111 async fn purchase_visibility_flips_only_after_completion() {
112 let mut h = TestHarness::new().await;
113 let (seller_id, item_id, buyer_id) = seller_item_buyer(&mut h, "vis").await;
114 let session = "cs_dbtl_vis_001";
115
116 assert!(
117 !db::transactions::transaction_exists_for_checkout_session(&h.db, session)
118 .await
119 .unwrap(),
120 "no transaction exists before create"
121 );
122
123 db::transactions::create_transaction(&h.db, &tx_params(buyer_id, seller_id, item_id, session))
124 .await
125 .unwrap();
126
127 // The row exists (idempotency guard), but a still-pending purchase does NOT
128 // count as purchased until it completes.
129 assert!(
130 db::transactions::transaction_exists_for_checkout_session(&h.db, session)
131 .await
132 .unwrap()
133 );
134 assert!(
135 !db::transactions::has_purchased_item(&h.db, buyer_id, item_id)
136 .await
137 .unwrap(),
138 "a pending purchase must not read as purchased"
139 );
140
141 db::transactions::complete_transaction(&h.db, session, Some("pi_dbtl_vis"), None)
142 .await
143 .unwrap();
144 assert!(
145 db::transactions::has_purchased_item(&h.db, buyer_id, item_id)
146 .await
147 .unwrap(),
148 "a completed purchase reads as purchased"
149 );
150 }
151
152 // ── claim_transaction_for_refund: single-shot (mirrors pending_refunds) ──
153
154 #[tokio::test]
155 async fn refund_claim_is_single_shot() {
156 let mut h = TestHarness::new().await;
157 let (seller_id, item_id, buyer_id) = seller_item_buyer(&mut h, "rfnd").await;
158 let session = "cs_dbtl_refund_001";
159
160 db::transactions::create_transaction(&h.db, &tx_params(buyer_id, seller_id, item_id, session))
161 .await
162 .unwrap();
163 let completed =
164 db::transactions::complete_transaction(&h.db, session, Some("pi_dbtl_refund"), None)
165 .await
166 .unwrap()
167 .expect("completed row");
168
169 // First claim moves completed -> refunding and returns the id; a second claim
170 // finds no completed row and returns None (no double refund).
171 let first = db::transactions::claim_transaction_for_refund(&h.db, completed.id)
172 .await
173 .unwrap();
174 assert_eq!(
175 first,
176 Some(completed.id),
177 "first refund claim matches the completed row"
178 );
179 let second = db::transactions::claim_transaction_for_refund(&h.db, completed.id)
180 .await
181 .unwrap();
182 assert!(
183 second.is_none(),
184 "a claimed (refunding) transaction can't be claimed again"
185 );
186
187 // Releasing the claim (refund call failed) returns it to completed so a retry
188 // can claim it once more.
189 db::transactions::release_refund_claim(&h.db, completed.id)
190 .await
191 .unwrap();
192 let retried = db::transactions::claim_transaction_for_refund(&h.db, completed.id)
193 .await
194 .unwrap();
195 assert_eq!(
196 retried,
197 Some(completed.id),
198 "a released claim is claimable again"
199 );
200 }
201
202 // ── buyer/seller scoping ──
203
204 #[tokio::test]
205 async fn transactions_are_scoped_to_their_buyer_and_seller() {
206 let mut h = TestHarness::new().await;
207 let (seller_id, item_id, buyer_id) = seller_item_buyer(&mut h, "scope").await;
208 let other_buyer = h
209 .signup("other_buyer", "other_buyer@test.com", "password123")
210 .await;
211 let session = "cs_dbtl_scope_001";
212
213 db::transactions::create_transaction(&h.db, &tx_params(buyer_id, seller_id, item_id, session))
214 .await
215 .unwrap();
216 db::transactions::complete_transaction(&h.db, session, Some("pi_dbtl_scope"), None)
217 .await
218 .unwrap();
219
220 let buyer_txs = db::transactions::get_transactions_by_buyer(&h.db, buyer_id, None)
221 .await
222 .unwrap();
223 assert!(
224 buyer_txs.iter().any(|t| t.item_id == Some(item_id)),
225 "buyer sees their purchase"
226 );
227
228 let other_txs = db::transactions::get_transactions_by_buyer(&h.db, other_buyer, None)
229 .await
230 .unwrap();
231 assert!(other_txs.is_empty(), "an unrelated buyer sees none of it");
232
233 let seller_txs = db::transactions::get_transactions_by_seller(&h.db, seller_id, None)
234 .await
235 .unwrap();
236 assert!(
237 seller_txs.iter().any(|t| t.item_id == Some(item_id)),
238 "seller sees the sale"
239 );
240 }
241
242 // ── claim_free_item: ON CONFLICT dedup (double-claim / double-credit guard) ──
243
244 #[tokio::test]
245 async fn free_claim_cannot_be_double_claimed() {
246 let mut h = TestHarness::new().await;
247 let (seller_id, item_id, buyer_id) = seller_item_buyer(&mut h, "free").await;
248
249 let params = db::transactions::ClaimParams {
250 buyer_id,
251 item_id,
252 seller_id,
253 item_title: "Free Item",
254 seller_username: "seller",
255 share_contact: false,
256 parent_transaction_id: None,
257 platform_credit_cents: 0,
258 };
259
260 let first = db::transactions::claim_free_item(&h.db, &params)
261 .await
262 .unwrap();
263 assert!(first, "first free claim inserts a completed transaction");
264 let second = db::transactions::claim_free_item(&h.db, &params)
265 .await
266 .unwrap();
267 assert!(
268 !second,
269 "a repeat free claim is a no-op (ON CONFLICT), never a second grant"
270 );
271
272 assert!(
273 db::transactions::has_purchased_item(&h.db, buyer_id, item_id)
274 .await
275 .unwrap()
276 );
277 }
278
279 // ── complete_cart_transactions: every line of a cart, once ──
280
281 #[tokio::test]
282 async fn cart_completion_flips_every_line_and_replays_empty() {
283 let mut h = TestHarness::new().await;
284 let setup = h
285 .create_creator_with_item("seller_cart", "audio", 1000)
286 .await;
287 let seller_id = setup.user_id;
288 let item_a: ItemId = setup.item_id.parse().expect("item id parses");
289 let item_b = extra_item(&mut h, &setup.project_id, "Second").await;
290 let item_c = extra_item(&mut h, &setup.project_id, "Third").await;
291 let buyer_id = h
292 .signup("buyer_cart", "buyer_cart@test.com", "password123")
293 .await;
294
295 let cart_session = "cs_dbtl_cart_001";
296 let other_session = "cs_dbtl_cart_other";
297 for item in [item_a, item_b] {
298 db::transactions::create_transaction(
299 &h.db,
300 &tx_params(buyer_id, seller_id, item, cart_session),
301 )
302 .await
303 .expect("create pending cart line");
304 }
305 // A pending line on an unrelated session, to prove the completion is scoped.
306 db::transactions::create_transaction(
307 &h.db,
308 &tx_params(buyer_id, seller_id, item_c, other_session),
309 )
310 .await
311 .unwrap();
312
313 let completed =
314 db::transactions::complete_cart_transactions(&h.db, cart_session, Some("pi_dbtl_cart_001"))
315 .await
316 .expect("cart completion ok");
317 assert_eq!(
318 completed.len(),
319 2,
320 "every pending line of the cart completes"
321 );
322 for tx in &completed {
323 assert_eq!(tx.status, TransactionStatus::Completed);
324 assert_eq!(
325 tx.stripe_payment_intent_id.as_deref(),
326 Some("pi_dbtl_cart_001"),
327 );
328 }
329 assert!(
330 db::transactions::has_purchased_item(&h.db, buyer_id, item_a)
331 .await
332 .unwrap()
333 && db::transactions::has_purchased_item(&h.db, buyer_id, item_b)
334 .await
335 .unwrap(),
336 "both cart lines grant access"
337 );
338 assert!(
339 !db::transactions::has_purchased_item(&h.db, buyer_id, item_c)
340 .await
341 .unwrap(),
342 "a line on another session stays pending"
343 );
344
345 // Replay of the same webhook: the `WHERE status = 'pending'` guard leaves
346 // nothing to flip, so the second delivery returns no rows and grants nothing
347 // a second time.
348 let replay =
349 db::transactions::complete_cart_transactions(&h.db, cart_session, Some("pi_dbtl_cart_001"))
350 .await
351 .expect("cart replay ok");
352 assert!(
353 replay.is_empty(),
354 "a duplicate cart webhook must complete nothing"
355 );
356
357 let completed_rows: i64 = sqlx::query_scalar(
358 "SELECT COUNT(*) FROM transactions \
359 WHERE stripe_checkout_session_id = $1 AND status = 'completed'",
360 )
361 .bind(cart_session)
362 .fetch_one(&h.db)
363 .await
364 .unwrap();
365 assert_eq!(completed_rows, 2, "the replay added no rows");
366 }
367
368 // ── attach_guest_purchases_by_email: one account, once ──
369
370 #[tokio::test]
371 async fn guest_purchases_attach_to_one_account_and_only_once() {
372 let mut h = TestHarness::new().await;
373 let setup = h
374 .create_creator_with_item("seller_guest", "audio", 1000)
375 .await;
376 let seller_id = setup.user_id;
377 let item_a: ItemId = setup.item_id.parse().expect("item id parses");
378 let item_b = extra_item(&mut h, &setup.project_id, "Second").await;
379
380 // Two guest purchases on the same email, each its own checkout session.
381 let guest_email = "guest_attach@test.com";
382 for (session, item) in [("cs_dbtl_guest_a", item_a), ("cs_dbtl_guest_b", item_b)] {
383 let mut params = tx_params(seller_id, seller_id, item, session);
384 params.buyer_id = None;
385 params.guest_email = Some(guest_email);
386 db::transactions::create_transaction(&h.db, &params)
387 .await
388 .expect("create guest pending");
389 db::transactions::complete_guest_transaction(&h.db, session, Some("pi_guest"), guest_email)
390 .await
391 .expect("complete guest")
392 .expect("guest row completed");
393 }
394
395 let claimer = h.signup("guest_claimer", guest_email, "password123").await;
396 let attached = db::transactions::attach_guest_purchases_by_email(&h.db, guest_email, claimer)
397 .await
398 .expect("attach ok");
399 assert_eq!(attached, 2, "both guest purchases attach to the account");
400 assert!(
401 db::transactions::has_purchased_item(&h.db, claimer, item_a)
402 .await
403 .unwrap()
404 && db::transactions::has_purchased_item(&h.db, claimer, item_b)
405 .await
406 .unwrap(),
407 "the attached purchases read as owned"
408 );
409
410 // Re-running attachment finds nothing: the rows now have a buyer, and the
411 // claim token is spent.
412 let again = db::transactions::attach_guest_purchases_by_email(&h.db, guest_email, claimer)
413 .await
414 .unwrap();
415 assert_eq!(
416 again, 0,
417 "an already-claimed guest purchase is not re-attached"
418 );
419
420 // And no second account can take them by naming the same email.
421 let other = h
422 .signup("guest_other", "guest_other@test.com", "password123")
423 .await;
424 let stolen = db::transactions::attach_guest_purchases_by_email(&h.db, guest_email, other)
425 .await
426 .unwrap();
427 assert_eq!(
428 stolen, 0,
429 "a claimed guest purchase attaches to one account only"
430 );
431
432 let (owned, tokens_left): (i64, i64) = sqlx::query_as(
433 "SELECT COUNT(*) FILTER (WHERE buyer_id = $1 AND claimed_by = $1), \
434 COUNT(*) FILTER (WHERE claim_token IS NOT NULL) \
435 FROM transactions WHERE LOWER(guest_email) = $2",
436 )
437 .bind(claimer)
438 .bind(guest_email)
439 .fetch_one(&h.db)
440 .await
441 .unwrap();
442 assert_eq!(owned, 2, "both rows belong to the claiming account");
443 assert_eq!(tokens_left, 0, "attachment spends the claim token");
444 }
445
446 // ── claim_free_items_batch: per-row idempotence on a multi-item grant ──
447
448 #[tokio::test]
449 async fn free_items_batch_claims_each_item_once() {
450 let mut h = TestHarness::new().await;
451 let setup = h.create_creator_with_item("seller_batch", "audio", 0).await;
452 let seller_id = setup.user_id;
453 let item_a: ItemId = setup.item_id.parse().expect("item id parses");
454 let item_b = extra_item(&mut h, &setup.project_id, "Second").await;
455 let buyer_id = h
456 .signup("buyer_batch", "buyer_batch@test.com", "password123")
457 .await;
458
459 let items: Vec<(ItemId, &str)> = vec![(item_a, "First"), (item_b, "Second")];
460 let claimed = db::transactions::claim_free_items_batch(
461 &h.db, buyer_id, seller_id, "seller", None, &items,
462 )
463 .await
464 .expect("batch claim ok");
465 assert_eq!(claimed, 2, "each item in the batch is granted once");
466 assert!(
467 db::transactions::has_purchased_item(&h.db, buyer_id, item_a)
468 .await
469 .unwrap()
470 && db::transactions::has_purchased_item(&h.db, buyer_id, item_b)
471 .await
472 .unwrap(),
473 );
474
475 // Replaying the same batch inserts nothing: the partial unique index
476 // catches every row.
477 let replay = db::transactions::claim_free_items_batch(
478 &h.db, buyer_id, seller_id, "seller", None, &items,
479 )
480 .await
481 .unwrap();
482 assert_eq!(replay, 0, "a repeated batch grant is a no-op");
483
484 // A partly-new batch grants only what is missing.
485 let item_c = {
486 h.client.post_form("/logout", "").await;
487 h.login("seller_batch", "password123").await;
488 let id = extra_item(&mut h, &setup.project_id, "Third").await;
489 h.client.post_form("/logout", "").await;
490 id
491 };
492 let mixed: Vec<(ItemId, &str)> = vec![(item_a, "First"), (item_c, "Third")];
493 let partial = db::transactions::claim_free_items_batch(
494 &h.db, buyer_id, seller_id, "seller", None, &mixed,
495 )
496 .await
497 .unwrap();
498 assert_eq!(partial, 1, "only the unowned item of the batch is granted");
499
500 // The single-claim path shares the conflict target, so it also refuses.
501 let single = db::transactions::claim_free_item(
502 &h.db,
503 &db::transactions::ClaimParams {
504 buyer_id,
505 item_id: item_a,
506 seller_id,
507 item_title: "First",
508 seller_username: "seller",
509 share_contact: false,
510 parent_transaction_id: None,
511 platform_credit_cents: 0,
512 },
513 )
514 .await
515 .unwrap();
516 assert!(
517 !single,
518 "a single claim of a batch-granted item is a no-op too"
519 );
520
521 let empty: Vec<(ItemId, &str)> = Vec::new();
522 assert_eq!(
523 db::transactions::claim_free_items_batch(
524 &h.db, buyer_id, seller_id, "seller", None, &empty
525 )
526 .await
527 .unwrap(),
528 0,
529 "an empty batch is a no-op"
530 );
531 }
532