Skip to main content

max / makenotwork

8.4 KB · 254 lines History Blame Raw
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::{
12 self, Cents, ItemId, TransactionStatus, transactions::CreateTransactionParams,
13 };
14
15 /// Create a seller (with an item) and a separate buyer, returning
16 /// `(seller_id, item_id, buyer_id)`.
17 async fn seller_item_buyer(h: &mut TestHarness, tag: &str) -> (db::UserId, ItemId, db::UserId) {
18 let setup = h
19 .create_creator_with_item(&format!("seller_{tag}"), "audio", 1000)
20 .await;
21 let item_id: ItemId = setup.item_id.parse().expect("item id parses");
22 let buyer_id = h
23 .signup(
24 &format!("buyer_{tag}"),
25 &format!("buyer_{tag}@test.com"),
26 "password123",
27 )
28 .await;
29 (setup.user_id, item_id, buyer_id)
30 }
31
32 fn tx_params(
33 buyer_id: db::UserId,
34 seller_id: db::UserId,
35 item_id: ItemId,
36 session: &str,
37 ) -> CreateTransactionParams<'_> {
38 CreateTransactionParams {
39 buyer_id: Some(buyer_id),
40 seller_id,
41 item_id: Some(item_id),
42 amount_cents: Cents::new(1000),
43 platform_fee_cents: Cents::ZERO,
44 stripe_checkout_session_id: session,
45 item_title: "Test Item",
46 seller_username: "seller",
47 share_contact: false,
48 project_id: None,
49 promo_code_id: None,
50 guest_email: None,
51 platform_credit_cents: 0,
52 }
53 }
54
55 // ── complete_transaction: the webhook-dedup idempotency backstop ──
56
57 #[tokio::test]
58 async fn complete_transaction_is_idempotent_across_duplicate_webhooks() {
59 let mut h = TestHarness::new().await;
60 let (seller_id, item_id, buyer_id) = seller_item_buyer(&mut h, "cmpl").await;
61 let session = "cs_dbtl_complete_001";
62
63 db::transactions::create_transaction(&h.db, &tx_params(buyer_id, seller_id, item_id, session))
64 .await
65 .expect("create pending transaction");
66
67 // First completion transitions pending -> completed and returns the row.
68 let first = db::transactions::complete_transaction(&h.db, session, Some("pi_dbtl_001"))
69 .await
70 .expect("first complete ok");
71 let completed = first.expect("first completion returns the row");
72 assert_eq!(completed.status, TransactionStatus::Completed);
73
74 // A duplicate webhook delivery must be a no-op: the guard is `WHERE status =
75 // 'pending'`, so the second call returns None rather than re-completing.
76 let second = db::transactions::complete_transaction(&h.db, session, Some("pi_dbtl_001"))
77 .await
78 .expect("second complete ok");
79 assert!(
80 second.is_none(),
81 "a duplicate completion webhook must be a no-op"
82 );
83 }
84
85 // ── has_purchased_item / transaction_exists: pre/post completion ──
86
87 #[tokio::test]
88 async fn purchase_visibility_flips_only_after_completion() {
89 let mut h = TestHarness::new().await;
90 let (seller_id, item_id, buyer_id) = seller_item_buyer(&mut h, "vis").await;
91 let session = "cs_dbtl_vis_001";
92
93 assert!(
94 !db::transactions::transaction_exists_for_checkout_session(&h.db, session)
95 .await
96 .unwrap(),
97 "no transaction exists before create"
98 );
99
100 db::transactions::create_transaction(&h.db, &tx_params(buyer_id, seller_id, item_id, session))
101 .await
102 .unwrap();
103
104 // The row exists (idempotency guard), but a still-pending purchase does NOT
105 // count as purchased until it completes.
106 assert!(
107 db::transactions::transaction_exists_for_checkout_session(&h.db, session)
108 .await
109 .unwrap()
110 );
111 assert!(
112 !db::transactions::has_purchased_item(&h.db, buyer_id, item_id)
113 .await
114 .unwrap(),
115 "a pending purchase must not read as purchased"
116 );
117
118 db::transactions::complete_transaction(&h.db, session, Some("pi_dbtl_vis"))
119 .await
120 .unwrap();
121 assert!(
122 db::transactions::has_purchased_item(&h.db, buyer_id, item_id)
123 .await
124 .unwrap(),
125 "a completed purchase reads as purchased"
126 );
127 }
128
129 // ── claim_transaction_for_refund: single-shot (mirrors pending_refunds) ──
130
131 #[tokio::test]
132 async fn refund_claim_is_single_shot() {
133 let mut h = TestHarness::new().await;
134 let (seller_id, item_id, buyer_id) = seller_item_buyer(&mut h, "rfnd").await;
135 let session = "cs_dbtl_refund_001";
136
137 db::transactions::create_transaction(&h.db, &tx_params(buyer_id, seller_id, item_id, session))
138 .await
139 .unwrap();
140 let completed = db::transactions::complete_transaction(&h.db, session, Some("pi_dbtl_refund"))
141 .await
142 .unwrap()
143 .expect("completed row");
144
145 // First claim moves completed -> refunding and returns the id; a second claim
146 // finds no completed row and returns None (no double refund).
147 let first = db::transactions::claim_transaction_for_refund(&h.db, completed.id)
148 .await
149 .unwrap();
150 assert_eq!(
151 first,
152 Some(completed.id),
153 "first refund claim matches the completed row"
154 );
155 let second = db::transactions::claim_transaction_for_refund(&h.db, completed.id)
156 .await
157 .unwrap();
158 assert!(
159 second.is_none(),
160 "a claimed (refunding) transaction can't be claimed again"
161 );
162
163 // Releasing the claim (refund call failed) returns it to completed so a retry
164 // can claim it once more.
165 db::transactions::release_refund_claim(&h.db, completed.id)
166 .await
167 .unwrap();
168 let retried = db::transactions::claim_transaction_for_refund(&h.db, completed.id)
169 .await
170 .unwrap();
171 assert_eq!(
172 retried,
173 Some(completed.id),
174 "a released claim is claimable again"
175 );
176 }
177
178 // ── buyer/seller scoping ──
179
180 #[tokio::test]
181 async fn transactions_are_scoped_to_their_buyer_and_seller() {
182 let mut h = TestHarness::new().await;
183 let (seller_id, item_id, buyer_id) = seller_item_buyer(&mut h, "scope").await;
184 let other_buyer = h
185 .signup("other_buyer", "other_buyer@test.com", "password123")
186 .await;
187 let session = "cs_dbtl_scope_001";
188
189 db::transactions::create_transaction(&h.db, &tx_params(buyer_id, seller_id, item_id, session))
190 .await
191 .unwrap();
192 db::transactions::complete_transaction(&h.db, session, Some("pi_dbtl_scope"))
193 .await
194 .unwrap();
195
196 let buyer_txs = db::transactions::get_transactions_by_buyer(&h.db, buyer_id, None)
197 .await
198 .unwrap();
199 assert!(
200 buyer_txs.iter().any(|t| t.item_id == Some(item_id)),
201 "buyer sees their purchase"
202 );
203
204 let other_txs = db::transactions::get_transactions_by_buyer(&h.db, other_buyer, None)
205 .await
206 .unwrap();
207 assert!(other_txs.is_empty(), "an unrelated buyer sees none of it");
208
209 let seller_txs = db::transactions::get_transactions_by_seller(&h.db, seller_id, None)
210 .await
211 .unwrap();
212 assert!(
213 seller_txs.iter().any(|t| t.item_id == Some(item_id)),
214 "seller sees the sale"
215 );
216 }
217
218 // ── claim_free_item: ON CONFLICT dedup (double-claim / double-credit guard) ──
219
220 #[tokio::test]
221 async fn free_claim_cannot_be_double_claimed() {
222 let mut h = TestHarness::new().await;
223 let (seller_id, item_id, buyer_id) = seller_item_buyer(&mut h, "free").await;
224
225 let params = db::transactions::ClaimParams {
226 buyer_id,
227 item_id,
228 seller_id,
229 item_title: "Free Item",
230 seller_username: "seller",
231 share_contact: false,
232 parent_transaction_id: None,
233 platform_credit_cents: 0,
234 };
235
236 let first = db::transactions::claim_free_item(&h.db, &params)
237 .await
238 .unwrap();
239 assert!(first, "first free claim inserts a completed transaction");
240 let second = db::transactions::claim_free_item(&h.db, &params)
241 .await
242 .unwrap();
243 assert!(
244 !second,
245 "a repeat free claim is a no-op (ON CONFLICT), never a second grant"
246 );
247
248 assert!(
249 db::transactions::has_purchased_item(&h.db, buyer_id, item_id)
250 .await
251 .unwrap()
252 );
253 }
254