Skip to main content

max / makenotwork

server: money-core db-layer contract tests (db::transactions) transactions.rs is the largest money module and its contracts were covered only indirectly through HTTP/webhook flows (audit Run 17 Testing gap). Add db_transactions_layer with direct db-layer tests pinning the invariants payment safety leans on: - complete_transaction idempotency (the duplicate-webhook dedup backstop) - has_purchased_item / transaction_exists flip only on completion - claim_transaction_for_refund single-shot + release-and-retry - buyer/seller scoping (an unrelated buyer sees none of a purchase) - claim_free_item ON CONFLICT dedup (no double-claim / double-credit) Postgres-backed (TestHarness); compiles offline, runs on astra with the rest of the integration suite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-02 18:02 UTC
Commit: 6df9789d967074a4397b25eefe6b8e62b6a039bc
Parent: 6753ee9
2 files changed, +185 insertions, -0 deletions
@@ -0,0 +1,184 @@
1 + //! DB-layer contract tests for the money core (`db::transactions`).
2 + //!
3 + //! `transactions.rs` is the largest money module and its contracts were asserted
4 + //! only indirectly through HTTP/webhook flows (audit Run 17 Testing). These call
5 + //! 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.
9 +
10 + use crate::harness::TestHarness;
11 + use makenotwork::db::{self, transactions::CreateTransactionParams, Cents, ItemId, TransactionStatus};
12 +
13 + /// Create a seller (with an item) and a separate buyer, returning
14 + /// `(seller_id, item_id, buyer_id)`.
15 + async fn seller_item_buyer(h: &mut TestHarness, tag: &str) -> (db::UserId, ItemId, db::UserId) {
16 + let setup = h
17 + .create_creator_with_item(&format!("seller_{tag}"), "audio", 1000)
18 + .await;
19 + let item_id: ItemId = setup.item_id.parse().expect("item id parses");
20 + let buyer_id = h.signup(&format!("buyer_{tag}"), &format!("buyer_{tag}@test.com"), "password123").await;
21 + (setup.user_id, item_id, buyer_id)
22 + }
23 +
24 + fn tx_params<'a>(
25 + buyer_id: db::UserId,
26 + seller_id: db::UserId,
27 + item_id: ItemId,
28 + session: &'a str,
29 + ) -> CreateTransactionParams<'a> {
30 + CreateTransactionParams {
31 + buyer_id: Some(buyer_id),
32 + seller_id,
33 + item_id: Some(item_id),
34 + amount_cents: Cents::new(1000),
35 + platform_fee_cents: Cents::ZERO,
36 + stripe_checkout_session_id: session,
37 + item_title: "Test Item",
38 + seller_username: "seller",
39 + share_contact: false,
40 + project_id: None,
41 + promo_code_id: None,
42 + guest_email: None,
43 + platform_credit_cents: 0,
44 + }
45 + }
46 +
47 + // ── complete_transaction: the webhook-dedup idempotency backstop ──
48 +
49 + #[tokio::test]
50 + async fn complete_transaction_is_idempotent_across_duplicate_webhooks() {
51 + let mut h = TestHarness::new().await;
52 + let (seller_id, item_id, buyer_id) = seller_item_buyer(&mut h, "cmpl").await;
53 + let session = "cs_dbtl_complete_001";
54 +
55 + db::transactions::create_transaction(&h.db, &tx_params(buyer_id, seller_id, item_id, session))
56 + .await
57 + .expect("create pending transaction");
58 +
59 + // First completion transitions pending -> completed and returns the row.
60 + let first = db::transactions::complete_transaction(&h.db, session, Some("pi_dbtl_001"))
61 + .await
62 + .expect("first complete ok");
63 + let completed = first.expect("first completion returns the row");
64 + assert_eq!(completed.status, TransactionStatus::Completed);
65 +
66 + // A duplicate webhook delivery must be a no-op: the guard is `WHERE status =
67 + // 'pending'`, so the second call returns None rather than re-completing.
68 + let second = db::transactions::complete_transaction(&h.db, session, Some("pi_dbtl_001"))
69 + .await
70 + .expect("second complete ok");
71 + assert!(second.is_none(), "a duplicate completion webhook must be a no-op");
72 + }
73 +
74 + // ── has_purchased_item / transaction_exists: pre/post completion ──
75 +
76 + #[tokio::test]
77 + async fn purchase_visibility_flips_only_after_completion() {
78 + let mut h = TestHarness::new().await;
79 + let (seller_id, item_id, buyer_id) = seller_item_buyer(&mut h, "vis").await;
80 + let session = "cs_dbtl_vis_001";
81 +
82 + assert!(
83 + !db::transactions::transaction_exists_for_checkout_session(&h.db, session).await.unwrap(),
84 + "no transaction exists before create"
85 + );
86 +
87 + db::transactions::create_transaction(&h.db, &tx_params(buyer_id, seller_id, item_id, session))
88 + .await
89 + .unwrap();
90 +
91 + // The row exists (idempotency guard), but a still-pending purchase does NOT
92 + // count as purchased until it completes.
93 + assert!(db::transactions::transaction_exists_for_checkout_session(&h.db, session).await.unwrap());
94 + assert!(
95 + !db::transactions::has_purchased_item(&h.db, buyer_id, item_id).await.unwrap(),
96 + "a pending purchase must not read as purchased"
97 + );
98 +
99 + db::transactions::complete_transaction(&h.db, session, Some("pi_dbtl_vis")).await.unwrap();
100 + assert!(
101 + db::transactions::has_purchased_item(&h.db, buyer_id, item_id).await.unwrap(),
102 + "a completed purchase reads as purchased"
103 + );
104 + }
105 +
106 + // ── claim_transaction_for_refund: single-shot (mirrors pending_refunds) ──
107 +
108 + #[tokio::test]
109 + async fn refund_claim_is_single_shot() {
110 + let mut h = TestHarness::new().await;
111 + let (seller_id, item_id, buyer_id) = seller_item_buyer(&mut h, "rfnd").await;
112 + let session = "cs_dbtl_refund_001";
113 +
114 + db::transactions::create_transaction(&h.db, &tx_params(buyer_id, seller_id, item_id, session))
115 + .await
116 + .unwrap();
117 + let completed = db::transactions::complete_transaction(&h.db, session, Some("pi_dbtl_refund"))
118 + .await
119 + .unwrap()
120 + .expect("completed row");
121 +
122 + // First claim moves completed -> refunding and returns the id; a second claim
123 + // finds no completed row and returns None (no double refund).
124 + let first = db::transactions::claim_transaction_for_refund(&h.db, completed.id).await.unwrap();
125 + assert_eq!(first, Some(completed.id), "first refund claim matches the completed row");
126 + let second = db::transactions::claim_transaction_for_refund(&h.db, completed.id).await.unwrap();
127 + assert!(second.is_none(), "a claimed (refunding) transaction can't be claimed again");
128 +
129 + // Releasing the claim (refund call failed) returns it to completed so a retry
130 + // can claim it once more.
131 + db::transactions::release_refund_claim(&h.db, completed.id).await.unwrap();
132 + let retried = db::transactions::claim_transaction_for_refund(&h.db, completed.id).await.unwrap();
133 + assert_eq!(retried, Some(completed.id), "a released claim is claimable again");
134 + }
135 +
136 + // ── buyer/seller scoping ──
137 +
138 + #[tokio::test]
139 + async fn transactions_are_scoped_to_their_buyer_and_seller() {
140 + let mut h = TestHarness::new().await;
141 + let (seller_id, item_id, buyer_id) = seller_item_buyer(&mut h, "scope").await;
142 + let other_buyer = h.signup("other_buyer", "other_buyer@test.com", "password123").await;
143 + let session = "cs_dbtl_scope_001";
144 +
145 + db::transactions::create_transaction(&h.db, &tx_params(buyer_id, seller_id, item_id, session))
146 + .await
147 + .unwrap();
148 + db::transactions::complete_transaction(&h.db, session, Some("pi_dbtl_scope")).await.unwrap();
149 +
150 + let buyer_txs = db::transactions::get_transactions_by_buyer(&h.db, buyer_id, None).await.unwrap();
151 + assert!(buyer_txs.iter().any(|t| t.item_id == Some(item_id)), "buyer sees their purchase");
152 +
153 + let other_txs = db::transactions::get_transactions_by_buyer(&h.db, other_buyer, None).await.unwrap();
154 + assert!(other_txs.is_empty(), "an unrelated buyer sees none of it");
155 +
156 + let seller_txs = db::transactions::get_transactions_by_seller(&h.db, seller_id, None).await.unwrap();
157 + assert!(seller_txs.iter().any(|t| t.item_id == Some(item_id)), "seller sees the sale");
158 + }
159 +
160 + // ── claim_free_item: ON CONFLICT dedup (double-claim / double-credit guard) ──
161 +
162 + #[tokio::test]
163 + async fn free_claim_cannot_be_double_claimed() {
164 + let mut h = TestHarness::new().await;
165 + let (seller_id, item_id, buyer_id) = seller_item_buyer(&mut h, "free").await;
166 +
167 + let params = db::transactions::ClaimParams {
168 + buyer_id,
169 + item_id,
170 + seller_id,
171 + item_title: "Free Item",
172 + seller_username: "seller",
173 + share_contact: false,
174 + parent_transaction_id: None,
175 + platform_credit_cents: 0,
176 + };
177 +
178 + let first = db::transactions::claim_free_item(&h.db, &params).await.unwrap();
179 + assert!(first, "first free claim inserts a completed transaction");
180 + let second = db::transactions::claim_free_item(&h.db, &params).await.unwrap();
181 + assert!(!second, "a repeat free claim is a no-op (ON CONFLICT), never a second grant");
182 +
183 + assert!(db::transactions::has_purchased_item(&h.db, buyer_id, item_id).await.unwrap());
184 + }
@@ -98,6 +98,7 @@ mod cart;
98 98 mod bundles;
99 99 mod idempotency;
100 100 mod db_payments_layer;
101 + mod db_transactions_layer;
101 102 mod db_collections_layer;
102 103 mod db_creator_tiers_layer;
103 104 mod db_issues_layer;