mod common; use std::collections::HashMap; use common::{ add_to_cart, client, login_admin, register_customer, setup_sellable, spawn_app, TestApp, }; use serial_test::serial; use uuid::Uuid; fn past_window() -> (String, String) { ("2020-01-01T00:00:00Z".into(), "2020-01-02T00:00:00Z".into()) } fn active_window() -> (String, String) { ("2020-01-01T00:00:00Z".into(), "2999-01-01T00:00:00Z".into()) } async fn create_session(app: &TestApp, owner: &str, starts: &str, ends: &str) -> String { let res = client() .post(app.url("/api/shop/flash-sales")) .bearer_auth(owner) .json(&serde_json::json!({ "label": {"en": "Flash", "zh": "秒杀"}, "starts_at": starts, "ends_at": ends, "enabled": true, })) .send() .await .unwrap(); assert_eq!(res.status(), 201, "create session: {:?}", res.text().await); res.json::().await.unwrap()["id"] .as_str() .unwrap() .to_string() } async fn add_item( app: &TestApp, owner: &str, session_id: &str, sku_id: &str, sale_price: i64, reserved: i32, limit: i32, ) -> reqwest::Response { client() .post(app.url(&format!("/api/shop/flash-sales/{session_id}/items"))) .bearer_auth(owner) .json(&serde_json::json!({ "sku_id": sku_id, "sale_price_minor": sale_price, "currency": "USD", "reserved_stock": reserved, "per_customer_limit": limit, })) .send() .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", "phone": "1", "country": "US", "region": "CA", "city": "SF", "line1": "1 Way", "postal_code": "94105" }, "currency": "USD", "coupon_by_shop": coupon_by_shop, })) .send() .await .unwrap() } async fn create_template(app: &TestApp, owner: &str, amount: i64, threshold: i64) -> String { let res = client() .post(app.url("/api/shop/coupon-templates")) .bearer_auth(owner) .json(&serde_json::json!({ "title": {"en": "Coupon", "zh": "优惠券"}, "amount_minor": amount, "threshold_minor": threshold, "currency": "USD", "stock": 5, "enabled": true, "starts_at": "2020-01-01T00:00:00Z", "ends_at": "2999-01-01T00:00:00Z", })) .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) -> String { 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()["id"] .as_str() .unwrap() .to_string() } async fn flash_item_state(app: &TestApp, item_id: &str) -> (i32, i32) { sqlx::query_as("SELECT reserved_stock, sold_count FROM flash_sale_items WHERE id = $1") .bind(Uuid::parse_str(item_id).unwrap()) .fetch_one(&app.db) .await .unwrap() } async fn activity_lines(app: &TestApp, order_id: &str) -> i64 { sqlx::query_scalar( "SELECT count(*) FROM order_items WHERE order_id = $1 AND flash_sale_item_id IS NOT NULL", ) .bind(Uuid::parse_str(order_id).unwrap()) .fetch_one(&app.db) .await .unwrap() } #[tokio::test] #[serial] async fn inactive_window_falls_back_to_normal_price() { let app = spawn_app().await; let admin = login_admin(&app).await; let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "fs-past", 1000, 5).await; let (starts, ends) = past_window(); let session = create_session(&app, &owner, &starts, &ends).await; let res = add_item(&app, &owner, &session, &sku, 500, 5, 3).await; assert_eq!(res.status(), 201, "past sessions may still be configured"); // Not discoverable as active. Scoped to this session: the shared test // database keeps sessions other tests created. let active: Vec = client() .get(app.url("/api/flash-sales")) .send() .await .unwrap() .json() .await .unwrap(); assert!( active.iter().all(|s| s["id"] != session.as_str()), "an elapsed session is not public" ); let (token, _) = register_customer(&app, "fs-past").await; add_to_cart(&app, &token, &sku, 1).await; let res = checkout_with(&app, &token, HashMap::new()).await; assert_eq!(res.status(), 201); let orders: Vec = res.json().await.unwrap(); let item = &orders[0]["items"][0]; assert_eq!(item["unit_price_minor"], 1000, "normal price applies"); assert!(item["flash_sale_item_id"].is_null()); assert_eq!(activity_lines(&app, orders[0]["id"].as_str().unwrap()).await, 0); } #[tokio::test] #[serial] async fn cross_shop_sku_and_overlapping_sessions_are_rejected() { let app = spawn_app().await; let admin = login_admin(&app).await; let (owner_a, shop_a, _product_a, sku_a) = setup_sellable(&app, &admin, "fs-a", 1000, 5).await; let (_owner_b, _shop_b, _product_b, sku_b) = setup_sellable(&app, &admin, "fs-b", 1000, 5).await; let (starts, ends) = active_window(); let session = create_session(&app, &owner_a, &starts, &ends).await; let res = add_item(&app, &owner_a, &session, &sku_b, 500, 5, 3).await; assert_eq!(res.status(), 400, "another shop's SKU is refused"); let res = add_item(&app, &owner_a, &session, &sku_a, 500, 5, 3).await; assert_eq!(res.status(), 201); // A second overlapping session cannot list the same SKU. let session_two = create_session(&app, &owner_a, &starts, &ends).await; let res = add_item(&app, &owner_a, &session_two, &sku_a, 400, 5, 3).await; assert_eq!(res.status(), 409, "overlapping sale for the same SKU is refused"); let _ = shop_a; } #[tokio::test] #[serial] async fn overlapping_group_buying_is_rejected() { let app = spawn_app().await; let admin = login_admin(&app).await; let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "fs-gb", 1000, 5).await; let (starts, ends) = active_window(); // The group-buying activity is configured first. let res = client() .post(app.url("/api/shop/group-buying-activities")) .bearer_auth(&owner) .json(&serde_json::json!({ "sku_id": sku, "name": {"en": "Group deal", "zh": "拼团"}, "group_price_minor": 700, "currency": "USD", "required_members": 2, "starts_at": starts, "ends_at": ends, "group_lifetime_hours": 24, "enabled": true, })) .send() .await .unwrap(); assert_eq!(res.status(), 201, "group activity: {:?}", res.text().await); // This guard was dormant until the group-buying table existed; it is live now. let session = create_session(&app, &owner, &starts, &ends).await; let res = add_item(&app, &owner, &session, &sku, 500, 5, 3).await; assert_eq!(res.status(), 409, "overlapping group-buy SKU is refused"); } #[tokio::test] #[serial] async fn reserved_stock_admits_one_activity_price() { let app = spawn_app().await; let admin = login_admin(&app).await; let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "fs-race", 1000, 10).await; let (starts, ends) = active_window(); let session = create_session(&app, &owner, &starts, &ends).await; let res = add_item(&app, &owner, &session, &sku, 400, 1, 5).await; assert_eq!(res.status(), 201); let item_id = res.json::().await.unwrap()["id"] .as_str() .unwrap() .to_string(); let (token_a, _) = register_customer(&app, "fs-race-a").await; let (token_b, _) = register_customer(&app, "fs-race-b").await; add_to_cart(&app, &token_a, &sku, 1).await; add_to_cart(&app, &token_b, &sku, 1).await; let (a, b) = tokio::join!( checkout_with(&app, &token_a, HashMap::new()), checkout_with(&app, &token_b, HashMap::new()), ); assert!(a.status().is_success() && b.status().is_success()); let (reserved, sold) = flash_item_state(&app, &item_id).await; assert_eq!(reserved, 0, "the single reserved unit is consumed"); assert_eq!(sold, 1); let activity: i64 = sqlx::query_scalar( "SELECT count(*) FROM order_items WHERE flash_sale_item_id = $1", ) .bind(Uuid::parse_str(&item_id).unwrap()) .fetch_one(&app.db) .await .unwrap(); assert_eq!(activity, 1, "exactly one line got the activity price"); } #[tokio::test] #[serial] async fn per_customer_limit_splits_the_line() { let app = spawn_app().await; let admin = login_admin(&app).await; let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "fs-limit", 1000, 10).await; let (starts, ends) = active_window(); let session = create_session(&app, &owner, &starts, &ends).await; let res = add_item(&app, &owner, &session, &sku, 400, 10, 1).await; assert_eq!(res.status(), 201); let item_id = res.json::().await.unwrap()["id"] .as_str() .unwrap() .to_string(); let (token, _) = register_customer(&app, "fs-limit").await; add_to_cart(&app, &token, &sku, 3).await; let res = checkout_with(&app, &token, HashMap::new()).await; assert_eq!(res.status(), 201); let orders: Vec = res.json().await.unwrap(); let items = orders[0]["items"].as_array().unwrap(); assert_eq!(items.len(), 2, "one activity line and one standard line"); let activity: Vec<&serde_json::Value> = items .iter() .filter(|i| !i["flash_sale_item_id"].is_null()) .collect(); let standard: Vec<&serde_json::Value> = items .iter() .filter(|i| i["flash_sale_item_id"].is_null()) .collect(); assert_eq!(activity.len(), 1); assert_eq!(activity[0]["qty"], 1, "only the allowance gets the activity price"); assert_eq!(activity[0]["unit_price_minor"], 400); assert_eq!(standard.len(), 1); assert_eq!(standard[0]["qty"], 2); assert_eq!(standard[0]["unit_price_minor"], 1000); assert_eq!(orders[0]["total_minor"], 400 + 2000); // The allowance is spent, so a second checkout is standard-priced. add_to_cart(&app, &token, &sku, 1).await; let res = checkout_with(&app, &token, HashMap::new()).await; let orders: Vec = res.json().await.unwrap(); assert!(orders[0]["items"][0]["flash_sale_item_id"].is_null()); assert_eq!(activity_lines(&app, orders[0]["id"].as_str().unwrap()).await, 0); let (_, sold) = flash_item_state(&app, &item_id).await; assert_eq!(sold, 1); } #[tokio::test] #[serial] async fn coupon_is_rejected_on_a_flash_priced_shop_order() { let app = spawn_app().await; let admin = login_admin(&app).await; let (owner, shop, _product, sku) = setup_sellable(&app, &admin, "fs-cp", 1000, 5).await; let (starts, ends) = active_window(); let session = create_session(&app, &owner, &starts, &ends).await; assert_eq!(add_item(&app, &owner, &session, &sku, 400, 5, 5).await.status(), 201); let template = create_template(&app, &owner, 100, 0).await; let (token, _) = register_customer(&app, "fs-cp").await; let coupon = claim(&app, &token, &template).await; add_to_cart(&app, &token, &sku, 1).await; let res = checkout_with(&app, &token, HashMap::from([(shop, coupon)])).await; assert_eq!(res.status(), 409, "activity pricing and coupons are exclusive"); } #[tokio::test] #[serial] async fn cancel_restores_reserved_stock_and_coupon() { let app = spawn_app().await; let admin = login_admin(&app).await; let (flash_owner, _flash_shop, _p1, flash_sku) = setup_sellable(&app, &admin, "fs-cancel-a", 1000, 5).await; let (owner_b, shop_b, _p2, sku_b) = setup_sellable(&app, &admin, "fs-cancel-b", 2000, 5).await; let (starts, ends) = active_window(); let session = create_session(&app, &flash_owner, &starts, &ends).await; let res = add_item(&app, &flash_owner, &session, &flash_sku, 400, 3, 5).await; assert_eq!(res.status(), 201); let item_id = res.json::().await.unwrap()["id"] .as_str() .unwrap() .to_string(); let template = create_template(&app, &owner_b, 100, 0).await; let (token, _) = register_customer(&app, "fs-cancel").await; let coupon = claim(&app, &token, &template).await; add_to_cart(&app, &token, &flash_sku, 2).await; add_to_cart(&app, &token, &sku_b, 1).await; let res = checkout_with(&app, &token, HashMap::from([(shop_b, coupon.clone())])).await; assert_eq!(res.status(), 201, "checkout: {:?}", res.text().await); let orders: Vec = res.json().await.unwrap(); assert_eq!(orders.len(), 2); let (reserved_after, sold_after) = flash_item_state(&app, &item_id).await; assert_eq!(reserved_after, 1, "2 of 3 reserved units consumed"); assert_eq!(sold_after, 2); for order in &orders { let res = client() .post(app.url(&format!("/api/orders/{}/cancel", order["id"].as_str().unwrap()))) .bearer_auth(&token) .send() .await .unwrap(); assert_eq!(res.status(), 200); } let (reserved, sold) = flash_item_state(&app, &item_id).await; assert_eq!(reserved, 3, "reserved activity stock is restored"); assert_eq!(sold, 0); let (status, order_ref): (String, Option) = sqlx::query_as("SELECT status::text, order_id FROM coupons WHERE id = $1") .bind(Uuid::parse_str(&coupon).unwrap()) .fetch_one(&app.db) .await .unwrap(); assert_eq!(status, "claimed"); assert!(order_ref.is_none()); }