Skip to main content

max / makenotwork

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