feat(api): group buying with payment-time seat claims
Shop-owned activities on one SKU, concrete groups with an open/successful/expired/cancelled lifecycle, and one paid membership row per paid order. Checkout accepts a single-SKU quantity-1 intent, snapshots the group price and identity on the pending order, and opens or references a group; a paid seat is claimed only at payment, which locks the group and fills it exactly at capacity. Pending-payment cancellation restores SKU stock only and never rolls back paid seats; an unpaid opener cancelling closes a still-empty group. Coupons are refused on a group shop order, and the flash-sale exclusion is now enforced in both directions because the activity table exists, which activates the guard add-flash-sales shipped dormant. The activity column names follow the contract recorded in this change's design. Surfaces (shop-admin, mall) and seeding follow.
This commit is contained in:
@@ -201,38 +201,33 @@ async fn cross_shop_sku_and_overlapping_sessions_are_rejected() {
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn overlapping_group_buying_is_rejected_once_that_capability_exists() {
|
||||
async fn overlapping_group_buying_is_rejected() {
|
||||
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();
|
||||
|
||||
// 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");
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
mod common;
|
||||
|
||||
use common::{
|
||||
add_to_cart, client, login_admin, register_customer, setup_sellable, spawn_app, TestApp,
|
||||
};
|
||||
use serial_test::serial;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn active_window() -> (String, String) {
|
||||
("2020-01-01T00:00:00Z".into(), "2999-01-01T00:00:00Z".into())
|
||||
}
|
||||
|
||||
fn address() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"recipient": "Test", "phone": "1", "country": "US",
|
||||
"region": "CA", "city": "SF", "line1": "1 Way", "postal_code": "94105"
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_activity_body(
|
||||
app: &TestApp,
|
||||
owner: &str,
|
||||
sku: &str,
|
||||
group_price: i64,
|
||||
required: i32,
|
||||
lifetime_hours: i32,
|
||||
) -> reqwest::Response {
|
||||
let (starts, ends) = active_window();
|
||||
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": group_price,
|
||||
"currency": "USD",
|
||||
"required_members": required,
|
||||
"starts_at": starts,
|
||||
"ends_at": ends,
|
||||
"group_lifetime_hours": lifetime_hours,
|
||||
"enabled": true,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn create_activity(
|
||||
app: &TestApp,
|
||||
owner: &str,
|
||||
sku: &str,
|
||||
group_price: i64,
|
||||
required: i32,
|
||||
lifetime_hours: i32,
|
||||
) -> String {
|
||||
let res = create_activity_body(app, owner, sku, group_price, required, lifetime_hours).await;
|
||||
assert_eq!(res.status(), 201, "create activity: {:?}", res.text().await);
|
||||
res.json::<serde_json::Value>().await.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn checkout_group(
|
||||
app: &TestApp,
|
||||
token: &str,
|
||||
sku: &str,
|
||||
activity: &str,
|
||||
group_id: Option<&str>,
|
||||
) -> reqwest::Response {
|
||||
client()
|
||||
.post(app.url("/api/orders/checkout"))
|
||||
.bearer_auth(token)
|
||||
.json(&serde_json::json!({
|
||||
"shipping_address": address(),
|
||||
"currency": "USD",
|
||||
"coupon_by_shop": {},
|
||||
"group_buy": {
|
||||
"activity_id": activity,
|
||||
"sku_id": sku,
|
||||
"group_id": group_id,
|
||||
},
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn checkout_with_coupon(
|
||||
app: &TestApp,
|
||||
token: &str,
|
||||
sku: &str,
|
||||
activity: &str,
|
||||
coupon_by_shop: serde_json::Value,
|
||||
) -> reqwest::Response {
|
||||
client()
|
||||
.post(app.url("/api/orders/checkout"))
|
||||
.bearer_auth(token)
|
||||
.json(&serde_json::json!({
|
||||
"shipping_address": address(),
|
||||
"currency": "USD",
|
||||
"coupon_by_shop": coupon_by_shop,
|
||||
"group_buy": { "activity_id": activity, "sku_id": sku, "group_id": null },
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn pay(app: &TestApp, token: &str, order_id: &str) -> reqwest::StatusCode {
|
||||
client()
|
||||
.post(app.url(&format!("/api/orders/{order_id}/pay")))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status()
|
||||
}
|
||||
|
||||
async fn cancel(app: &TestApp, token: &str, order_id: &str) -> reqwest::StatusCode {
|
||||
client()
|
||||
.post(app.url(&format!("/api/orders/{order_id}/cancel")))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status()
|
||||
}
|
||||
|
||||
async fn group_state(app: &TestApp, group_id: &str) -> (String, i32) {
|
||||
sqlx::query_as("SELECT status::text, paid_member_count FROM collective_groups WHERE id = $1")
|
||||
.bind(Uuid::parse_str(group_id).unwrap())
|
||||
.fetch_one(&app.db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn order_status(app: &TestApp, order_id: &str) -> String {
|
||||
sqlx::query_scalar("SELECT status::text FROM orders WHERE id = $1")
|
||||
.bind(Uuid::parse_str(order_id).unwrap())
|
||||
.fetch_one(&app.db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Checkout one group order and return its id.
|
||||
async fn place(
|
||||
app: &TestApp,
|
||||
token: &str,
|
||||
sku: &str,
|
||||
activity: &str,
|
||||
group_id: Option<&str>,
|
||||
) -> String {
|
||||
let res = checkout_group(app, token, sku, activity, group_id).await;
|
||||
assert_eq!(res.status(), 201, "checkout: {:?}", res.text().await);
|
||||
res.json::<serde_json::Value>().await.unwrap()[0]["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn activity_requires_an_own_sku_and_two_members() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let (owner_a, _shop_a, _p, sku_a) = setup_sellable(&app, &admin, "gb-valid-a", 1000, 10).await;
|
||||
let (_owner_b, _shop_b, _p2, sku_b) = setup_sellable(&app, &admin, "gb-valid-b", 1000, 10).await;
|
||||
|
||||
// Another shop's SKU is refused.
|
||||
let res = create_activity_body(&app, &owner_a, &sku_b, 700, 2, 24).await;
|
||||
assert_eq!(res.status(), 400);
|
||||
|
||||
// Fewer than two paid members is refused.
|
||||
let res = create_activity_body(&app, &owner_a, &sku_a, 700, 1, 24).await;
|
||||
assert_eq!(res.status(), 400);
|
||||
|
||||
// A valid activity is accepted and publicly discoverable.
|
||||
let activity = create_activity(&app, &owner_a, &sku_a, 700, 2, 24).await;
|
||||
let active: Vec<serde_json::Value> = client()
|
||||
.get(app.url("/api/group-buying/activities"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let found = active
|
||||
.iter()
|
||||
.find(|a| a["id"] == activity.as_str())
|
||||
.expect("the activity is public");
|
||||
assert_eq!(found["required_members"], 2);
|
||||
assert_eq!(found["group_price_minor"], 700);
|
||||
assert!(found["open_groups"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn open_then_join_completes_a_group() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "gb-flow", 1000, 10).await;
|
||||
let activity = create_activity(&app, &owner, &sku, 700, 2, 24).await;
|
||||
|
||||
let (token_a, _) = register_customer(&app, "gb-open").await;
|
||||
let (token_b, _) = register_customer(&app, "gb-join").await;
|
||||
|
||||
// The opener creates the group and snapshots the group price.
|
||||
add_to_cart(&app, &token_a, &sku, 1).await;
|
||||
let res = checkout_group(&app, &token_a, &sku, &activity, None).await;
|
||||
assert_eq!(res.status(), 201, "open: {:?}", res.text().await);
|
||||
let opened: Vec<serde_json::Value> = res.json().await.unwrap();
|
||||
let order_a = opened[0]["id"].as_str().unwrap().to_string();
|
||||
let group_id = opened[0]["group_id"].as_str().unwrap().to_string();
|
||||
assert_eq!(opened[0]["total_minor"], 700, "group price is snapshotted");
|
||||
assert_eq!(opened[0]["items"][0]["unit_price_minor"], 700);
|
||||
assert_eq!(opened[0]["group_activity_id"], activity.as_str());
|
||||
|
||||
// It is discoverable as an open group before anyone has paid.
|
||||
let active: Vec<serde_json::Value> = client()
|
||||
.get(app.url("/api/group-buying/activities"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let found = active.iter().find(|a| a["id"] == activity.as_str()).unwrap();
|
||||
assert_eq!(found["open_groups"][0]["id"], group_id.as_str());
|
||||
assert_eq!(found["open_groups"][0]["paid_member_count"], 0);
|
||||
|
||||
// A joiner references the open group.
|
||||
add_to_cart(&app, &token_b, &sku, 1).await;
|
||||
let order_b = place(&app, &token_b, &sku, &activity, Some(&group_id)).await;
|
||||
|
||||
assert_eq!(pay(&app, &token_a, &order_a).await, 200);
|
||||
assert_eq!(group_state(&app, &group_id).await, ("open".into(), 1));
|
||||
assert_eq!(pay(&app, &token_b, &order_b).await, 200);
|
||||
assert_eq!(group_state(&app, &group_id).await, ("successful".into(), 2));
|
||||
assert_eq!(order_status(&app, &order_a).await, "paid");
|
||||
assert_eq!(order_status(&app, &order_b).await, "paid");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn intent_requires_quantity_one() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "gb-qty", 1000, 10).await;
|
||||
let activity = create_activity(&app, &owner, &sku, 700, 2, 24).await;
|
||||
|
||||
let (token, _) = register_customer(&app, "gb-qty").await;
|
||||
add_to_cart(&app, &token, &sku, 2).await;
|
||||
let res = checkout_group(&app, &token, &sku, &activity, None).await;
|
||||
assert_eq!(res.status(), 400, "group intent is quantity-1 only");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn final_seat_admits_one_payment() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "gb-seat", 1000, 10).await;
|
||||
let activity = create_activity(&app, &owner, &sku, 700, 2, 24).await;
|
||||
|
||||
let (token_a, _) = register_customer(&app, "gb-seat-a").await;
|
||||
let (token_b, _) = register_customer(&app, "gb-seat-b").await;
|
||||
let (token_c, _) = register_customer(&app, "gb-seat-c").await;
|
||||
|
||||
add_to_cart(&app, &token_a, &sku, 1).await;
|
||||
let order_a = place(&app, &token_a, &sku, &activity, None).await;
|
||||
let group_id = sqlx::query_scalar::<_, Uuid>("SELECT group_id FROM orders WHERE id = $1")
|
||||
.bind(Uuid::parse_str(&order_a).unwrap())
|
||||
.fetch_one(&app.db)
|
||||
.await
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert_eq!(pay(&app, &token_a, &order_a).await, 200);
|
||||
assert_eq!(group_state(&app, &group_id).await, ("open".into(), 1));
|
||||
|
||||
add_to_cart(&app, &token_b, &sku, 1).await;
|
||||
let order_b = place(&app, &token_b, &sku, &activity, Some(&group_id)).await;
|
||||
add_to_cart(&app, &token_c, &sku, 1).await;
|
||||
let order_c = place(&app, &token_c, &sku, &activity, Some(&group_id)).await;
|
||||
|
||||
// Two payments race for the one remaining seat.
|
||||
let (b, c) = tokio::join!(pay(&app, &token_b, &order_b), pay(&app, &token_c, &order_c));
|
||||
let wins = [b, c].iter().filter(|s| s.is_success()).count();
|
||||
assert_eq!(wins, 1, "exactly one payment takes the final seat: {b} {c}");
|
||||
assert_eq!(group_state(&app, &group_id).await, ("successful".into(), 2));
|
||||
|
||||
let loser_order = if b.is_success() { &order_c } else { &order_b };
|
||||
assert_eq!(
|
||||
order_status(&app, loser_order).await,
|
||||
"pending_payment",
|
||||
"a rejected payment leaves the order pending"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn expired_and_cancelled_groups_are_not_joinable() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "gb-state", 1000, 20).await;
|
||||
let activity = create_activity(&app, &owner, &sku, 700, 3, 24).await;
|
||||
|
||||
let (token_a, _) = register_customer(&app, "gb-state-a").await;
|
||||
let (token_b, _) = register_customer(&app, "gb-state-b").await;
|
||||
|
||||
// A paid member whose group expires stays identifiable for a later refund.
|
||||
add_to_cart(&app, &token_a, &sku, 1).await;
|
||||
let order_a = place(&app, &token_a, &sku, &activity, None).await;
|
||||
let group_id = sqlx::query_scalar::<_, Uuid>("SELECT group_id FROM orders WHERE id = $1")
|
||||
.bind(Uuid::parse_str(&order_a).unwrap())
|
||||
.fetch_one(&app.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(pay(&app, &token_a, &order_a).await, 200);
|
||||
sqlx::query("UPDATE collective_groups SET expires_at = now() - interval '1 hour' WHERE id = $1")
|
||||
.bind(group_id)
|
||||
.execute(&app.db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// A committed read records the expiry (a failed checkout rolls its own
|
||||
// sweep back, so discovery is what persists it).
|
||||
let active_after: Vec<serde_json::Value> = client()
|
||||
.get(app.url("/api/group-buying/activities"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let found = active_after
|
||||
.iter()
|
||||
.find(|a| a["id"] == activity.as_str())
|
||||
.unwrap();
|
||||
assert!(
|
||||
found["open_groups"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|g| g["id"] != group_id.to_string()),
|
||||
"an expired group is not offered"
|
||||
);
|
||||
assert_eq!(group_state(&app, &group_id.to_string()).await.0, "expired");
|
||||
|
||||
// Joining an expired group is refused.
|
||||
add_to_cart(&app, &token_b, &sku, 1).await;
|
||||
let res = checkout_group(&app, &token_b, &sku, &activity, Some(&group_id.to_string())).await;
|
||||
assert_eq!(res.status(), 409, "an expired group is not joinable");
|
||||
let members: i64 =
|
||||
sqlx::query_scalar("SELECT count(*) FROM collective_group_members WHERE group_id = $1")
|
||||
.bind(group_id)
|
||||
.fetch_one(&app.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(members, 1, "the paid membership survives for a refund flow");
|
||||
|
||||
// A second group, cancelled by its unpaid opener. A failed checkout leaves
|
||||
// the cart intact, so use a fresh customer rather than adding again.
|
||||
let (token_c, _) = register_customer(&app, "gb-state-c").await;
|
||||
add_to_cart(&app, &token_a, &sku, 1).await;
|
||||
let order_c = place(&app, &token_a, &sku, &activity, None).await;
|
||||
let group_c = sqlx::query_scalar::<_, Uuid>("SELECT group_id FROM orders WHERE id = $1")
|
||||
.bind(Uuid::parse_str(&order_c).unwrap())
|
||||
.fetch_one(&app.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cancel(&app, &token_a, &order_c).await, 200);
|
||||
assert_eq!(group_state(&app, &group_c.to_string()).await.0, "cancelled");
|
||||
|
||||
add_to_cart(&app, &token_c, &sku, 1).await;
|
||||
let res = checkout_group(&app, &token_c, &sku, &activity, Some(&group_c.to_string())).await;
|
||||
assert_eq!(res.status(), 409, "a cancelled group is not joinable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn coupon_is_rejected_on_a_group_order() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let (owner, shop, _product, sku) = setup_sellable(&app, &admin, "gb-coupon", 1000, 10).await;
|
||||
let activity = create_activity(&app, &owner, &sku, 700, 2, 24).await;
|
||||
|
||||
let res = client()
|
||||
.post(app.url("/api/shop/coupon-templates"))
|
||||
.bearer_auth(&owner)
|
||||
.json(&serde_json::json!({
|
||||
"title": {"en": "Coupon", "zh": "优惠券"},
|
||||
"amount_minor": 100, "threshold_minor": 0, "currency": "USD", "stock": 5,
|
||||
"enabled": true,
|
||||
"starts_at": "2020-01-01T00:00:00Z", "ends_at": "2999-01-01T00:00:00Z",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let template = res.json::<serde_json::Value>().await.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
let (token, _) = register_customer(&app, "gb-coupon").await;
|
||||
let res = client()
|
||||
.post(app.url("/api/me/coupons"))
|
||||
.bearer_auth(&token)
|
||||
.json(&serde_json::json!({ "template_id": template }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let coupon = res.json::<serde_json::Value>().await.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
add_to_cart(&app, &token, &sku, 1).await;
|
||||
let res = checkout_with_coupon(
|
||||
&app,
|
||||
&token,
|
||||
&sku,
|
||||
&activity,
|
||||
serde_json::json!({ shop: coupon }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(res.status(), 409, "a group order refuses a coupon");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn activity_rejects_a_sku_with_an_overlapping_flash_sale() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "gb-flash", 1000, 10).await;
|
||||
let (starts, ends) = active_window();
|
||||
|
||||
let session = 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()
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/shop/flash-sales/{session}/items")))
|
||||
.bearer_auth(&owner)
|
||||
.json(&serde_json::json!({
|
||||
"sku_id": sku, "sale_price_minor": 500, "currency": "USD",
|
||||
"reserved_stock": 5, "per_customer_limit": 2,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 201);
|
||||
|
||||
// The group activity now refuses the SKU that is already in a flash sale.
|
||||
let res = create_activity_body(&app, &owner, &sku, 700, 2, 24).await;
|
||||
assert_eq!(res.status(), 409);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn cancelling_does_not_roll_back_paid_seats() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "gb-cancel", 1000, 10).await;
|
||||
let activity = create_activity(&app, &owner, &sku, 700, 3, 24).await;
|
||||
|
||||
let (token_a, _) = register_customer(&app, "gb-cancel-a").await;
|
||||
let (token_b, _) = register_customer(&app, "gb-cancel-b").await;
|
||||
|
||||
add_to_cart(&app, &token_a, &sku, 1).await;
|
||||
let order_a = place(&app, &token_a, &sku, &activity, None).await;
|
||||
let group_id = sqlx::query_scalar::<_, Uuid>("SELECT group_id FROM orders WHERE id = $1")
|
||||
.bind(Uuid::parse_str(&order_a).unwrap())
|
||||
.fetch_one(&app.db)
|
||||
.await
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert_eq!(pay(&app, &token_a, &order_a).await, 200);
|
||||
|
||||
add_to_cart(&app, &token_b, &sku, 1).await;
|
||||
let order_b = place(&app, &token_b, &sku, &activity, Some(&group_id)).await;
|
||||
assert_eq!(cancel(&app, &token_b, &order_b).await, 200);
|
||||
|
||||
// The paid seat is untouched and the group stays open.
|
||||
assert_eq!(group_state(&app, &group_id).await, ("open".into(), 1));
|
||||
assert_eq!(order_status(&app, &order_b).await, "cancelled");
|
||||
}
|
||||
Reference in New Issue
Block a user