Skip to main content

max / makenotwork

8.4 KB · 255 lines History Blame Raw
1 //! DB-layer contract tests for the money core (`db::transactions`).
2 //!
3 //! `db::transactions` 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"), None)
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"), None)
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"), None)
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 =
141 db::transactions::complete_transaction(&h.db, session, Some("pi_dbtl_refund"), None)
142 .await
143 .unwrap()
144 .expect("completed row");
145
146 // First claim moves completed -> refunding and returns the id; a second claim
147 // finds no completed row and returns None (no double refund).
148 let first = db::transactions::claim_transaction_for_refund(&h.db, completed.id)
149 .await
150 .unwrap();
151 assert_eq!(
152 first,
153 Some(completed.id),
154 "first refund claim matches the completed row"
155 );
156 let second = db::transactions::claim_transaction_for_refund(&h.db, completed.id)
157 .await
158 .unwrap();
159 assert!(
160 second.is_none(),
161 "a claimed (refunding) transaction can't be claimed again"
162 );
163
164 // Releasing the claim (refund call failed) returns it to completed so a retry
165 // can claim it once more.
166 db::transactions::release_refund_claim(&h.db, completed.id)
167 .await
168 .unwrap();
169 let retried = db::transactions::claim_transaction_for_refund(&h.db, completed.id)
170 .await
171 .unwrap();
172 assert_eq!(
173 retried,
174 Some(completed.id),
175 "a released claim is claimable again"
176 );
177 }
178
179 // ── buyer/seller scoping ──
180
181 #[tokio::test]
182 async fn transactions_are_scoped_to_their_buyer_and_seller() {
183 let mut h = TestHarness::new().await;
184 let (seller_id, item_id, buyer_id) = seller_item_buyer(&mut h, "scope").await;
185 let other_buyer = h
186 .signup("other_buyer", "other_buyer@test.com", "password123")
187 .await;
188 let session = "cs_dbtl_scope_001";
189
190 db::transactions::create_transaction(&h.db, &tx_params(buyer_id, seller_id, item_id, session))
191 .await
192 .unwrap();
193 db::transactions::complete_transaction(&h.db, session, Some("pi_dbtl_scope"), None)
194 .await
195 .unwrap();
196
197 let buyer_txs = db::transactions::get_transactions_by_buyer(&h.db, buyer_id, None)
198 .await
199 .unwrap();
200 assert!(
201 buyer_txs.iter().any(|t| t.item_id == Some(item_id)),
202 "buyer sees their purchase"
203 );
204
205 let other_txs = db::transactions::get_transactions_by_buyer(&h.db, other_buyer, None)
206 .await
207 .unwrap();
208 assert!(other_txs.is_empty(), "an unrelated buyer sees none of it");
209
210 let seller_txs = db::transactions::get_transactions_by_seller(&h.db, seller_id, None)
211 .await
212 .unwrap();
213 assert!(
214 seller_txs.iter().any(|t| t.item_id == Some(item_id)),
215 "seller sees the sale"
216 );
217 }
218
219 // ── claim_free_item: ON CONFLICT dedup (double-claim / double-credit guard) ──
220
221 #[tokio::test]
222 async fn free_claim_cannot_be_double_claimed() {
223 let mut h = TestHarness::new().await;
224 let (seller_id, item_id, buyer_id) = seller_item_buyer(&mut h, "free").await;
225
226 let params = db::transactions::ClaimParams {
227 buyer_id,
228 item_id,
229 seller_id,
230 item_title: "Free Item",
231 seller_username: "seller",
232 share_contact: false,
233 parent_transaction_id: None,
234 platform_credit_cents: 0,
235 };
236
237 let first = db::transactions::claim_free_item(&h.db, &params)
238 .await
239 .unwrap();
240 assert!(first, "first free claim inserts a completed transaction");
241 let second = db::transactions::claim_free_item(&h.db, &params)
242 .await
243 .unwrap();
244 assert!(
245 !second,
246 "a repeat free claim is a no-op (ON CONFLICT), never a second grant"
247 );
248
249 assert!(
250 db::transactions::has_purchased_item(&h.db, buyer_id, item_id)
251 .await
252 .unwrap()
253 );
254 }
255