//! DB-layer contract tests for the money core (`db::transactions`, //! `db::transactions::purchases`). //! //! `db::transactions` is the largest money module and its contracts were asserted //! only indirectly through HTTP/webhook flows. These call //! the `db::` functions directly so the invariants the payment safety leans on, //! completion idempotency (the webhook-dedup backstop), cart completion across //! every line of one session, guest-purchase attachment, single-shot refund //! claiming, free-claim dedup (single and batch), and buyer/seller scoping, are //! pinned at the layer they live in. //! //! Every function under test lives in `purchases.rs`, which the header above //! names so the coverage seal credits it. use crate::harness::TestHarness; use makenotwork::db::{ self, Cents, ItemId, TransactionStatus, transactions::CreateTransactionParams, }; /// Create a seller (with an item) and a separate buyer, returning /// `(seller_id, item_id, buyer_id)`. async fn seller_item_buyer(h: &mut TestHarness, tag: &str) -> (db::UserId, ItemId, db::UserId) { let setup = h .create_creator_with_item(&format!("seller_{tag}"), "audio", 1000) .await; let item_id: ItemId = setup.item_id.parse().expect("item id parses"); let buyer_id = h .signup( &format!("buyer_{tag}"), &format!("buyer_{tag}@test.com"), "password123", ) .await; (setup.user_id, item_id, buyer_id) } /// Add another item to an existing project. The creator must be logged in. async fn extra_item(h: &mut TestHarness, project_id: &str, title: &str) -> ItemId { let resp = h .client .post_form( &format!("/api/projects/{project_id}/items"), &format!("title={title}&item_type=audio&price_cents=1000"), ) .await; assert_eq!(resp.status, 200, "create extra item failed: {}", resp.text); let item: serde_json::Value = resp.json(); item["id"] .as_str() .unwrap() .parse() .expect("item id parses") } fn tx_params( buyer_id: db::UserId, seller_id: db::UserId, item_id: ItemId, session: &str, ) -> CreateTransactionParams<'_> { CreateTransactionParams { buyer_id: Some(buyer_id), seller_id, item_id: Some(item_id), amount_cents: Cents::new(1000), platform_fee_cents: Cents::ZERO, stripe_checkout_session_id: session, item_title: "Test Item", seller_username: "seller", share_contact: false, project_id: None, promo_code_id: None, guest_email: None, platform_credit_cents: 0, } } // ── complete_transaction: the webhook-dedup idempotency backstop ── #[tokio::test] async fn complete_transaction_is_idempotent_across_duplicate_webhooks() { let mut h = TestHarness::new().await; let (seller_id, item_id, buyer_id) = seller_item_buyer(&mut h, "cmpl").await; let session = "cs_dbtl_complete_001"; db::transactions::create_transaction(&h.db, &tx_params(buyer_id, seller_id, item_id, session)) .await .expect("create pending transaction"); // First completion transitions pending -> completed and returns the row. let first = db::transactions::complete_transaction(&h.db, session, Some("pi_dbtl_001"), None) .await .expect("first complete ok"); let completed = first.expect("first completion returns the row"); assert_eq!(completed.status, TransactionStatus::Completed); // A duplicate webhook delivery must be a no-op: the guard is `WHERE status = // 'pending'`, so the second call returns None rather than re-completing. let second = db::transactions::complete_transaction(&h.db, session, Some("pi_dbtl_001"), None) .await .expect("second complete ok"); assert!( second.is_none(), "a duplicate completion webhook must be a no-op" ); } // ── has_purchased_item / transaction_exists: pre/post completion ── #[tokio::test] async fn purchase_visibility_flips_only_after_completion() { let mut h = TestHarness::new().await; let (seller_id, item_id, buyer_id) = seller_item_buyer(&mut h, "vis").await; let session = "cs_dbtl_vis_001"; assert!( !db::transactions::transaction_exists_for_checkout_session(&h.db, session) .await .unwrap(), "no transaction exists before create" ); db::transactions::create_transaction(&h.db, &tx_params(buyer_id, seller_id, item_id, session)) .await .unwrap(); // The row exists (idempotency guard), but a still-pending purchase does NOT // count as purchased until it completes. assert!( db::transactions::transaction_exists_for_checkout_session(&h.db, session) .await .unwrap() ); assert!( !db::transactions::has_purchased_item(&h.db, buyer_id, item_id) .await .unwrap(), "a pending purchase must not read as purchased" ); db::transactions::complete_transaction(&h.db, session, Some("pi_dbtl_vis"), None) .await .unwrap(); assert!( db::transactions::has_purchased_item(&h.db, buyer_id, item_id) .await .unwrap(), "a completed purchase reads as purchased" ); } // ── claim_transaction_for_refund: single-shot (mirrors pending_refunds) ── #[tokio::test] async fn refund_claim_is_single_shot() { let mut h = TestHarness::new().await; let (seller_id, item_id, buyer_id) = seller_item_buyer(&mut h, "rfnd").await; let session = "cs_dbtl_refund_001"; db::transactions::create_transaction(&h.db, &tx_params(buyer_id, seller_id, item_id, session)) .await .unwrap(); let completed = db::transactions::complete_transaction(&h.db, session, Some("pi_dbtl_refund"), None) .await .unwrap() .expect("completed row"); // First claim moves completed -> refunding and returns the id; a second claim // finds no completed row and returns None (no double refund). let first = db::transactions::claim_transaction_for_refund(&h.db, completed.id) .await .unwrap(); assert_eq!( first, Some(completed.id), "first refund claim matches the completed row" ); let second = db::transactions::claim_transaction_for_refund(&h.db, completed.id) .await .unwrap(); assert!( second.is_none(), "a claimed (refunding) transaction can't be claimed again" ); // Releasing the claim (refund call failed) returns it to completed so a retry // can claim it once more. db::transactions::release_refund_claim(&h.db, completed.id) .await .unwrap(); let retried = db::transactions::claim_transaction_for_refund(&h.db, completed.id) .await .unwrap(); assert_eq!( retried, Some(completed.id), "a released claim is claimable again" ); } // ── buyer/seller scoping ── #[tokio::test] async fn transactions_are_scoped_to_their_buyer_and_seller() { let mut h = TestHarness::new().await; let (seller_id, item_id, buyer_id) = seller_item_buyer(&mut h, "scope").await; let other_buyer = h .signup("other_buyer", "other_buyer@test.com", "password123") .await; let session = "cs_dbtl_scope_001"; db::transactions::create_transaction(&h.db, &tx_params(buyer_id, seller_id, item_id, session)) .await .unwrap(); db::transactions::complete_transaction(&h.db, session, Some("pi_dbtl_scope"), None) .await .unwrap(); let buyer_txs = db::transactions::get_transactions_by_buyer(&h.db, buyer_id, None) .await .unwrap(); assert!( buyer_txs.iter().any(|t| t.item_id == Some(item_id)), "buyer sees their purchase" ); let other_txs = db::transactions::get_transactions_by_buyer(&h.db, other_buyer, None) .await .unwrap(); assert!(other_txs.is_empty(), "an unrelated buyer sees none of it"); let seller_txs = db::transactions::get_transactions_by_seller(&h.db, seller_id, None) .await .unwrap(); assert!( seller_txs.iter().any(|t| t.item_id == Some(item_id)), "seller sees the sale" ); } // ── claim_free_item: ON CONFLICT dedup (double-claim / double-credit guard) ── #[tokio::test] async fn free_claim_cannot_be_double_claimed() { let mut h = TestHarness::new().await; let (seller_id, item_id, buyer_id) = seller_item_buyer(&mut h, "free").await; let params = db::transactions::ClaimParams { buyer_id, item_id, seller_id, item_title: "Free Item", seller_username: "seller", share_contact: false, parent_transaction_id: None, platform_credit_cents: 0, }; let first = db::transactions::claim_free_item(&h.db, ¶ms) .await .unwrap(); assert!(first, "first free claim inserts a completed transaction"); let second = db::transactions::claim_free_item(&h.db, ¶ms) .await .unwrap(); assert!( !second, "a repeat free claim is a no-op (ON CONFLICT), never a second grant" ); assert!( db::transactions::has_purchased_item(&h.db, buyer_id, item_id) .await .unwrap() ); } // ── complete_cart_transactions: every line of a cart, once ── #[tokio::test] async fn cart_completion_flips_every_line_and_replays_empty() { let mut h = TestHarness::new().await; let setup = h .create_creator_with_item("seller_cart", "audio", 1000) .await; let seller_id = setup.user_id; let item_a: ItemId = setup.item_id.parse().expect("item id parses"); let item_b = extra_item(&mut h, &setup.project_id, "Second").await; let item_c = extra_item(&mut h, &setup.project_id, "Third").await; let buyer_id = h .signup("buyer_cart", "buyer_cart@test.com", "password123") .await; let cart_session = "cs_dbtl_cart_001"; let other_session = "cs_dbtl_cart_other"; for item in [item_a, item_b] { db::transactions::create_transaction( &h.db, &tx_params(buyer_id, seller_id, item, cart_session), ) .await .expect("create pending cart line"); } // A pending line on an unrelated session, to prove the completion is scoped. db::transactions::create_transaction( &h.db, &tx_params(buyer_id, seller_id, item_c, other_session), ) .await .unwrap(); let completed = db::transactions::complete_cart_transactions(&h.db, cart_session, Some("pi_dbtl_cart_001")) .await .expect("cart completion ok"); assert_eq!( completed.len(), 2, "every pending line of the cart completes" ); for tx in &completed { assert_eq!(tx.status, TransactionStatus::Completed); assert_eq!( tx.stripe_payment_intent_id.as_deref(), Some("pi_dbtl_cart_001"), ); } assert!( db::transactions::has_purchased_item(&h.db, buyer_id, item_a) .await .unwrap() && db::transactions::has_purchased_item(&h.db, buyer_id, item_b) .await .unwrap(), "both cart lines grant access" ); assert!( !db::transactions::has_purchased_item(&h.db, buyer_id, item_c) .await .unwrap(), "a line on another session stays pending" ); // Replay of the same webhook: the `WHERE status = 'pending'` guard leaves // nothing to flip, so the second delivery returns no rows and grants nothing // a second time. let replay = db::transactions::complete_cart_transactions(&h.db, cart_session, Some("pi_dbtl_cart_001")) .await .expect("cart replay ok"); assert!( replay.is_empty(), "a duplicate cart webhook must complete nothing" ); let completed_rows: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM transactions \ WHERE stripe_checkout_session_id = $1 AND status = 'completed'", ) .bind(cart_session) .fetch_one(&h.db) .await .unwrap(); assert_eq!(completed_rows, 2, "the replay added no rows"); } // ── attach_guest_purchases_by_email: one account, once ── #[tokio::test] async fn guest_purchases_attach_to_one_account_and_only_once() { let mut h = TestHarness::new().await; let setup = h .create_creator_with_item("seller_guest", "audio", 1000) .await; let seller_id = setup.user_id; let item_a: ItemId = setup.item_id.parse().expect("item id parses"); let item_b = extra_item(&mut h, &setup.project_id, "Second").await; // Two guest purchases on the same email, each its own checkout session. let guest_email = "guest_attach@test.com"; for (session, item) in [("cs_dbtl_guest_a", item_a), ("cs_dbtl_guest_b", item_b)] { let mut params = tx_params(seller_id, seller_id, item, session); params.buyer_id = None; params.guest_email = Some(guest_email); db::transactions::create_transaction(&h.db, ¶ms) .await .expect("create guest pending"); db::transactions::complete_guest_transaction(&h.db, session, Some("pi_guest"), guest_email) .await .expect("complete guest") .expect("guest row completed"); } let claimer = h.signup("guest_claimer", guest_email, "password123").await; let attached = db::transactions::attach_guest_purchases_by_email(&h.db, guest_email, claimer) .await .expect("attach ok"); assert_eq!(attached, 2, "both guest purchases attach to the account"); assert!( db::transactions::has_purchased_item(&h.db, claimer, item_a) .await .unwrap() && db::transactions::has_purchased_item(&h.db, claimer, item_b) .await .unwrap(), "the attached purchases read as owned" ); // Re-running attachment finds nothing: the rows now have a buyer, and the // claim token is spent. let again = db::transactions::attach_guest_purchases_by_email(&h.db, guest_email, claimer) .await .unwrap(); assert_eq!( again, 0, "an already-claimed guest purchase is not re-attached" ); // And no second account can take them by naming the same email. let other = h .signup("guest_other", "guest_other@test.com", "password123") .await; let stolen = db::transactions::attach_guest_purchases_by_email(&h.db, guest_email, other) .await .unwrap(); assert_eq!( stolen, 0, "a claimed guest purchase attaches to one account only" ); let (owned, tokens_left): (i64, i64) = sqlx::query_as( "SELECT COUNT(*) FILTER (WHERE buyer_id = $1 AND claimed_by = $1), \ COUNT(*) FILTER (WHERE claim_token IS NOT NULL) \ FROM transactions WHERE LOWER(guest_email) = $2", ) .bind(claimer) .bind(guest_email) .fetch_one(&h.db) .await .unwrap(); assert_eq!(owned, 2, "both rows belong to the claiming account"); assert_eq!(tokens_left, 0, "attachment spends the claim token"); } // ── claim_free_items_batch: per-row idempotence on a multi-item grant ── #[tokio::test] async fn free_items_batch_claims_each_item_once() { let mut h = TestHarness::new().await; let setup = h.create_creator_with_item("seller_batch", "audio", 0).await; let seller_id = setup.user_id; let item_a: ItemId = setup.item_id.parse().expect("item id parses"); let item_b = extra_item(&mut h, &setup.project_id, "Second").await; let buyer_id = h .signup("buyer_batch", "buyer_batch@test.com", "password123") .await; let items: Vec<(ItemId, &str)> = vec![(item_a, "First"), (item_b, "Second")]; let claimed = db::transactions::claim_free_items_batch( &h.db, buyer_id, seller_id, "seller", None, &items, ) .await .expect("batch claim ok"); assert_eq!(claimed, 2, "each item in the batch is granted once"); assert!( db::transactions::has_purchased_item(&h.db, buyer_id, item_a) .await .unwrap() && db::transactions::has_purchased_item(&h.db, buyer_id, item_b) .await .unwrap(), ); // Replaying the same batch inserts nothing: the partial unique index // catches every row. let replay = db::transactions::claim_free_items_batch( &h.db, buyer_id, seller_id, "seller", None, &items, ) .await .unwrap(); assert_eq!(replay, 0, "a repeated batch grant is a no-op"); // A partly-new batch grants only what is missing. let item_c = { h.client.post_form("/logout", "").await; h.login("seller_batch", "password123").await; let id = extra_item(&mut h, &setup.project_id, "Third").await; h.client.post_form("/logout", "").await; id }; let mixed: Vec<(ItemId, &str)> = vec![(item_a, "First"), (item_c, "Third")]; let partial = db::transactions::claim_free_items_batch( &h.db, buyer_id, seller_id, "seller", None, &mixed, ) .await .unwrap(); assert_eq!(partial, 1, "only the unowned item of the batch is granted"); // The single-claim path shares the conflict target, so it also refuses. let single = db::transactions::claim_free_item( &h.db, &db::transactions::ClaimParams { buyer_id, item_id: item_a, seller_id, item_title: "First", seller_username: "seller", share_contact: false, parent_transaction_id: None, platform_credit_cents: 0, }, ) .await .unwrap(); assert!( !single, "a single claim of a batch-granted item is a no-op too" ); let empty: Vec<(ItemId, &str)> = Vec::new(); assert_eq!( db::transactions::claim_free_items_batch( &h.db, buyer_id, seller_id, "seller", None, &empty ) .await .unwrap(), 0, "an empty batch is a no-op" ); }