Skip to main content

max / makenotwork

Pin the cart, guest-attach and batch-claim paths of the purchase ledger Three contract tests in db_transactions_layer.rs: complete_cart_transactions flips every pending line of one session and nothing else, returning no rows on a replay; attach_guest_purchases_by_email attaches each completed guest row to exactly one account, spends the claim token, and refuses a second account; claim_free_items_batch grants each item once, grants only the missing item of a partly-owned batch, and shares its conflict target with the single claim. The header now names db::transactions::purchases, which it always tested. That credit drops the untested money/data count to 33.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-23 22:08 UTC
Signed with PGP, not checked
Commit: 012c2dba4c569fbc65f822687c60b9c421e7414b
Parent: 6ab028b
2 files changed, +290 insertions, -7 deletions
@@ -24,8 +24,13 @@
24 24 use std::fs;
25 25 use std::path::{Path, PathBuf};
26 26
27 - /// Money and user-data files with no test at all: 34 on 2026-08-07, down from
28 - /// 41, which was down from 50 on 2026-08-04.
27 + /// Money and user-data files with no test at all: 33 on 2026-08-23, down from
28 + /// 34, which was down from 41 and from 50 on 2026-08-04.
29 + ///
30 + /// The 34 to 33 step is `db/transactions/purchases.rs`. Its contract tests were
31 + /// in `tests/workflows/db_transactions_layer.rs` all along; that file's header
32 + /// named only `db::transactions`, so the subject credit stopped at the
33 + /// directory module and the file it actually tests read as untested.
29 34 ///
30 35 /// The drop from 41 is not seven new tests. It is `declared_contract_subjects`
31 36 /// below finally crediting the eight files whose tests live in a
@@ -35,7 +40,7 @@
35 40 /// Lower it when you cover one. Never raise it: a new untested file in these
36 41 /// areas is the thing this seal exists to refuse. If you genuinely need to add
37 42 /// one, the honest move is to write the test, not to bump the constant.
38 - const UNTESTED_HIGH_WATER: usize = 34;
43 + const UNTESTED_HIGH_WATER: usize = 33;
39 44
40 45 /// Anything that moves money or decides what someone is entitled to.
41 46 const MONEY: &[&str] = &[
@@ -1,11 +1,17 @@
1 - //! DB-layer contract tests for the money core (`db::transactions`).
1 + //! DB-layer contract tests for the money core (`db::transactions`,
2 + //! `db::transactions::purchases`).
2 3 //!
3 4 //! `db::transactions` is the largest money module and its contracts were asserted
4 5 //! only indirectly through HTTP/webhook flows (audit Run 17 Testing). These call
5 6 //! the `db::` functions directly so the invariants the payment safety leans on,
6 - //! completion idempotency (the webhook-dedup backstop), single-shot refund
7 - //! claiming, free-claim dedup, and buyer/seller scoping, are pinned at the layer
8 - //! they live in.
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.
9 15
10 16 use crate::harness::TestHarness;
11 17 use makenotwork::db::{
@@ -29,6 +35,24 @@
29 35 (setup.user_id, item_id, buyer_id)
30 36 }
31 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 +
32 56 fn tx_params(
33 57 buyer_id: db::UserId,
34 58 seller_id: db::UserId,
@@ -252,3 +276,257 @@
252 276 .unwrap()
253 277 );
254 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 + }