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::().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::().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 = 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 = 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 = 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 = 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::().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::().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::() .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_an_elapsed_empty_group_records_expired() { let app = spawn_app().await; let admin = login_admin(&app).await; let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "gb-precedence", 1000, 10).await; let activity = create_activity(&app, &owner, &sku, 700, 3, 24).await; let (token, _) = register_customer(&app, "gb-precedence").await; add_to_cart(&app, &token, &sku, 1).await; let order = place(&app, &token, &sku, &activity, None).await; let group_id = sqlx::query_scalar::<_, Uuid>("SELECT group_id FROM orders WHERE id = $1") .bind(Uuid::parse_str(&order).unwrap()) .fetch_one(&app.db) .await .unwrap(); // The lifetime ends before the opener cancels. sqlx::query("UPDATE collective_groups SET expires_at = now() - interval '1 hour' WHERE id = $1") .bind(group_id) .execute(&app.db) .await .unwrap(); assert_eq!(cancel(&app, &token, &order).await, 200); // Both conditions hold; an elapsed group is expired, not relabelled cancelled. assert_eq!(group_state(&app, &group_id.to_string()).await.0, "expired"); } #[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"); }