Skip to main content

max / makenotwork

16.7 KB · 528 lines History Blame Raw
1 //! DB-layer contract tests for the payment cold-spot modules (`db::tips`,
2 //! `db::license_keys`, `db::pending_refunds`).
3 //!
4 //! Ultra-fuzz Run 7 flagged these three modules as the Payments cold spot:
5 //! correct, but with their DB-layer contracts asserted only indirectly through
6 //! HTTP/webhook flows. These tests call the `db::` functions directly so the
7 //! claim/unclaim/complete lifecycle, the activation cap, and the
8 //! finalize-idempotency are pinned at the layer they live in.
9
10 use crate::harness::TestHarness;
11 use makenotwork::db::{self, KeyCode};
12
13 // ── db::pending_refunds, claim/unclaim/complete lifecycle (PAY-S1) ──
14
15 #[tokio::test]
16 async fn pending_refund_claim_is_single_shot_and_completes() {
17 let h = TestHarness::new().await;
18 let pi = "pi_dbpl_single_001";
19
20 db::pending_refunds::insert_pending_refund(&h.db, pi, 999, 999)
21 .await
22 .unwrap();
23
24 // First claim matches the row; a second claim finds nothing (matched_at set).
25 let first = db::pending_refunds::claim_pending_refund(&h.db, pi)
26 .await
27 .unwrap();
28 let claimed = first.expect("first claim must match the unclaimed row");
29 assert_eq!(claimed.payment_intent_id, pi);
30 let second = db::pending_refunds::claim_pending_refund(&h.db, pi)
31 .await
32 .unwrap();
33 assert!(
34 second.is_none(),
35 "a claimed refund must not be claimable twice"
36 );
37
38 // Completing it removes it from the stale sweep.
39 db::pending_refunds::mark_refund_completed(&h.db, claimed.id)
40 .await
41 .unwrap();
42 let stale = db::pending_refunds::get_stale_refunds(&h.db, chrono::Duration::zero())
43 .await
44 .unwrap();
45 assert!(
46 !stale.iter().any(|r| r.payment_intent_id == pi),
47 "a completed refund must not surface in the stale sweep"
48 );
49 }
50
51 #[tokio::test]
52 async fn pending_refund_unclaim_reopens_for_retry() {
53 let h = TestHarness::new().await;
54 let pi = "pi_dbpl_reopen_001";
55
56 db::pending_refunds::insert_pending_refund(&h.db, pi, 500, 500)
57 .await
58 .unwrap();
59 let claimed = db::pending_refunds::claim_pending_refund(&h.db, pi)
60 .await
61 .unwrap()
62 .expect("claim");
63
64 // A graceful failure unclaims; the row is then re-claimable.
65 db::pending_refunds::unclaim_pending_refund(&h.db, claimed.id)
66 .await
67 .unwrap();
68 let reclaim = db::pending_refunds::claim_pending_refund(&h.db, pi)
69 .await
70 .unwrap();
71 assert!(
72 reclaim.is_some(),
73 "an unclaimed refund must be re-claimable"
74 );
75 }
76
77 #[tokio::test]
78 async fn pending_refund_claimed_but_uncompleted_is_swept() {
79 let h = TestHarness::new().await;
80 let pi = "pi_dbpl_crash_001";
81
82 // Claim but never complete, the crash window. Backdate created_at so the
83 // age filter surfaces it (insert_pending_refund stamps created_at = NOW()).
84 db::pending_refunds::insert_pending_refund(&h.db, pi, 700, 700)
85 .await
86 .unwrap();
87 let claimed = db::pending_refunds::claim_pending_refund(&h.db, pi)
88 .await
89 .unwrap()
90 .expect("claim");
91 sqlx::query(
92 "UPDATE pending_refunds SET created_at = NOW() - INTERVAL '25 hours' WHERE id = $1",
93 )
94 .bind(claimed.id)
95 .execute(&h.db)
96 .await
97 .unwrap();
98
99 let stale = db::pending_refunds::get_stale_refunds(&h.db, chrono::Duration::hours(24))
100 .await
101 .unwrap();
102 assert!(
103 stale.iter().any(|r| r.payment_intent_id == pi),
104 "a claimed-but-uncompleted refund must surface for human reconciliation (PAY-S1)"
105 );
106 }
107
108 // ── db::transactions, self-service refund claim (Pay-S1, Run 9) ──
109
110 #[tokio::test]
111 async fn refund_claim_blocks_double_submit_and_releases() {
112 let h = TestHarness::new().await;
113 let (buyer, seller) = two_users(&h, "refclaim").await;
114
115 // A bare completed transaction with a payment intent, the only state the
116 // refund claim cares about is status = 'completed'.
117 let tx_id: db::TransactionId = sqlx::query_scalar(
118 "INSERT INTO transactions (buyer_id, seller_id, amount_cents, status, stripe_payment_intent_id) \
119 VALUES ($1, $2, 500, 'completed', $3) RETURNING id",
120 )
121 .bind(buyer)
122 .bind(seller)
123 .bind("pi_refclaim_001")
124 .fetch_one(&h.db)
125 .await
126 .unwrap();
127
128 // First claim wins the completed -> refunding transition; a rapid second
129 // submit (the bug: the row stays completed until the async webhook) finds
130 // nothing and is rejected before any Stripe call.
131 let first = db::transactions::claim_transaction_for_refund(&h.db, tx_id)
132 .await
133 .unwrap();
134 assert_eq!(
135 first,
136 Some(tx_id),
137 "first refund claim must win completed->refunding"
138 );
139 let second = db::transactions::claim_transaction_for_refund(&h.db, tx_id)
140 .await
141 .unwrap();
142 assert!(
143 second.is_none(),
144 "a second concurrent refund claim must be blocked (double-submit)"
145 );
146
147 // A Stripe failure releases the claim back to completed so the creator can retry.
148 db::transactions::release_refund_claim(&h.db, tx_id)
149 .await
150 .unwrap();
151 let reclaim = db::transactions::claim_transaction_for_refund(&h.db, tx_id)
152 .await
153 .unwrap();
154 assert_eq!(
155 reclaim,
156 Some(tx_id),
157 "a released claim must be re-claimable"
158 );
159
160 // Once the webhook finalizes the row to refunded, it is no longer claimable.
161 sqlx::query("UPDATE transactions SET status = 'refunded' WHERE id = $1")
162 .bind(tx_id)
163 .execute(&h.db)
164 .await
165 .unwrap();
166 let after = db::transactions::claim_transaction_for_refund(&h.db, tx_id)
167 .await
168 .unwrap();
169 assert!(
170 after.is_none(),
171 "a refunded transaction must not be refund-claimable"
172 );
173 }
174
175 // ── db::tips, create guard + complete/refund idempotency ──
176
177 /// Two bare users (tipper, recipient) for tip tests. Raw SQL keeps the fixture
178 /// minimal, tips need only valid user FKs.
179 async fn two_users(h: &TestHarness, tag: &str) -> (db::UserId, db::UserId) {
180 let tipper: db::UserId = sqlx::query_scalar(
181 "INSERT INTO users (username, email, password_hash, email_verified) \
182 VALUES ($1, $2, 'x', true) RETURNING id",
183 )
184 .bind(format!("tipper_{tag}"))
185 .bind(format!("tipper_{tag}@test.com"))
186 .fetch_one(&h.db)
187 .await
188 .unwrap();
189 let recipient: db::UserId = sqlx::query_scalar(
190 "INSERT INTO users (username, email, password_hash, email_verified) \
191 VALUES ($1, $2, 'x', true) RETURNING id",
192 )
193 .bind(format!("recip_{tag}"))
194 .bind(format!("recip_{tag}@test.com"))
195 .fetch_one(&h.db)
196 .await
197 .unwrap();
198 (tipper, recipient)
199 }
200
201 #[tokio::test]
202 async fn create_tip_rejects_nonpositive_amount() {
203 let h = TestHarness::new().await;
204 let (tipper, recipient) = two_users(&h, "guard").await;
205
206 for amount in [0, -100] {
207 let res =
208 db::tips::create_tip(&h.db, tipper, recipient, None, amount, None, "cs_guard").await;
209 assert!(
210 res.is_err(),
211 "tip amount {amount} must be rejected by the positivity guard"
212 );
213 }
214
215 // A positive amount goes through.
216 let ok = db::tips::create_tip(
217 &h.db,
218 tipper,
219 recipient,
220 None,
221 500,
222 Some("thanks"),
223 "cs_guard_ok",
224 )
225 .await;
226 assert!(ok.is_ok(), "a positive tip must be accepted");
227 }
228
229 #[tokio::test]
230 async fn complete_tip_is_idempotent() {
231 let h = TestHarness::new().await;
232 let (tipper, recipient) = two_users(&h, "complete").await;
233 let session = "cs_dbpl_complete_001";
234
235 db::tips::create_tip(&h.db, tipper, recipient, None, 1500, None, session)
236 .await
237 .unwrap();
238
239 // First completion transitions pending → completed and returns the row.
240 let first = db::tips::complete_tip(&h.db, session, Some("pi_tip_complete_001"))
241 .await
242 .unwrap();
243 assert!(
244 first.is_some(),
245 "first completion must update the pending tip"
246 );
247
248 // A redelivered webhook completing the same session is a no-op.
249 let second = db::tips::complete_tip(&h.db, session, Some("pi_tip_complete_001"))
250 .await
251 .unwrap();
252 assert!(
253 second.is_none(),
254 "completing an already-completed tip must be idempotent"
255 );
256
257 // The completed tip counts toward the recipient's totals.
258 assert_eq!(
259 db::tips::count_tips_received(&h.db, recipient)
260 .await
261 .unwrap(),
262 1
263 );
264 assert_eq!(
265 db::tips::total_tips_received(&h.db, recipient)
266 .await
267 .unwrap(),
268 1500
269 );
270 }
271
272 #[tokio::test]
273 async fn refund_tip_is_idempotent_and_scoped() {
274 let h = TestHarness::new().await;
275 let (tipper, recipient) = two_users(&h, "refund").await;
276 let session = "cs_dbpl_refund_001";
277 let pi = "pi_tip_refund_001";
278
279 db::tips::create_tip(&h.db, tipper, recipient, None, 800, None, session)
280 .await
281 .unwrap();
282 db::tips::complete_tip(&h.db, session, Some(pi))
283 .await
284 .unwrap();
285
286 // First refund flips completed → refunded.
287 assert!(
288 db::tips::refund_tip_by_payment_intent(&h.db, pi)
289 .await
290 .unwrap()
291 );
292 // Second refund of the same PI is a no-op (already refunded).
293 assert!(
294 !db::tips::refund_tip_by_payment_intent(&h.db, pi)
295 .await
296 .unwrap()
297 );
298 // An unknown PI never matches.
299 assert!(
300 !db::tips::refund_tip_by_payment_intent(&h.db, "pi_unknown")
301 .await
302 .unwrap()
303 );
304
305 // A refunded tip drops out of the received totals.
306 assert_eq!(
307 db::tips::count_tips_received(&h.db, recipient)
308 .await
309 .unwrap(),
310 0
311 );
312 }
313
314 // ── db::license_keys, finalize idempotency + activation cap ──
315
316 /// A user/project/item plus a completed transaction. License-key tests need the
317 /// full FK chain (license_keys.transaction_id → transactions).
318 async fn keyed_item(h: &TestHarness, tag: &str) -> (db::UserId, db::ItemId, db::TransactionId) {
319 let owner: db::UserId = sqlx::query_scalar(
320 "INSERT INTO users (username, email, password_hash, email_verified) \
321 VALUES ($1, $2, 'x', true) RETURNING id",
322 )
323 .bind(format!("lkowner_{tag}"))
324 .bind(format!("lkowner_{tag}@test.com"))
325 .fetch_one(&h.db)
326 .await
327 .unwrap();
328 let project: db::ProjectId = sqlx::query_scalar(
329 "INSERT INTO projects (user_id, slug, title) VALUES ($1, $2, 'P') RETURNING id",
330 )
331 .bind(owner)
332 .bind(format!("lkproj_{tag}"))
333 .fetch_one(&h.db)
334 .await
335 .unwrap();
336 let item: db::ItemId = sqlx::query_scalar(
337 "INSERT INTO items (project_id, title, item_type, price_cents, slug) \
338 VALUES ($1, 'Plugin', 'plugin', 0, $2) RETURNING id",
339 )
340 .bind(project)
341 .bind(format!("lkitem_{tag}"))
342 .fetch_one(&h.db)
343 .await
344 .unwrap();
345 let tx: db::TransactionId = sqlx::query_scalar(
346 "INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, status) \
347 VALUES ($1, $1, $2, 0, 'completed') RETURNING id",
348 )
349 .bind(owner)
350 .bind(item)
351 .fetch_one(&h.db)
352 .await
353 .unwrap();
354 (owner, item, tx)
355 }
356
357 #[tokio::test]
358 async fn create_license_key_is_idempotent_on_transaction_id() {
359 let h = TestHarness::new().await;
360 let (owner, item, tx) = keyed_item(&h, "idem").await;
361
362 let code_a = KeyCode::from_trusted("aaaa-bbbb-cccc-dddd-eeee".to_string());
363 let code_b = KeyCode::from_trusted("ffff-gggg-hhhh-iiii-jjjj".to_string());
364
365 // First mint for the transaction.
366 let first =
367 db::license_keys::create_license_key(&h.db, item, owner, Some(tx), &code_a, Some(3))
368 .await
369 .unwrap();
370
371 // A redelivered finalize for the SAME transaction must return the existing
372 // key (Pay-M1), not error and not mint a second, even with a different code.
373 let second =
374 db::license_keys::create_license_key(&h.db, item, owner, Some(tx), &code_b, Some(3))
375 .await
376 .unwrap();
377
378 assert_eq!(
379 first.id, second.id,
380 "a duplicate finalize must return the existing key"
381 );
382 let count = db::license_keys::count_keys_by_item(&h.db, item)
383 .await
384 .unwrap();
385 assert_eq!(
386 count, 1,
387 "no second key may be minted for the same transaction"
388 );
389 }
390
391 #[tokio::test]
392 async fn try_create_activation_enforces_max_activations() {
393 let h = TestHarness::new().await;
394 let (owner, item, tx) = keyed_item(&h, "cap").await;
395
396 let code = KeyCode::from_trusted("kkkk-llll-mmmm-nnnn-oooo".to_string());
397 let key = db::license_keys::create_license_key(&h.db, item, owner, Some(tx), &code, Some(2))
398 .await
399 .unwrap();
400
401 // Two distinct machines fit under the cap of 2.
402 assert!(
403 db::license_keys::try_create_activation(&h.db, key.id, "machine-a", None)
404 .await
405 .unwrap()
406 .is_some()
407 );
408 assert!(
409 db::license_keys::try_create_activation(&h.db, key.id, "machine-b", None)
410 .await
411 .unwrap()
412 .is_some()
413 );
414 // A third distinct machine is refused.
415 assert!(
416 db::license_keys::try_create_activation(&h.db, key.id, "machine-c", None)
417 .await
418 .unwrap()
419 .is_none(),
420 "activation past max_activations must be refused"
421 );
422 // Re-activating an existing machine is always allowed (no new slot consumed).
423 assert!(
424 db::license_keys::try_create_activation(&h.db, key.id, "machine-a", None)
425 .await
426 .unwrap()
427 .is_some(),
428 "re-activation of a known machine must always succeed"
429 );
430
431 // A revoked key activates nothing.
432 db::license_keys::revoke_license_key(&h.db, key.id)
433 .await
434 .unwrap();
435 assert!(
436 db::license_keys::try_create_activation(&h.db, key.id, "machine-d", None)
437 .await
438 .unwrap()
439 .is_none(),
440 "a revoked key must not activate"
441 );
442 }
443
444 // ── db::creator_tiers, storage-cap compare-and-set (Run 13 Test) ──
445 // The storage-cap enforcement was "illusory coverage": the atomic
446 // conditional-UPDATE was exercised only indirectly through upload flows. Pin the
447 // CAS contract at the DB layer: it must never let the cap be exceeded, including
448 // under a concurrent race.
449
450 #[tokio::test]
451 async fn storage_increment_succeeds_under_cap() {
452 let mut h = TestHarness::new().await;
453 let user = h.create_creator("storagecap_under").await;
454
455 db::creator_tiers::try_increment_storage(&h.db, user, 400, 1000)
456 .await
457 .expect("an increment within the cap must succeed");
458
459 let used: i64 = sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1")
460 .bind(user)
461 .fetch_one(&h.db)
462 .await
463 .unwrap();
464 assert_eq!(used, 400);
465 }
466
467 #[tokio::test]
468 async fn storage_increment_past_cap_is_rejected_and_leaves_count_unchanged() {
469 let mut h = TestHarness::new().await;
470 let user = h.create_creator("storagecap_reject").await;
471
472 db::creator_tiers::try_increment_storage(&h.db, user, 800, 1000)
473 .await
474 .unwrap();
475 // 800 + 300 = 1100 > 1000 → rejected.
476 assert!(
477 db::creator_tiers::try_increment_storage(&h.db, user, 300, 1000)
478 .await
479 .is_err(),
480 "an increment past the cap must fail"
481 );
482
483 let used: i64 = sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1")
484 .bind(user)
485 .fetch_one(&h.db)
486 .await
487 .unwrap();
488 assert_eq!(
489 used, 800,
490 "a rejected increment must not change the stored count"
491 );
492 }
493
494 #[tokio::test]
495 async fn storage_increment_concurrent_never_exceeds_cap() {
496 let mut h = TestHarness::new().await;
497 let user = h.create_creator("storagecap_race").await;
498
499 // Cap 900, starting at 0. Two concurrent +500 increments: only one fits
500 // (0+500 ok → 500; the other then sees 500, 500+500=1000 > 900 → rejected).
501 let (p1, p2) = (h.db.clone(), h.db.clone());
502 let a =
503 tokio::spawn(
504 async move { db::creator_tiers::try_increment_storage(&p1, user, 500, 900).await },
505 );
506 let b =
507 tokio::spawn(
508 async move { db::creator_tiers::try_increment_storage(&p2, user, 500, 900).await },
509 );
510 let (ra, rb) = (a.await.unwrap(), b.await.unwrap());
511
512 let succeeded = [ra.is_ok(), rb.is_ok()].iter().filter(|x| **x).count();
513 assert_eq!(
514 succeeded, 1,
515 "exactly one of two racing over-cap increments may succeed"
516 );
517
518 let used: i64 = sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1")
519 .bind(user)
520 .fetch_one(&h.db)
521 .await
522 .unwrap();
523 assert_eq!(
524 used, 500,
525 "the cap holds under a race: total is 500, never 1000"
526 );
527 }
528