The inactive-window test asserted that no active session exists at all, which the shared test database breaks once another test has created one. Assert that this session is absent from discovery instead.
404 lines
15 KiB
Rust
404 lines
15 KiB
Rust
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::<serde_json::Value>().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<String, String>,
|
|
) -> 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::<serde_json::Value>().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::<serde_json::Value>().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<serde_json::Value> = 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<serde_json::Value> = 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_once_that_capability_exists() {
|
|
let app = spawn_app().await;
|
|
let table_exists: bool = sqlx::query_scalar(
|
|
"SELECT to_regclass('public.group_buying_activities') IS NOT NULL",
|
|
)
|
|
.fetch_one(&app.db)
|
|
.await
|
|
.unwrap();
|
|
if !table_exists {
|
|
// add-group-buying lands after add-flash-sales, so this cross-capability
|
|
// scenario can only be exercised once its table exists.
|
|
eprintln!("skipped: group_buying_activities is not present yet");
|
|
return;
|
|
}
|
|
|
|
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();
|
|
sqlx::query(
|
|
"INSERT INTO group_buying_activities
|
|
(shop_id, sku_id, name, group_price_minor, currency, required_members,
|
|
starts_at, ends_at, group_lifetime_hours)
|
|
SELECT p.shop_id, $1, '{\"en\":\"g\",\"zh\":\"团\"}', 100, 'USD', 2, $2, $3, 24
|
|
FROM skus s JOIN products p ON p.id = s.product_id WHERE s.id = $1",
|
|
)
|
|
.bind(Uuid::parse_str(&sku).unwrap())
|
|
.bind(chrono::DateTime::parse_from_rfc3339(&starts).unwrap())
|
|
.bind(chrono::DateTime::parse_from_rfc3339(&ends).unwrap())
|
|
.execute(&app.db)
|
|
.await
|
|
.unwrap();
|
|
|
|
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::<serde_json::Value>().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::<serde_json::Value>().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<serde_json::Value> = 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<serde_json::Value> = 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::<serde_json::Value>().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<serde_json::Value> = 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<Uuid>) =
|
|
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());
|
|
}
|