Skip to main content

max / makenotwork

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