378 lines
13 KiB
Rust
378 lines
13 KiB
Rust
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::<serde_json::Value>().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<String, String>,
|
||
) -> 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<serde_json::Value> = 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<serde_json::Value> = 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<serde_json::Value> = 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<serde_json::Value> = 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<Uuid>) =
|
||
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<serde_json::Value> = 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<Uuid>) =
|
||
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);
|
||
}
|