//! DB-layer contract tests for the money core (`db::transactions`). //! //! `transactions.rs` is the largest money module and its contracts were asserted //! only indirectly through HTTP/webhook flows (audit Run 17 Testing). These call //! the `db::` functions directly so the invariants the payment safety leans on, //! completion idempotency (the webhook-dedup backstop), single-shot refund //! claiming, free-claim dedup, and buyer/seller scoping, are pinned at the layer //! they live in. 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) } 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")) .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")) .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")) .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")) .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")) .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() ); }