mod common; use std::collections::HashMap; use common::{ add_to_cart, client, create_shop, login_admin, make_shop_owner, register_customer, setup_sellable, spawn_app, TestApp, }; use serial_test::serial; use uuid::Uuid; fn template_body(amount: i64, threshold: i64, stock: i32) -> serde_json::Value { serde_json::json!({ "title": {"en": "Test coupon", "zh": "测试优惠券"}, "amount_minor": amount, "threshold_minor": threshold, "currency": "USD", "stock": stock, "enabled": true, "starts_at": "2020-01-01T00:00:00Z", "ends_at": "2999-01-01T00:00:00Z", }) } async fn create_template(app: &TestApp, owner: &str, body: serde_json::Value) -> String { let res = client() .post(app.url("/api/shop/coupon-templates")) .bearer_auth(owner) .json(&body) .send() .await .unwrap(); assert_eq!(res.status(), 201, "create template: {:?}", res.text().await); res.json::().await.unwrap()["id"] .as_str() .unwrap() .to_string() } async fn claim(app: &TestApp, token: &str, template_id: &str) -> reqwest::StatusCode { client() .post(app.url("/api/me/coupons")) .bearer_auth(token) .json(&serde_json::json!({ "template_id": template_id })) .send() .await .unwrap() .status() } async fn claim_ok(app: &TestApp, token: &str, template_id: &str) -> serde_json::Value { let res = client() .post(app.url("/api/me/coupons")) .bearer_auth(token) .json(&serde_json::json!({ "template_id": template_id })) .send() .await .unwrap(); assert_eq!(res.status(), 201, "claim: {:?}", res.text().await); res.json().await.unwrap() } async fn checkout_with( app: &TestApp, token: &str, coupon_by_shop: HashMap, ) -> reqwest::Response { client() .post(app.url("/api/orders/checkout")) .bearer_auth(token) .json(&serde_json::json!({ "shipping_address": { "recipient": "Test Recipient", "phone": "123456", "country": "US", "region": "CA", "city": "San Jose", "line1": "1 Test Way", "postal_code": "95131" }, "currency": "USD", "coupon_by_shop": coupon_by_shop, })) .send() .await .unwrap() } #[tokio::test] #[serial] async fn shop_manages_only_its_own_templates() { let app = spawn_app().await; let admin = login_admin(&app).await; let shop_a = create_shop(&app, &admin, "cp-own-a").await; let owner_a = make_shop_owner(&app, &admin, &shop_a).await; let shop_b = create_shop(&app, &admin, "cp-own-b").await; let owner_b = make_shop_owner(&app, &admin, &shop_b).await; let template_id = create_template(&app, &owner_a, template_body(500, 0, 3)).await; // The issuing shop sees it; another shop does not. let res = client() .get(app.url("/api/shop/coupon-templates")) .bearer_auth(&owner_a) .send() .await .unwrap(); assert_eq!(res.status(), 200); let mine: Vec = res.json().await.unwrap(); assert_eq!(mine.len(), 1); let res = client() .get(app.url("/api/shop/coupon-templates")) .bearer_auth(&owner_b) .send() .await .unwrap(); let theirs: Vec = res.json().await.unwrap(); assert!(theirs.is_empty(), "cross-shop templates are hidden"); // Cross-shop mutation is a 404, not a 403 with an existence hint. let res = client() .put(app.url(&format!("/api/shop/coupon-templates/{template_id}"))) .bearer_auth(&owner_b) .json(&template_body(100, 0, 1)) .send() .await .unwrap(); assert_eq!(res.status(), 404); let res = client() .delete(app.url(&format!("/api/shop/coupon-templates/{template_id}"))) .bearer_auth(&owner_b) .send() .await .unwrap(); assert_eq!(res.status(), 404); // The public claimable listing exposes an active template. let res = client() .get(app.url(&format!("/api/shops/{shop_a}/coupon-templates"))) .send() .await .unwrap(); assert_eq!(res.status(), 200); let public: Vec = res.json().await.unwrap(); assert_eq!(public.len(), 1); assert_eq!(public[0]["amount_minor"], 500); } #[tokio::test] #[serial] async fn claim_is_unique_and_the_last_one_wins() { let app = spawn_app().await; let admin = login_admin(&app).await; let shop = create_shop(&app, &admin, "cp-claim").await; let owner = make_shop_owner(&app, &admin, &shop).await; let template = create_template(&app, &owner, template_body(500, 0, 1)).await; let (token_a, _) = register_customer(&app, "cp-claim-a").await; let (token_b, _) = register_customer(&app, "cp-claim-b").await; // Two customers race for the final instance. let (a, b) = tokio::join!( claim(&app, &token_a, &template), claim(&app, &token_b, &template), ); let wins = [a, b].iter().filter(|s| s.is_success()).count(); assert_eq!(wins, 1, "exactly one claim takes the last stock: {a} {b}"); let loser = if a.is_success() { b } else { a }; assert_eq!(loser, reqwest::StatusCode::CONFLICT); // The same customer cannot claim the same template twice. let dupe = create_template(&app, &owner, template_body(500, 0, 5)).await; let (token_c, _) = register_customer(&app, "cp-claim-c").await; claim_ok(&app, &token_c, &dupe).await; assert_eq!( claim(&app, &token_c, &dupe).await, reqwest::StatusCode::CONFLICT, "a duplicate claim is refused" ); // Stock never went negative. let stock: i32 = sqlx::query_scalar("SELECT stock FROM coupon_templates WHERE id = $1") .bind(Uuid::parse_str(&template).unwrap()) .fetch_one(&app.db) .await .unwrap(); assert_eq!(stock, 0); } #[tokio::test] #[serial] async fn checkout_discounts_only_the_issuing_shop() { let app = spawn_app().await; let admin = login_admin(&app).await; let (owner_a, shop_a, _, sku_a) = setup_sellable(&app, &admin, "cp-multi-a", 1000, 5).await; let (_owner_b, _shop_b, _, sku_b) = setup_sellable(&app, &admin, "cp-multi-b", 2000, 5).await; let template = create_template(&app, &owner_a, template_body(500, 0, 5)).await; let (token, _user_id) = register_customer(&app, "cp-multi").await; let coupon = claim_ok(&app, &token, &template).await; let coupon_id = coupon["id"].as_str().unwrap().to_string(); add_to_cart(&app, &token, &sku_a, 1).await; add_to_cart(&app, &token, &sku_b, 1).await; let res = checkout_with( &app, &token, HashMap::from([(shop_a.clone(), coupon_id.clone())]), ) .await; assert_eq!(res.status(), 201, "checkout: {:?}", res.text().await); let orders: Vec = res.json().await.unwrap(); assert_eq!(orders.len(), 2); let discounted = orders .iter() .find(|o| o["shop_id"] == shop_a.as_str()) .expect("shop A order"); assert_eq!(discounted["discount_minor"], 500); assert_eq!(discounted["total_minor"], 500, "1000 − 500"); assert_eq!(discounted["coupon_id"], coupon_id.as_str()); let untouched = orders .iter() .find(|o| o["shop_id"] != shop_a.as_str()) .expect("shop B order"); assert_eq!(untouched["discount_minor"], 0); assert_eq!(untouched["total_minor"], 2000); // The coupon is now bound to the order. let (status, order_ref): (String, Option) = sqlx::query_as("SELECT status::text, order_id FROM coupons WHERE id = $1") .bind(Uuid::parse_str(&coupon_id).unwrap()) .fetch_one(&app.db) .await .unwrap(); assert_eq!(status, "redeemed"); assert!(order_ref.is_some()); // A redeemed coupon cannot be spent again. add_to_cart(&app, &token, &sku_a, 1).await; let res = checkout_with( &app, &token, HashMap::from([(shop_a.clone(), coupon_id.clone())]), ) .await; assert_eq!(res.status(), 409, "a spent coupon is not redeemable"); } #[tokio::test] #[serial] async fn rejects_cross_shop_threshold_and_expired_coupons() { let app = spawn_app().await; let admin = login_admin(&app).await; let (owner_a, shop_a, _, sku_a) = setup_sellable(&app, &admin, "cp-rej-a", 1000, 5).await; let (_owner_b, shop_b, _, sku_b) = setup_sellable(&app, &admin, "cp-rej-b", 2000, 5).await; let (token, user_id) = register_customer(&app, "cp-rej").await; let user = Uuid::parse_str(&user_id).unwrap(); // Cross-shop: a shop A coupon offered against a shop B order. let cross = create_template(&app, &owner_a, template_body(500, 0, 5)).await; let cross_coupon = claim_ok(&app, &token, &cross).await; add_to_cart(&app, &token, &sku_b, 1).await; let res = checkout_with( &app, &token, HashMap::from([( shop_b.clone(), cross_coupon["id"].as_str().unwrap().to_string(), )]), ) .await; assert_eq!(res.status(), 400, "coupon was not issued by that shop"); // Threshold: subtotal below the coupon's threshold. let high = create_template(&app, &owner_a, template_body(500, 10_000, 5)).await; let high_coupon = claim_ok(&app, &token, &high).await; add_to_cart(&app, &token, &sku_a, 1).await; let res = checkout_with( &app, &token, HashMap::from([( shop_a.clone(), high_coupon["id"].as_str().unwrap().to_string(), )]), ) .await; assert_eq!(res.status(), 409, "subtotal does not reach the threshold"); // Expired: claim while active, then let the snapshot window elapse. let expiring = create_template(&app, &owner_a, template_body(500, 0, 5)).await; let expiring_coupon = claim_ok(&app, &token, &expiring).await; sqlx::query("UPDATE coupons SET ends_at = now() - interval '1 day' WHERE id = $1") .bind(Uuid::parse_str(expiring_coupon["id"].as_str().unwrap()).unwrap()) .execute(&app.db) .await .unwrap(); let res = checkout_with( &app, &token, HashMap::from([( shop_a.clone(), expiring_coupon["id"].as_str().unwrap().to_string(), )]), ) .await; assert_eq!(res.status(), 409, "an elapsed coupon is not redeemable"); // Every failed checkout rolled back: no order was created. let orders: i64 = sqlx::query_scalar("SELECT count(*) FROM orders WHERE user_id = $1") .bind(user) .fetch_one(&app.db) .await .unwrap(); assert_eq!(orders, 0); } #[tokio::test] #[serial] async fn cancelling_a_pending_order_restores_its_coupon() { let app = spawn_app().await; let admin = login_admin(&app).await; let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "cp-cancel", 1000, 5).await; let template = create_template(&app, &owner, template_body(500, 0, 5)).await; let (token, user_id) = register_customer(&app, "cp-cancel").await; let user = Uuid::parse_str(&user_id).unwrap(); let coupon = claim_ok(&app, &token, &template).await; let coupon_id = coupon["id"].as_str().unwrap().to_string(); let shop_id = coupon["shop_id"].as_str().unwrap().to_string(); add_to_cart(&app, &token, &sku, 2).await; let res = checkout_with( &app, &token, HashMap::from([(shop_id, coupon_id.clone())]), ) .await; assert_eq!(res.status(), 201); let orders: Vec = res.json().await.unwrap(); let order_id = orders[0]["id"].as_str().unwrap().to_string(); let res = client() .post(app.url(&format!("/api/orders/{order_id}/cancel"))) .bearer_auth(&token) .send() .await .unwrap(); assert_eq!(res.status(), 200, "cancel: {:?}", res.text().await); let (status, order_ref): (String, Option) = sqlx::query_as("SELECT status::text, order_id FROM coupons WHERE id = $1") .bind(Uuid::parse_str(&coupon_id).unwrap()) .fetch_one(&app.db) .await .unwrap(); assert_eq!(status, "claimed", "the coupon is claimable again"); assert!(order_ref.is_none(), "and no longer bound to the order"); // Stock came back with it. let stock: i32 = sqlx::query_scalar("SELECT stock FROM skus WHERE id = $1") .bind(Uuid::parse_str(&sku).unwrap()) .fetch_one(&app.db) .await .unwrap(); assert_eq!(stock, 5); // The snapshot survived: the customer still holds it. let held: i64 = sqlx::query_scalar("SELECT count(*) FROM coupons WHERE user_id = $1 AND template_id = $2") .bind(user) .bind(Uuid::parse_str(&template).unwrap()) .fetch_one(&app.db) .await .unwrap(); assert_eq!(held, 1); }