//! Route-layer contract tests for the buyer-facing purchase handlers: //! `routes::stripe::checkout::item` and `routes::stripe::checkout::cart`. //! //! These two routes decide, from a form post, how many cents a buyer is about //! to be charged. Every test pins that number exactly: the pending row a handler //! writes before redirecting to the provider must carry the amount the provider //! session was built from, in integer cents. A handler that shipped dollars //! where cents were meant, or the listed price where the buyer's own //! pay-what-you-want amount was meant, would pass a test that only asserted "a //! session was created" and fails every test below. //! //! Oracle note: the mock provider records a session id and URL but not the //! amount, so the amount is read back from the row the handler wrote in the same //! breath (transactions.amount_cents) and tied to the provider by asserting that //! row's session id is the session the buyer was redirected to. Both halves are //! needed: the amount alone would not prove it belongs to this session, the //! session alone would not prove the amount. //! //! The tip handler is `stripe_tip_checkout_routes`. //! //! Delete this file and two things stop being checked anywhere: the per-line //! price fidelity of a multi-item cart, and that re-submitting a checkout form //! does not open a second charge for the same item. use crate::harness::TestHarness; use crate::harness::client::TestResponse; use crate::harness::stripe::MockCheckout; use makenotwork::db; use serde_json::Value; // Helpers /// Assert an exact status, reporting the body when it does not hold. fn assert_status(resp: &TestResponse, expected: u16, what: &str) { assert_eq!( resp.status, expected, "{what}: got {} with body {}", resp.status, resp.text ); } /// Assert the exact redirect target, reporting the body when it does not hold. fn assert_redirect(resp: &TestResponse, target: &str, what: &str) { assert_eq!( resp.header("location"), Some(target), "{what}, body: {}", resp.text ); } /// Sessions the mock provider was asked to open, in call order. fn sessions(h: &TestHarness) -> Vec { h.mock_stripe .as_ref() .expect("mock stripe configured") .checkouts() } /// Assert how many provider sessions have been opened so far. fn assert_sessions(h: &TestHarness, expected: usize, what: &str) { let opened = sessions(h); assert_eq!(opened.len(), expected, "{what}, got {opened:?}"); } /// A buyer's open charge amounts, ascending. Ascending rather than insertion /// order so a test can state the exact multiset of line prices without /// depending on which row landed first. async fn pending_amounts(h: &TestHarness, buyer_id: db::UserId) -> Vec { sqlx::query_scalar( "SELECT amount_cents FROM transactions \ WHERE buyer_id = $1 AND status = 'pending' ORDER BY amount_cents", ) .bind(buyer_id) .fetch_all(&h.db) .await .expect("read pending amounts") } /// Assert the buyer's open charges are exactly these cent amounts. async fn assert_pending(h: &TestHarness, buyer_id: db::UserId, expected: &[i32], what: &str) { assert_eq!(pending_amounts(h, buyer_id).await, expected, "{what}"); } /// The provider session id recorded on a buyer's single pending row. async fn pending_session_id(h: &TestHarness, buyer_id: db::UserId) -> String { sqlx::query_scalar( "SELECT stripe_checkout_session_id FROM transactions \ WHERE buyer_id = $1 AND status = 'pending'", ) .bind(buyer_id) .fetch_one(&h.db) .await .expect("read pending session id") } /// Create a Stripe-connected creator with one published paid item, then log /// out. Returns `(seller_id, project_id, item_id)`. async fn connected_seller_with_item( h: &mut TestHarness, username: &str, price_cents: i64, ) -> (db::UserId, String, String) { let setup = h .create_creator_with_item(username, "audio", price_cents) .await; h.connect_stripe(setup.user_id, &format!("acct_test_{username}")) .await; h.publish_project_and_item(&setup.project_id, &setup.item_id) .await; h.client.post_form("/logout", "").await; (setup.user_id, setup.project_id, setup.item_id) } /// Add a second published item to an existing project. The owning creator must /// be logged in. async fn add_published_item( h: &mut TestHarness, project_id: &str, title: &str, price_cents: i64, ) -> String { let resp = h .client .post_form( &format!("/api/projects/{project_id}/items"), &format!("title={title}&item_type=audio&price_cents={price_cents}"), ) .await; assert_status(&resp, 200, "create extra item"); let item: Value = resp.json(); let item_id = item["id"] .as_str() .expect("item id in response") .to_string(); let resp = h .client .put_form(&format!("/api/items/{item_id}"), "is_public=true") .await; assert_status(&resp, 200, "publish extra item"); item_id } /// Turn an existing item into a pay-what-you-want item with the given minimum. async fn make_pwyw(h: &TestHarness, item_id: &str, min_cents: i32) { sqlx::query("UPDATE items SET pwyw_enabled = true, pwyw_min_cents = $2 WHERE id = $1::uuid") .bind(item_id) .bind(min_cents) .execute(&h.db) .await .expect("enable pwyw"); } /// Add one item to the logged-in buyer's cart. async fn add_to_cart(h: &mut TestHarness, item_id: &str) { let resp = h .client .post_form(&format!("/api/cart/{item_id}"), "") .await; assert_status(&resp, 200, "add to cart"); } // routes::stripe::checkout::item /// The pending charge is the item's price in cents, unrounded and unscaled, and /// it belongs to the session the buyer was redirected to. 1234 is chosen so a /// dollars/cents mix-up (12 or 123400) and a round to the nearest dollar (1200) /// each give a different answer than the correct one. #[tokio::test] async fn item_checkout_charges_the_listed_price_in_cents() { let mut h = TestHarness::with_mocks().await; let (_seller, _project, item_id) = connected_seller_with_item(&mut h, "itemseller", 1234).await; let buyer_id = h .signup("itembuyer", "itembuyer@test.com", "pass1234") .await; let resp = h .client .post_form( &format!("/stripe/checkout/{item_id}"), "share_contact=false", ) .await; assert_status(&resp, 303, "paid item checkout must redirect to Stripe"); let opened = sessions(&h); assert_sessions(&h, 1, "one submission opens exactly one provider session"); assert_redirect( &resp, &opened[0].url, "buyer goes to the session just opened", ); assert_pending( &h, buyer_id, &[1234], "the charge is the item's exact cent price", ) .await; assert_eq!( pending_session_id(&h, buyer_id).await, opened[0].id, "the pending row must reference the session the buyer was sent to" ); } /// A pay-what-you-want item is charged at the buyer's amount, not at the listed /// `price_cents`. The two differ (763 vs 1234) so reading the wrong field shows. #[tokio::test] async fn pwyw_checkout_charges_the_buyer_chosen_amount_not_the_listed_price() { let mut h = TestHarness::with_mocks().await; let (_seller, _project, item_id) = connected_seller_with_item(&mut h, "pwywseller", 1234).await; make_pwyw(&h, &item_id, 500).await; let buyer_id = h .signup("pwywbuyer", "pwywbuyer@test.com", "pass1234") .await; let path = format!("/stripe/checkout/{item_id}"); let resp = h .client .post_form(&path, "share_contact=false&amount_cents=763") .await; assert_status(&resp, 303, "pwyw checkout above the minimum must redirect"); assert_pending( &h, buyer_id, &[763], "pwyw charges the buyer's amount, not the list", ) .await; } /// Both sides of the creator's pay-what-you-want floor: one cent under is /// refused with no row written, the floor itself goes through at that amount. #[tokio::test] async fn pwyw_checkout_refuses_below_the_creator_minimum_and_accepts_the_minimum_itself() { let mut h = TestHarness::with_mocks().await; let (_seller, _project, item_id) = connected_seller_with_item(&mut h, "pwywminsell", 1234).await; make_pwyw(&h, &item_id, 500).await; let buyer_id = h .signup("pwywminbuy", "pwywminbuy@test.com", "pass1234") .await; let url = format!("/stripe/checkout/{item_id}"); let resp = h .client .post_form(&url, "share_contact=false&amount_cents=499") .await; assert_status( &resp, 400, "one cent under the pwyw minimum must be refused", ); assert_pending(&h, buyer_id, &[], "a refused pwyw amount writes no charge").await; let resp = h .client .post_form(&url, "share_contact=false&amount_cents=500") .await; assert_status(&resp, 303, "the pwyw minimum itself must be accepted"); assert_pending( &h, buyer_id, &[500], "the accepted amount is exact, not rounded", ) .await; } /// Both sides of the $10,000 pay-what-you-want ceiling. The cap exists so a /// mistyped amount cannot open a mega-charge, so the cent above it must fail /// while the cap itself still succeeds. #[tokio::test] async fn pwyw_checkout_refuses_above_the_ten_thousand_dollar_cap_and_accepts_the_cap_itself() { let mut h = TestHarness::with_mocks().await; let (_seller, _project, item_id) = connected_seller_with_item(&mut h, "pwywcapsell", 1234).await; make_pwyw(&h, &item_id, 500).await; let buyer_id = h .signup("pwywcapbuy", "pwywcapbuy@test.com", "pass1234") .await; let url = format!("/stripe/checkout/{item_id}"); let resp = h .client .post_form(&url, "share_contact=false&amount_cents=1000001") .await; assert_status(&resp, 400, "one cent over the $10,000 cap must be refused"); assert_pending( &h, buyer_id, &[], "a refused over-cap amount writes no charge", ) .await; let resp = h .client .post_form(&url, "share_contact=false&amount_cents=1000000") .await; assert_status(&resp, 303, "the cap itself must be accepted"); assert_pending( &h, buyer_id, &[1_000_000], "the cap amount is charged exactly", ) .await; } /// Both sides of the 50 cent USD provider minimum. Under it the buyer gets a /// clean refusal and no pending row; at it the charge opens at 50 cents. #[tokio::test] async fn item_checkout_refuses_a_price_under_the_stripe_minimum_and_accepts_the_minimum_itself() { let mut h = TestHarness::with_mocks().await; let (_seller, project_id, cheap_item) = connected_seller_with_item(&mut h, "minsell", 49).await; // Second item priced at exactly the minimum, under the same seller. h.login("minsell", "password123").await; let at_min_item = add_published_item(&mut h, &project_id, "At+Minimum", 50).await; h.client.post_form("/logout", "").await; let buyer_id = h.signup("minbuyer", "minbuyer@test.com", "pass1234").await; let path = format!("/stripe/checkout/{cheap_item}"); let resp = h.client.post_form(&path, "share_contact=false").await; assert_status(&resp, 400, "49 cents is under the USD provider minimum"); assert_pending(&h, buyer_id, &[], "a sub-minimum item writes no charge").await; let path = format!("/stripe/checkout/{at_min_item}"); let resp = h.client.post_form(&path, "share_contact=false").await; assert_status( &resp, 303, "50 cents is exactly the minimum and is accepted", ); assert_pending( &h, buyer_id, &[50], "the minimum-priced item is charged at 50", ) .await; } /// Replay of the buyer-side submission: a double-posted checkout form (a /// refresh, a double click, a retried request) must leave exactly one open /// charge. The second post is answered with the purchase page rather than a /// second Stripe redirect, and the surviving row still points at the first /// session. #[tokio::test] async fn repeated_item_checkout_leaves_exactly_one_pending_charge() { let mut h = TestHarness::with_mocks().await; let (_seller, _project, item_id) = connected_seller_with_item(&mut h, "dupeseller", 1234).await; let buyer_id = h .signup("dupebuyer", "dupebuyer@test.com", "pass1234") .await; let url = format!("/stripe/checkout/{item_id}"); let first = h.client.post_form(&url, "share_contact=false").await; assert_status(&first, 303, "first checkout must redirect to Stripe"); let first_session = pending_session_id(&h, buyer_id).await; let second = h.client.post_form(&url, "share_contact=false").await; assert_status(&second, 303, "the replay answers with a redirect"); assert_redirect( &second, &format!("/purchase/{item_id}"), "the replay lands on the purchase page, not a second session", ); assert_pending(&h, buyer_id, &[1234], "a replay opens no second charge").await; assert_eq!( pending_session_id(&h, buyer_id).await, first_session, "the surviving pending row must still be the first session's" ); } /// Cancelling the pending checkout is what lets a buyer start over: the row is /// gone afterwards, and a fresh submission opens a new charge at the same price /// against a different session. #[tokio::test] async fn cancelling_a_pending_item_checkout_lets_the_buyer_start_a_fresh_one() { let mut h = TestHarness::with_mocks().await; let (_seller, _project, item_id) = connected_seller_with_item(&mut h, "cancelsell", 1234).await; let buyer_id = h .signup("cancelbuy", "cancelbuy@test.com", "pass1234") .await; let url = format!("/stripe/checkout/{item_id}"); let resp = h.client.post_form(&url, "share_contact=false").await; assert_status(&resp, 303, "first checkout must redirect"); let first_session = pending_session_id(&h, buyer_id).await; let resp = h .client .post_form(&format!("{url}/cancel-pending"), "") .await; assert_status(&resp, 303, "cancel-pending redirects to the purchase page"); assert_pending(&h, buyer_id, &[], "cancel-pending clears the open charge").await; let resp = h.client.post_form(&url, "share_contact=false").await; assert_status( &resp, 303, "a fresh checkout after cancelling must redirect", ); assert_pending(&h, buyer_id, &[1234], "the fresh checkout opens one charge").await; assert_ne!( pending_session_id(&h, buyer_id).await, first_session, "the fresh checkout must be a new session, not the cancelled one" ); } // routes::stripe::checkout::cart /// Two lines from one seller become one pending row each, at that line's own /// price, inside a single session. The prices are distinct and neither divides /// the other, so duplicating a line (1234 twice), averaging them (900) or /// charging the sum once all differ from the expected pair. #[tokio::test] async fn cart_checkout_creates_one_pending_row_per_line_at_that_lines_exact_price() { let mut h = TestHarness::with_mocks().await; let (seller_id, project_id, item_a) = connected_seller_with_item(&mut h, "cartlines", 1234).await; h.login("cartlines", "password123").await; let item_b = add_published_item(&mut h, &project_id, "Second+Line", 567).await; h.client.post_form("/logout", "").await; let buyer_id = h .signup("cartlinebuy", "cartlinebuy@test.com", "pass1234") .await; add_to_cart(&mut h, &item_a).await; add_to_cart(&mut h, &item_b).await; let body = format!("seller_id={seller_id}&share_contact=false"); let resp = h.client.post_form("/stripe/checkout/cart", &body).await; assert_status(&resp, 303, "cart checkout must redirect to Stripe"); let opened = sessions(&h); assert_sessions(&h, 1, "one seller's cart is one provider session"); assert_redirect( &resp, &opened[0].url, "buyer goes to the session just opened", ); assert_pending( &h, buyer_id, &[567, 1234], "each line is charged at its own price", ) .await; let rows: Vec = sqlx::query_scalar( "SELECT DISTINCT stripe_checkout_session_id FROM transactions \ WHERE buyer_id = $1 AND status = 'pending'", ) .bind(buyer_id) .fetch_all(&h.db) .await .expect("read pending sessions"); assert_eq!( rows, vec![opened[0].id.clone()], "both lines must hang off the single session the buyer was sent to" ); } /// A free line is claimed outright at zero and never reaches the provider; the /// paid line is charged alone. Asserting the free row is zero and the paid row /// is the full 1234 separates "claimed the free item" from "folded it into the /// charge". #[tokio::test] async fn cart_checkout_claims_a_free_line_at_zero_and_charges_only_the_paid_line() { let mut h = TestHarness::with_mocks().await; let (seller_id, project_id, paid_item) = connected_seller_with_item(&mut h, "cartfree", 1234).await; h.login("cartfree", "password123").await; let free_item = add_published_item(&mut h, &project_id, "Free+Line", 0).await; h.client.post_form("/logout", "").await; let buyer_id = h .signup("cartfreebuy", "cartfreebuy@test.com", "pass1234") .await; add_to_cart(&mut h, &paid_item).await; add_to_cart(&mut h, &free_item).await; let body = format!("seller_id={seller_id}&share_contact=false"); let resp = h.client.post_form("/stripe/checkout/cart", &body).await; assert_status(&resp, 303, "mixed free/paid cart checkout must redirect"); assert_pending( &h, buyer_id, &[1234], "only the paid line is charged, at its price", ) .await; let free_amount: i32 = sqlx::query_scalar( "SELECT amount_cents FROM transactions \ WHERE buyer_id = $1 AND item_id = $2::uuid AND status = 'completed'", ) .bind(buyer_id) .bind(&free_item) .fetch_one(&h.db) .await .expect("free line must be claimed as a completed transaction"); assert_eq!( free_amount, 0, "a free line is claimed at zero cents, not at the paid line's price" ); let still_carted: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM cart_items WHERE user_id = $1 AND item_id = $2::uuid", ) .bind(buyer_id) .bind(&free_item) .fetch_one(&h.db) .await .expect("read cart"); assert_eq!( still_carted, 0, "a claimed free line is removed from the cart immediately" ); } /// Both sides of the provider minimum on the cart total. Two 20 cent lines /// total 40 and must be refused before any session is opened; raising one line /// to 30 makes the total exactly 50 and the checkout goes through with both /// line prices intact. #[tokio::test] async fn cart_checkout_refuses_a_total_under_the_stripe_minimum_and_accepts_the_minimum_itself() { let mut h = TestHarness::with_mocks().await; let (seller_id, project_id, item_a) = connected_seller_with_item(&mut h, "cartmin", 20).await; h.login("cartmin", "password123").await; let item_b = add_published_item(&mut h, &project_id, "Cheap+Two", 20).await; h.client.post_form("/logout", "").await; let buyer_id = h .signup("cartminbuy", "cartminbuy@test.com", "pass1234") .await; add_to_cart(&mut h, &item_a).await; add_to_cart(&mut h, &item_b).await; let body = format!("seller_id={seller_id}&share_contact=false"); let resp = h.client.post_form("/stripe/checkout/cart", &body).await; assert_status(&resp, 400, "a 40 cent cart total is under the USD minimum"); assert_pending(&h, buyer_id, &[], "a refused cart writes no pending charge").await; assert_sessions( &h, 0, "the minimum is checked before the provider is called", ); // 20 + 30 = 50, exactly the minimum. sqlx::query("UPDATE items SET price_cents = 30 WHERE id = $1::uuid") .bind(&item_b) .execute(&h.db) .await .expect("reprice second line"); let resp = h.client.post_form("/stripe/checkout/cart", &body).await; assert_status(&resp, 303, "a total of exactly the minimum is accepted"); assert_pending(&h, buyer_id, &[20, 30], "each line keeps its own price").await; } /// Replay of the cart submission. The second post is refused on the pending /// pre-check, before a second provider session exists, and the rows written by /// the first submission are untouched. #[tokio::test] async fn repeated_cart_checkout_leaves_the_first_sessions_pending_rows_untouched() { let mut h = TestHarness::with_mocks().await; let (seller_id, project_id, item_a) = connected_seller_with_item(&mut h, "cartdupe", 1234).await; h.login("cartdupe", "password123").await; let item_b = add_published_item(&mut h, &project_id, "Dupe+Two", 567).await; h.client.post_form("/logout", "").await; let buyer_id = h .signup("cartdupebuy", "cartdupebuy@test.com", "pass1234") .await; add_to_cart(&mut h, &item_a).await; add_to_cart(&mut h, &item_b).await; let body = format!("seller_id={seller_id}&share_contact=false"); let first = h.client.post_form("/stripe/checkout/cart", &body).await; assert_status(&first, 303, "first cart checkout must redirect"); let second = h.client.post_form("/stripe/checkout/cart", &body).await; assert_status(&second, 400, "a replayed cart checkout must be refused"); assert_pending( &h, buyer_id, &[567, 1234], "the replay duplicates no pending row", ) .await; assert_sessions(&h, 1, "the replay is refused before a second session opens"); } /// A cart spanning two sellers is charged one seller at a time: the first /// submission opens a single session for one seller's line only, and the return /// from that session drains the queue into a second session for the other /// seller. Each pending row carries its own seller's price and session id. #[tokio::test] async fn cart_checkout_all_charges_each_seller_in_its_own_session() { let mut h = TestHarness::with_mocks().await; let (_one, _p1, item_one) = connected_seller_with_item(&mut h, "sellerone", 1234).await; let (_two, _p2, item_two) = connected_seller_with_item(&mut h, "sellertwo", 567).await; let buyer_id = h.signup("allbuyer", "allbuyer@test.com", "pass1234").await; add_to_cart(&mut h, &item_one).await; add_to_cart(&mut h, &item_two).await; let resp = h .client .post_form("/stripe/checkout/cart/all", "share_contact=false") .await; assert_status(&resp, 303, "checkout-all redirects to the first session"); assert_sessions(&h, 1, "only the first seller is charged before the return"); // Which seller goes first is the cart's ordering, not a contract; that // exactly one line is pending, at one of the two real prices, is. let after_first = pending_amounts(&h, buyer_id).await; assert!( after_first == vec![1234] || after_first == vec![567], "exactly one seller's line, at that seller's price, may be pending after \ the first leg, got {after_first:?}" ); // Returning from the first session drains the queued second seller. let resp = h.client.get("/stripe/success").await; assert_status(&resp, 303, "the success return continues the queued seller"); let opened = sessions(&h); assert_sessions(&h, 2, "the queued seller gets a second session"); assert_redirect(&resp, &opened[1].url, "buyer goes to the second session"); assert_pending( &h, buyer_id, &[567, 1234], "each seller's line is at its own price", ) .await; let distinct: i64 = sqlx::query_scalar( "SELECT COUNT(DISTINCT stripe_checkout_session_id) FROM transactions \ WHERE buyer_id = $1 AND status = 'pending'", ) .bind(buyer_id) .fetch_one(&h.db) .await .expect("count sessions"); assert_eq!( distinct, 2, "two sellers means two sessions; one shared session would settle to the \ wrong connected account" ); } /// A seller who cannot accept charges is refused before any money moves: no /// session, no pending row. #[tokio::test] async fn cart_checkout_refuses_a_seller_who_cannot_accept_charges() { let mut h = TestHarness::with_mocks().await; let setup = h.create_creator_with_item("nostripe", "audio", 1234).await; h.publish_project_and_item(&setup.project_id, &setup.item_id) .await; h.client.post_form("/logout", "").await; let buyer_id = h .signup("nostripebuy", "nostripebuy@test.com", "pass1234") .await; add_to_cart(&mut h, &setup.item_id).await; let resp = h .client .post_form( "/stripe/checkout/cart", &format!("seller_id={}&share_contact=false", setup.user_id), ) .await; assert_status(&resp, 400, "a seller with no connected account is refused"); assert_pending( &h, buyer_id, &[], "a seller who cannot be paid gets no charge", ) .await; assert_sessions(&h, 0, "no provider session may be opened"); } // routes::stripe::checkout::tips