Skip to main content

max / makenotwork

25.3 KB · 697 lines History Blame Raw
1 //! Route-layer contract tests for the buyer-facing purchase handlers:
2 //! `routes::stripe::checkout::item` and `routes::stripe::checkout::cart`.
3 //!
4 //! These two routes decide, from a form post, how many cents a buyer is about
5 //! to be charged. Every test pins that number exactly: the pending row a handler
6 //! writes before redirecting to the provider must carry the amount the provider
7 //! session was built from, in integer cents. A handler that shipped dollars
8 //! where cents were meant, or the listed price where the buyer's own
9 //! pay-what-you-want amount was meant, would pass a test that only asserted "a
10 //! session was created" and fails every test below.
11 //!
12 //! Oracle note: the mock provider records a session id and URL but not the
13 //! amount, so the amount is read back from the row the handler wrote in the same
14 //! breath (transactions.amount_cents) and tied to the provider by asserting that
15 //! row's session id is the session the buyer was redirected to. Both halves are
16 //! needed: the amount alone would not prove it belongs to this session, the
17 //! session alone would not prove the amount.
18 //!
19 //! The tip handler is `stripe_tip_checkout_routes`.
20 //!
21 //! Delete this file and two things stop being checked anywhere: the per-line
22 //! price fidelity of a multi-item cart, and that re-submitting a checkout form
23 //! does not open a second charge for the same item.
24
25 use crate::harness::TestHarness;
26 use crate::harness::client::TestResponse;
27 use crate::harness::stripe::MockCheckout;
28 use makenotwork::db;
29 use serde_json::Value;
30
31 // Helpers
32
33 /// Assert an exact status, reporting the body when it does not hold.
34 fn assert_status(resp: &TestResponse, expected: u16, what: &str) {
35 assert_eq!(
36 resp.status, expected,
37 "{what}: got {} with body {}",
38 resp.status, resp.text
39 );
40 }
41
42 /// Assert the exact redirect target, reporting the body when it does not hold.
43 fn assert_redirect(resp: &TestResponse, target: &str, what: &str) {
44 assert_eq!(
45 resp.header("location"),
46 Some(target),
47 "{what}, body: {}",
48 resp.text
49 );
50 }
51
52 /// Sessions the mock provider was asked to open, in call order.
53 fn sessions(h: &TestHarness) -> Vec<MockCheckout> {
54 h.mock_stripe
55 .as_ref()
56 .expect("mock stripe configured")
57 .checkouts()
58 }
59
60 /// Assert how many provider sessions have been opened so far.
61 fn assert_sessions(h: &TestHarness, expected: usize, what: &str) {
62 let opened = sessions(h);
63 assert_eq!(opened.len(), expected, "{what}, got {opened:?}");
64 }
65
66 /// A buyer's open charge amounts, ascending. Ascending rather than insertion
67 /// order so a test can state the exact multiset of line prices without
68 /// depending on which row landed first.
69 async fn pending_amounts(h: &TestHarness, buyer_id: db::UserId) -> Vec<i32> {
70 sqlx::query_scalar(
71 "SELECT amount_cents FROM transactions \
72 WHERE buyer_id = $1 AND status = 'pending' ORDER BY amount_cents",
73 )
74 .bind(buyer_id)
75 .fetch_all(&h.db)
76 .await
77 .expect("read pending amounts")
78 }
79
80 /// Assert the buyer's open charges are exactly these cent amounts.
81 async fn assert_pending(h: &TestHarness, buyer_id: db::UserId, expected: &[i32], what: &str) {
82 assert_eq!(pending_amounts(h, buyer_id).await, expected, "{what}");
83 }
84
85 /// The provider session id recorded on a buyer's single pending row.
86 async fn pending_session_id(h: &TestHarness, buyer_id: db::UserId) -> String {
87 sqlx::query_scalar(
88 "SELECT stripe_checkout_session_id FROM transactions \
89 WHERE buyer_id = $1 AND status = 'pending'",
90 )
91 .bind(buyer_id)
92 .fetch_one(&h.db)
93 .await
94 .expect("read pending session id")
95 }
96
97 /// Create a Stripe-connected creator with one published paid item, then log
98 /// out. Returns `(seller_id, project_id, item_id)`.
99 async fn connected_seller_with_item(
100 h: &mut TestHarness,
101 username: &str,
102 price_cents: i64,
103 ) -> (db::UserId, String, String) {
104 let setup = h
105 .create_creator_with_item(username, "audio", price_cents)
106 .await;
107 h.connect_stripe(setup.user_id, &format!("acct_test_{username}"))
108 .await;
109 h.publish_project_and_item(&setup.project_id, &setup.item_id)
110 .await;
111 h.client.post_form("/logout", "").await;
112 (setup.user_id, setup.project_id, setup.item_id)
113 }
114
115 /// Add a second published item to an existing project. The owning creator must
116 /// be logged in.
117 async fn add_published_item(
118 h: &mut TestHarness,
119 project_id: &str,
120 title: &str,
121 price_cents: i64,
122 ) -> String {
123 let resp = h
124 .client
125 .post_form(
126 &format!("/api/projects/{project_id}/items"),
127 &format!("title={title}&item_type=audio&price_cents={price_cents}"),
128 )
129 .await;
130 assert_status(&resp, 200, "create extra item");
131 let item: Value = resp.json();
132 let item_id = item["id"]
133 .as_str()
134 .expect("item id in response")
135 .to_string();
136
137 let resp = h
138 .client
139 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
140 .await;
141 assert_status(&resp, 200, "publish extra item");
142 item_id
143 }
144
145 /// Turn an existing item into a pay-what-you-want item with the given minimum.
146 async fn make_pwyw(h: &TestHarness, item_id: &str, min_cents: i32) {
147 sqlx::query("UPDATE items SET pwyw_enabled = true, pwyw_min_cents = $2 WHERE id = $1::uuid")
148 .bind(item_id)
149 .bind(min_cents)
150 .execute(&h.db)
151 .await
152 .expect("enable pwyw");
153 }
154
155 /// Add one item to the logged-in buyer's cart.
156 async fn add_to_cart(h: &mut TestHarness, item_id: &str) {
157 let resp = h
158 .client
159 .post_form(&format!("/api/cart/{item_id}"), "")
160 .await;
161 assert_status(&resp, 200, "add to cart");
162 }
163
164 // routes::stripe::checkout::item
165
166 /// The pending charge is the item's price in cents, unrounded and unscaled, and
167 /// it belongs to the session the buyer was redirected to. 1234 is chosen so a
168 /// dollars/cents mix-up (12 or 123400) and a round to the nearest dollar (1200)
169 /// each give a different answer than the correct one.
170 #[tokio::test]
171 async fn item_checkout_charges_the_listed_price_in_cents() {
172 let mut h = TestHarness::with_mocks().await;
173 let (_seller, _project, item_id) = connected_seller_with_item(&mut h, "itemseller", 1234).await;
174 let buyer_id = h
175 .signup("itembuyer", "itembuyer@test.com", "pass1234")
176 .await;
177
178 let resp = h
179 .client
180 .post_form(
181 &format!("/stripe/checkout/{item_id}"),
182 "share_contact=false",
183 )
184 .await;
185 assert_status(&resp, 303, "paid item checkout must redirect to Stripe");
186
187 let opened = sessions(&h);
188 assert_sessions(&h, 1, "one submission opens exactly one provider session");
189 assert_redirect(
190 &resp,
191 &opened[0].url,
192 "buyer goes to the session just opened",
193 );
194 assert_pending(
195 &h,
196 buyer_id,
197 &[1234],
198 "the charge is the item's exact cent price",
199 )
200 .await;
201 assert_eq!(
202 pending_session_id(&h, buyer_id).await,
203 opened[0].id,
204 "the pending row must reference the session the buyer was sent to"
205 );
206 }
207
208 /// A pay-what-you-want item is charged at the buyer's amount, not at the listed
209 /// `price_cents`. The two differ (763 vs 1234) so reading the wrong field shows.
210 #[tokio::test]
211 async fn pwyw_checkout_charges_the_buyer_chosen_amount_not_the_listed_price() {
212 let mut h = TestHarness::with_mocks().await;
213 let (_seller, _project, item_id) = connected_seller_with_item(&mut h, "pwywseller", 1234).await;
214 make_pwyw(&h, &item_id, 500).await;
215 let buyer_id = h
216 .signup("pwywbuyer", "pwywbuyer@test.com", "pass1234")
217 .await;
218
219 let path = format!("/stripe/checkout/{item_id}");
220 let resp = h
221 .client
222 .post_form(&path, "share_contact=false&amount_cents=763")
223 .await;
224 assert_status(&resp, 303, "pwyw checkout above the minimum must redirect");
225 assert_pending(
226 &h,
227 buyer_id,
228 &[763],
229 "pwyw charges the buyer's amount, not the list",
230 )
231 .await;
232 }
233
234 /// Both sides of the creator's pay-what-you-want floor: one cent under is
235 /// refused with no row written, the floor itself goes through at that amount.
236 #[tokio::test]
237 async fn pwyw_checkout_refuses_below_the_creator_minimum_and_accepts_the_minimum_itself() {
238 let mut h = TestHarness::with_mocks().await;
239 let (_seller, _project, item_id) =
240 connected_seller_with_item(&mut h, "pwywminsell", 1234).await;
241 make_pwyw(&h, &item_id, 500).await;
242 let buyer_id = h
243 .signup("pwywminbuy", "pwywminbuy@test.com", "pass1234")
244 .await;
245 let url = format!("/stripe/checkout/{item_id}");
246
247 let resp = h
248 .client
249 .post_form(&url, "share_contact=false&amount_cents=499")
250 .await;
251 assert_status(
252 &resp,
253 400,
254 "one cent under the pwyw minimum must be refused",
255 );
256 assert_pending(&h, buyer_id, &[], "a refused pwyw amount writes no charge").await;
257
258 let resp = h
259 .client
260 .post_form(&url, "share_contact=false&amount_cents=500")
261 .await;
262 assert_status(&resp, 303, "the pwyw minimum itself must be accepted");
263 assert_pending(
264 &h,
265 buyer_id,
266 &[500],
267 "the accepted amount is exact, not rounded",
268 )
269 .await;
270 }
271
272 /// Both sides of the $10,000 pay-what-you-want ceiling. The cap exists so a
273 /// mistyped amount cannot open a mega-charge, so the cent above it must fail
274 /// while the cap itself still succeeds.
275 #[tokio::test]
276 async fn pwyw_checkout_refuses_above_the_ten_thousand_dollar_cap_and_accepts_the_cap_itself() {
277 let mut h = TestHarness::with_mocks().await;
278 let (_seller, _project, item_id) =
279 connected_seller_with_item(&mut h, "pwywcapsell", 1234).await;
280 make_pwyw(&h, &item_id, 500).await;
281 let buyer_id = h
282 .signup("pwywcapbuy", "pwywcapbuy@test.com", "pass1234")
283 .await;
284 let url = format!("/stripe/checkout/{item_id}");
285
286 let resp = h
287 .client
288 .post_form(&url, "share_contact=false&amount_cents=1000001")
289 .await;
290 assert_status(&resp, 400, "one cent over the $10,000 cap must be refused");
291 assert_pending(
292 &h,
293 buyer_id,
294 &[],
295 "a refused over-cap amount writes no charge",
296 )
297 .await;
298
299 let resp = h
300 .client
301 .post_form(&url, "share_contact=false&amount_cents=1000000")
302 .await;
303 assert_status(&resp, 303, "the cap itself must be accepted");
304 assert_pending(
305 &h,
306 buyer_id,
307 &[1_000_000],
308 "the cap amount is charged exactly",
309 )
310 .await;
311 }
312
313 /// Both sides of the 50 cent USD provider minimum. Under it the buyer gets a
314 /// clean refusal and no pending row; at it the charge opens at 50 cents.
315 #[tokio::test]
316 async fn item_checkout_refuses_a_price_under_the_stripe_minimum_and_accepts_the_minimum_itself() {
317 let mut h = TestHarness::with_mocks().await;
318 let (_seller, project_id, cheap_item) = connected_seller_with_item(&mut h, "minsell", 49).await;
319
320 // Second item priced at exactly the minimum, under the same seller.
321 h.login("minsell", "password123").await;
322 let at_min_item = add_published_item(&mut h, &project_id, "At+Minimum", 50).await;
323 h.client.post_form("/logout", "").await;
324 let buyer_id = h.signup("minbuyer", "minbuyer@test.com", "pass1234").await;
325
326 let path = format!("/stripe/checkout/{cheap_item}");
327 let resp = h.client.post_form(&path, "share_contact=false").await;
328 assert_status(&resp, 400, "49 cents is under the USD provider minimum");
329 assert_pending(&h, buyer_id, &[], "a sub-minimum item writes no charge").await;
330
331 let path = format!("/stripe/checkout/{at_min_item}");
332 let resp = h.client.post_form(&path, "share_contact=false").await;
333 assert_status(
334 &resp,
335 303,
336 "50 cents is exactly the minimum and is accepted",
337 );
338 assert_pending(
339 &h,
340 buyer_id,
341 &[50],
342 "the minimum-priced item is charged at 50",
343 )
344 .await;
345 }
346
347 /// Replay of the buyer-side submission: a double-posted checkout form (a
348 /// refresh, a double click, a retried request) must leave exactly one open
349 /// charge. The second post is answered with the purchase page rather than a
350 /// second Stripe redirect, and the surviving row still points at the first
351 /// session.
352 #[tokio::test]
353 async fn repeated_item_checkout_leaves_exactly_one_pending_charge() {
354 let mut h = TestHarness::with_mocks().await;
355 let (_seller, _project, item_id) = connected_seller_with_item(&mut h, "dupeseller", 1234).await;
356 let buyer_id = h
357 .signup("dupebuyer", "dupebuyer@test.com", "pass1234")
358 .await;
359 let url = format!("/stripe/checkout/{item_id}");
360
361 let first = h.client.post_form(&url, "share_contact=false").await;
362 assert_status(&first, 303, "first checkout must redirect to Stripe");
363 let first_session = pending_session_id(&h, buyer_id).await;
364
365 let second = h.client.post_form(&url, "share_contact=false").await;
366 assert_status(&second, 303, "the replay answers with a redirect");
367 assert_redirect(
368 &second,
369 &format!("/purchase/{item_id}"),
370 "the replay lands on the purchase page, not a second session",
371 );
372 assert_pending(&h, buyer_id, &[1234], "a replay opens no second charge").await;
373 assert_eq!(
374 pending_session_id(&h, buyer_id).await,
375 first_session,
376 "the surviving pending row must still be the first session's"
377 );
378 }
379
380 /// Cancelling the pending checkout is what lets a buyer start over: the row is
381 /// gone afterwards, and a fresh submission opens a new charge at the same price
382 /// against a different session.
383 #[tokio::test]
384 async fn cancelling_a_pending_item_checkout_lets_the_buyer_start_a_fresh_one() {
385 let mut h = TestHarness::with_mocks().await;
386 let (_seller, _project, item_id) = connected_seller_with_item(&mut h, "cancelsell", 1234).await;
387 let buyer_id = h
388 .signup("cancelbuy", "cancelbuy@test.com", "pass1234")
389 .await;
390 let url = format!("/stripe/checkout/{item_id}");
391
392 let resp = h.client.post_form(&url, "share_contact=false").await;
393 assert_status(&resp, 303, "first checkout must redirect");
394 let first_session = pending_session_id(&h, buyer_id).await;
395
396 let resp = h
397 .client
398 .post_form(&format!("{url}/cancel-pending"), "")
399 .await;
400 assert_status(&resp, 303, "cancel-pending redirects to the purchase page");
401 assert_pending(&h, buyer_id, &[], "cancel-pending clears the open charge").await;
402
403 let resp = h.client.post_form(&url, "share_contact=false").await;
404 assert_status(
405 &resp,
406 303,
407 "a fresh checkout after cancelling must redirect",
408 );
409 assert_pending(&h, buyer_id, &[1234], "the fresh checkout opens one charge").await;
410 assert_ne!(
411 pending_session_id(&h, buyer_id).await,
412 first_session,
413 "the fresh checkout must be a new session, not the cancelled one"
414 );
415 }
416
417 // routes::stripe::checkout::cart
418
419 /// Two lines from one seller become one pending row each, at that line's own
420 /// price, inside a single session. The prices are distinct and neither divides
421 /// the other, so duplicating a line (1234 twice), averaging them (900) or
422 /// charging the sum once all differ from the expected pair.
423 #[tokio::test]
424 async fn cart_checkout_creates_one_pending_row_per_line_at_that_lines_exact_price() {
425 let mut h = TestHarness::with_mocks().await;
426 let (seller_id, project_id, item_a) =
427 connected_seller_with_item(&mut h, "cartlines", 1234).await;
428 h.login("cartlines", "password123").await;
429 let item_b = add_published_item(&mut h, &project_id, "Second+Line", 567).await;
430 h.client.post_form("/logout", "").await;
431
432 let buyer_id = h
433 .signup("cartlinebuy", "cartlinebuy@test.com", "pass1234")
434 .await;
435 add_to_cart(&mut h, &item_a).await;
436 add_to_cart(&mut h, &item_b).await;
437
438 let body = format!("seller_id={seller_id}&share_contact=false");
439 let resp = h.client.post_form("/stripe/checkout/cart", &body).await;
440 assert_status(&resp, 303, "cart checkout must redirect to Stripe");
441
442 let opened = sessions(&h);
443 assert_sessions(&h, 1, "one seller's cart is one provider session");
444 assert_redirect(
445 &resp,
446 &opened[0].url,
447 "buyer goes to the session just opened",
448 );
449 assert_pending(
450 &h,
451 buyer_id,
452 &[567, 1234],
453 "each line is charged at its own price",
454 )
455 .await;
456
457 let rows: Vec<String> = sqlx::query_scalar(
458 "SELECT DISTINCT stripe_checkout_session_id FROM transactions \
459 WHERE buyer_id = $1 AND status = 'pending'",
460 )
461 .bind(buyer_id)
462 .fetch_all(&h.db)
463 .await
464 .expect("read pending sessions");
465 assert_eq!(
466 rows,
467 vec![opened[0].id.clone()],
468 "both lines must hang off the single session the buyer was sent to"
469 );
470 }
471
472 /// A free line is claimed outright at zero and never reaches the provider; the
473 /// paid line is charged alone. Asserting the free row is zero and the paid row
474 /// is the full 1234 separates "claimed the free item" from "folded it into the
475 /// charge".
476 #[tokio::test]
477 async fn cart_checkout_claims_a_free_line_at_zero_and_charges_only_the_paid_line() {
478 let mut h = TestHarness::with_mocks().await;
479 let (seller_id, project_id, paid_item) =
480 connected_seller_with_item(&mut h, "cartfree", 1234).await;
481 h.login("cartfree", "password123").await;
482 let free_item = add_published_item(&mut h, &project_id, "Free+Line", 0).await;
483 h.client.post_form("/logout", "").await;
484
485 let buyer_id = h
486 .signup("cartfreebuy", "cartfreebuy@test.com", "pass1234")
487 .await;
488 add_to_cart(&mut h, &paid_item).await;
489 add_to_cart(&mut h, &free_item).await;
490
491 let body = format!("seller_id={seller_id}&share_contact=false");
492 let resp = h.client.post_form("/stripe/checkout/cart", &body).await;
493 assert_status(&resp, 303, "mixed free/paid cart checkout must redirect");
494 assert_pending(
495 &h,
496 buyer_id,
497 &[1234],
498 "only the paid line is charged, at its price",
499 )
500 .await;
501
502 let free_amount: i32 = sqlx::query_scalar(
503 "SELECT amount_cents FROM transactions \
504 WHERE buyer_id = $1 AND item_id = $2::uuid AND status = 'completed'",
505 )
506 .bind(buyer_id)
507 .bind(&free_item)
508 .fetch_one(&h.db)
509 .await
510 .expect("free line must be claimed as a completed transaction");
511 assert_eq!(
512 free_amount, 0,
513 "a free line is claimed at zero cents, not at the paid line's price"
514 );
515
516 let still_carted: i64 = sqlx::query_scalar(
517 "SELECT COUNT(*) FROM cart_items WHERE user_id = $1 AND item_id = $2::uuid",
518 )
519 .bind(buyer_id)
520 .bind(&free_item)
521 .fetch_one(&h.db)
522 .await
523 .expect("read cart");
524 assert_eq!(
525 still_carted, 0,
526 "a claimed free line is removed from the cart immediately"
527 );
528 }
529
530 /// Both sides of the provider minimum on the cart total. Two 20 cent lines
531 /// total 40 and must be refused before any session is opened; raising one line
532 /// to 30 makes the total exactly 50 and the checkout goes through with both
533 /// line prices intact.
534 #[tokio::test]
535 async fn cart_checkout_refuses_a_total_under_the_stripe_minimum_and_accepts_the_minimum_itself() {
536 let mut h = TestHarness::with_mocks().await;
537 let (seller_id, project_id, item_a) = connected_seller_with_item(&mut h, "cartmin", 20).await;
538 h.login("cartmin", "password123").await;
539 let item_b = add_published_item(&mut h, &project_id, "Cheap+Two", 20).await;
540 h.client.post_form("/logout", "").await;
541
542 let buyer_id = h
543 .signup("cartminbuy", "cartminbuy@test.com", "pass1234")
544 .await;
545 add_to_cart(&mut h, &item_a).await;
546 add_to_cart(&mut h, &item_b).await;
547 let body = format!("seller_id={seller_id}&share_contact=false");
548
549 let resp = h.client.post_form("/stripe/checkout/cart", &body).await;
550 assert_status(&resp, 400, "a 40 cent cart total is under the USD minimum");
551 assert_pending(&h, buyer_id, &[], "a refused cart writes no pending charge").await;
552 assert_sessions(
553 &h,
554 0,
555 "the minimum is checked before the provider is called",
556 );
557
558 // 20 + 30 = 50, exactly the minimum.
559 sqlx::query("UPDATE items SET price_cents = 30 WHERE id = $1::uuid")
560 .bind(&item_b)
561 .execute(&h.db)
562 .await
563 .expect("reprice second line");
564
565 let resp = h.client.post_form("/stripe/checkout/cart", &body).await;
566 assert_status(&resp, 303, "a total of exactly the minimum is accepted");
567 assert_pending(&h, buyer_id, &[20, 30], "each line keeps its own price").await;
568 }
569
570 /// Replay of the cart submission. The second post is refused on the pending
571 /// pre-check, before a second provider session exists, and the rows written by
572 /// the first submission are untouched.
573 #[tokio::test]
574 async fn repeated_cart_checkout_leaves_the_first_sessions_pending_rows_untouched() {
575 let mut h = TestHarness::with_mocks().await;
576 let (seller_id, project_id, item_a) =
577 connected_seller_with_item(&mut h, "cartdupe", 1234).await;
578 h.login("cartdupe", "password123").await;
579 let item_b = add_published_item(&mut h, &project_id, "Dupe+Two", 567).await;
580 h.client.post_form("/logout", "").await;
581
582 let buyer_id = h
583 .signup("cartdupebuy", "cartdupebuy@test.com", "pass1234")
584 .await;
585 add_to_cart(&mut h, &item_a).await;
586 add_to_cart(&mut h, &item_b).await;
587 let body = format!("seller_id={seller_id}&share_contact=false");
588
589 let first = h.client.post_form("/stripe/checkout/cart", &body).await;
590 assert_status(&first, 303, "first cart checkout must redirect");
591
592 let second = h.client.post_form("/stripe/checkout/cart", &body).await;
593 assert_status(&second, 400, "a replayed cart checkout must be refused");
594 assert_pending(
595 &h,
596 buyer_id,
597 &[567, 1234],
598 "the replay duplicates no pending row",
599 )
600 .await;
601 assert_sessions(&h, 1, "the replay is refused before a second session opens");
602 }
603
604 /// A cart spanning two sellers is charged one seller at a time: the first
605 /// submission opens a single session for one seller's line only, and the return
606 /// from that session drains the queue into a second session for the other
607 /// seller. Each pending row carries its own seller's price and session id.
608 #[tokio::test]
609 async fn cart_checkout_all_charges_each_seller_in_its_own_session() {
610 let mut h = TestHarness::with_mocks().await;
611 let (_one, _p1, item_one) = connected_seller_with_item(&mut h, "sellerone", 1234).await;
612 let (_two, _p2, item_two) = connected_seller_with_item(&mut h, "sellertwo", 567).await;
613
614 let buyer_id = h.signup("allbuyer", "allbuyer@test.com", "pass1234").await;
615 add_to_cart(&mut h, &item_one).await;
616 add_to_cart(&mut h, &item_two).await;
617
618 let resp = h
619 .client
620 .post_form("/stripe/checkout/cart/all", "share_contact=false")
621 .await;
622 assert_status(&resp, 303, "checkout-all redirects to the first session");
623 assert_sessions(&h, 1, "only the first seller is charged before the return");
624
625 // Which seller goes first is the cart's ordering, not a contract; that
626 // exactly one line is pending, at one of the two real prices, is.
627 let after_first = pending_amounts(&h, buyer_id).await;
628 assert!(
629 after_first == vec![1234] || after_first == vec![567],
630 "exactly one seller's line, at that seller's price, may be pending after \
631 the first leg, got {after_first:?}"
632 );
633
634 // Returning from the first session drains the queued second seller.
635 let resp = h.client.get("/stripe/success").await;
636 assert_status(&resp, 303, "the success return continues the queued seller");
637 let opened = sessions(&h);
638 assert_sessions(&h, 2, "the queued seller gets a second session");
639 assert_redirect(&resp, &opened[1].url, "buyer goes to the second session");
640 assert_pending(
641 &h,
642 buyer_id,
643 &[567, 1234],
644 "each seller's line is at its own price",
645 )
646 .await;
647
648 let distinct: i64 = sqlx::query_scalar(
649 "SELECT COUNT(DISTINCT stripe_checkout_session_id) FROM transactions \
650 WHERE buyer_id = $1 AND status = 'pending'",
651 )
652 .bind(buyer_id)
653 .fetch_one(&h.db)
654 .await
655 .expect("count sessions");
656 assert_eq!(
657 distinct, 2,
658 "two sellers means two sessions; one shared session would settle to the \
659 wrong connected account"
660 );
661 }
662
663 /// A seller who cannot accept charges is refused before any money moves: no
664 /// session, no pending row.
665 #[tokio::test]
666 async fn cart_checkout_refuses_a_seller_who_cannot_accept_charges() {
667 let mut h = TestHarness::with_mocks().await;
668 let setup = h.create_creator_with_item("nostripe", "audio", 1234).await;
669 h.publish_project_and_item(&setup.project_id, &setup.item_id)
670 .await;
671 h.client.post_form("/logout", "").await;
672
673 let buyer_id = h
674 .signup("nostripebuy", "nostripebuy@test.com", "pass1234")
675 .await;
676 add_to_cart(&mut h, &setup.item_id).await;
677
678 let resp = h
679 .client
680 .post_form(
681 "/stripe/checkout/cart",
682 &format!("seller_id={}&share_contact=false", setup.user_id),
683 )
684 .await;
685 assert_status(&resp, 400, "a seller with no connected account is refused");
686 assert_pending(
687 &h,
688 buyer_id,
689 &[],
690 "a seller who cannot be paid gets no charge",
691 )
692 .await;
693 assert_sessions(&h, 0, "no provider session may be opened");
694 }
695
696 // routes::stripe::checkout::tips
697