| 1178 |
1178 |
|
assert_eq!(use_count, 1, "a completed cart checkout reserves exactly one promo use");
|
| 1179 |
1179 |
|
}
|
| 1180 |
1180 |
|
|
|
1181 |
+ |
/// An ALL-FREE cart (a 100%-off promo zeroes every line) must still consume
|
|
1182 |
+ |
/// exactly one promo use, and a max_uses-limited code must be exhausted after
|
|
1183 |
+ |
/// it. Regression for the audit finding where the free-cart early-return fired
|
|
1184 |
+ |
/// before the promo reservation, letting a limited/100%-off code be redeemed
|
|
1185 |
+ |
/// unlimited times via cart checkout.
|
|
1186 |
+ |
#[tokio::test]
|
|
1187 |
+ |
async fn cart_checkout_all_free_promo_consumes_one_use() {
|
|
1188 |
+ |
let mut h = TestHarness::with_mocks().await;
|
|
1189 |
+ |
let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await;
|
|
1190 |
+ |
|
|
1191 |
+ |
// 100%-off, single-use code (direct SQL — the API form doesn't take max_uses).
|
|
1192 |
+ |
sqlx::query(
|
|
1193 |
+ |
"INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, max_uses) \
|
|
1194 |
+ |
VALUES ($1, 'CARTFREE', 'discount', 'percentage', 100, 0, 1)",
|
|
1195 |
+ |
)
|
|
1196 |
+ |
.bind(seller_id)
|
|
1197 |
+ |
.execute(&h.db)
|
|
1198 |
+ |
.await
|
|
1199 |
+ |
.unwrap();
|
|
1200 |
+ |
h.client.post_form("/logout", "").await;
|
|
1201 |
+ |
|
|
1202 |
+ |
// First buyer: all-free cart checkout claims the item and burns the one use.
|
|
1203 |
+ |
let buyer1 = h.signup("cartfree1", "cartfree1@test.com", "pass1234").await;
|
|
1204 |
+ |
h.client.post_form(&format!("/api/cart/{}", item_id), "").await;
|
|
1205 |
+ |
let resp = h.client.post_form(
|
|
1206 |
+ |
"/stripe/checkout/cart",
|
|
1207 |
+ |
&format!("seller_id={}&share_contact=false&promo_code=CARTFREE", seller_id),
|
|
1208 |
+ |
).await;
|
|
1209 |
+ |
assert!(
|
|
1210 |
+ |
resp.status.is_redirection() || resp.status.is_success(),
|
|
1211 |
+ |
"all-free cart checkout should succeed: {} {}", resp.status, resp.text
|
|
1212 |
+ |
);
|
|
1213 |
+ |
// Free claim recorded (a $0 completed transaction), no Stripe session.
|
|
1214 |
+ |
let amount: i32 = sqlx::query_scalar(
|
|
1215 |
+ |
"SELECT amount_cents FROM transactions WHERE buyer_id = $1",
|
|
1216 |
+ |
)
|
|
1217 |
+ |
.bind(buyer1)
|
|
1218 |
+ |
.fetch_one(&h.db)
|
|
1219 |
+ |
.await
|
|
1220 |
+ |
.unwrap();
|
|
1221 |
+ |
assert_eq!(amount, 0, "all-free cart should record a $0 claim");
|
|
1222 |
+ |
|
|
1223 |
+ |
let use_count: i32 = sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'CARTFREE'")
|
|
1224 |
+ |
.fetch_one(&h.db)
|
|
1225 |
+ |
.await
|
|
1226 |
+ |
.unwrap();
|
|
1227 |
+ |
assert_eq!(use_count, 1, "an all-free cart checkout must consume exactly one promo use");
|
|
1228 |
+ |
|
|
1229 |
+ |
// Second buyer: the code is now exhausted; the all-free cart must be rejected
|
|
1230 |
+ |
// and the use_count must not move past its max.
|
|
1231 |
+ |
h.client.post_form("/logout", "").await;
|
|
1232 |
+ |
let _buyer2 = h.signup("cartfree2", "cartfree2@test.com", "pass1234").await;
|
|
1233 |
+ |
h.client.post_form(&format!("/api/cart/{}", item_id), "").await;
|
|
1234 |
+ |
let resp = h.client.post_form(
|
|
1235 |
+ |
"/stripe/checkout/cart",
|
|
1236 |
+ |
&format!("seller_id={}&share_contact=false&promo_code=CARTFREE", seller_id),
|
|
1237 |
+ |
).await;
|
|
1238 |
+ |
assert_eq!(
|
|
1239 |
+ |
resp.status.as_u16(), 400,
|
|
1240 |
+ |
"exhausted code on an all-free cart should be rejected: {}", resp.text
|
|
1241 |
+ |
);
|
|
1242 |
+ |
let use_count: i32 = sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'CARTFREE'")
|
|
1243 |
+ |
.fetch_one(&h.db)
|
|
1244 |
+ |
.await
|
|
1245 |
+ |
.unwrap();
|
|
1246 |
+ |
assert_eq!(use_count, 1, "exhausted code must not exceed its max_uses");
|
|
1247 |
+ |
|
|
1248 |
+ |
let mock_stripe = h.mock_stripe.as_ref().unwrap();
|
|
1249 |
+ |
assert_eq!(mock_stripe.checkouts().len(), 0, "no Stripe session for an all-free cart");
|
|
1250 |
+ |
}
|
|
1251 |
+ |
|
| 1181 |
1252 |
|
/// A promo that drops the cart total below the Stripe minimum is rejected and
|
| 1182 |
1253 |
|
/// must NOT burn a promo use. Pins the cart core's "reserve only after the
|
| 1183 |
1254 |
|
/// min-charge gate" ordering (the gate runs before reservation).
|