mod common; use common::{client, login_admin, register_customer, spawn_app, TestApp}; use serial_test::serial; use uuid::Uuid; use vmall_api::models::AccountKind; use vmall_api::modules::account::service as accounts; use vmall_api::state::AppState; fn address() -> serde_json::Value { serde_json::json!({ "recipient": "Test Recipient", "phone": "123456", "country": "US", "region": "CA", "city": "San Jose", "line1": "1 Test Way", "postal_code": "95131" }) } async fn create_product( app: &TestApp, admin: &str, points_price: i64, stock: i32, published: bool, ) -> serde_json::Value { let res = client() .post(app.url("/api/admin/points/products")) .bearer_auth(admin) .json(&serde_json::json!({ "name": {"en": "Reward Mug", "zh": "积分杯"}, "points_price": points_price, "stock": stock, "published": published, })) .send() .await .unwrap(); assert_eq!(res.status(), 201, "create product: {:?}", res.text().await); res.json().await.unwrap() } async fn credit_points(state: &AppState, user_id: Uuid, amount: i64) { let mut tx = state.db.begin().await.unwrap(); accounts::credit( &mut tx, user_id, AccountKind::Points, None, amount, "test_credit", None, ) .await .unwrap(); tx.commit().await.unwrap(); } async fn redeem( app: &TestApp, token: &str, product_id: &str, qty: i32, ) -> reqwest::Response { client() .post(app.url("/api/points/redemptions")) .bearer_auth(token) .json(&serde_json::json!({ "product_id": product_id, "qty": qty, "shipping_address": address(), })) .send() .await .unwrap() } fn uuid(id: &str) -> Uuid { Uuid::parse_str(id).unwrap() } async fn points_balance(app: &TestApp, user_id: Uuid) -> i64 { sqlx::query_scalar( "SELECT balance_minor FROM customer_accounts WHERE user_id = $1 AND kind = 'points'", ) .bind(user_id) .fetch_one(&app.db) .await .unwrap() } async fn stock_of(app: &TestApp, product_id: &str) -> i32 { sqlx::query_scalar("SELECT stock FROM integral_products WHERE id = $1") .bind(uuid(product_id)) .fetch_one(&app.db) .await .unwrap() } #[tokio::test] #[serial] async fn unpublished_product_is_hidden_and_not_redeemable() { let app = spawn_app().await; let admin = login_admin(&app).await; let draft = create_product(&app, &admin, 300, 5, false).await; let id = draft["id"].as_str().unwrap().to_string(); let (token, user_id) = register_customer(&app, "pm-draft").await; credit_points(&app.state, uuid(&user_id), 1_000).await; // Absent from the public catalog. let res = client() .get(app.url("/api/points/products")) .send() .await .unwrap(); let catalog: Vec = res.json().await.unwrap(); assert!(catalog.iter().all(|p| p["id"] != id.as_str())); // And not redeemable. let res = redeem(&app, &token, &id, 1).await; assert_eq!(res.status(), 404, "a draft product cannot be redeemed"); assert_eq!(stock_of(&app, &id).await, 5, "and no stock moved"); // Publishing exposes it. let res = client() .post(app.url(&format!("/api/admin/points/products/{id}/publish"))) .bearer_auth(&admin) .send() .await .unwrap(); assert_eq!(res.status(), 200); let res = client() .get(app.url("/api/points/products")) .send() .await .unwrap(); let catalog: Vec = res.json().await.unwrap(); assert!(catalog.iter().any(|p| p["id"] == id.as_str())); } #[tokio::test] #[serial] async fn insufficient_points_creates_nothing() { let app = spawn_app().await; let admin = login_admin(&app).await; let product = create_product(&app, &admin, 1_000, 5, true).await; let id = product["id"].as_str().unwrap().to_string(); let (token, user_id) = register_customer(&app, "pm-poor").await; let user = uuid(&user_id); credit_points(&app.state, user, 500).await; let res = redeem(&app, &token, &id, 1).await; assert_eq!(res.status(), 409, "points are short"); // One transaction: no order, no stock change, no ledger movement. let orders: i64 = sqlx::query_scalar("SELECT count(*) FROM integral_orders WHERE user_id = $1") .bind(user) .fetch_one(&app.db) .await .unwrap(); assert_eq!(orders, 0); assert_eq!(stock_of(&app, &id).await, 5); assert_eq!(points_balance(&app, user).await, 500); // The failed attempt left no ledger entry either. let entries: i64 = sqlx::query_scalar( "SELECT count(*) FROM customer_account_entries e JOIN customer_accounts a ON a.id = e.account_id WHERE a.user_id = $1 AND e.reason = 'integral_redemption'", ) .bind(user) .fetch_one(&app.db) .await .unwrap(); assert_eq!(entries, 0); } #[tokio::test] #[serial] async fn final_stock_admits_one_redeemer() { let app = spawn_app().await; let admin = login_admin(&app).await; let product = create_product(&app, &admin, 100, 1, true).await; let id = product["id"].as_str().unwrap().to_string(); let (token_a, user_a) = register_customer(&app, "pm-race-a").await; let (token_b, user_b) = register_customer(&app, "pm-race-b").await; credit_points(&app.state, uuid(&user_a), 1_000).await; credit_points(&app.state, uuid(&user_b), 1_000).await; let (a, b) = tokio::join!( redeem(&app, &token_a, &id, 1), redeem(&app, &token_b, &id, 1), ); let wins = [a.status(), b.status()] .iter() .filter(|s| s.is_success()) .count(); assert_eq!(wins, 1, "exactly one redemption fits the last stock"); let loser = if a.status().is_success() { b } else { a }; assert_eq!(loser.status(), 409); assert_eq!(stock_of(&app, &id).await, 0, "no negative stock is stored"); let orders: i64 = sqlx::query_scalar( "SELECT count(*) FROM integral_orders o JOIN integral_order_items i ON i.order_id = o.id WHERE i.product_id = $1", ) .bind(uuid(&id)) .fetch_one(&app.db) .await .unwrap(); assert_eq!(orders, 1); } #[tokio::test] #[serial] async fn history_is_owner_scoped_and_admin_visible() { let app = spawn_app().await; let admin = login_admin(&app).await; let product = create_product(&app, &admin, 200, 5, true).await; let id = product["id"].as_str().unwrap().to_string(); let (token_a, user_a) = register_customer(&app, "pm-own-a").await; let (token_b, _user_b) = register_customer(&app, "pm-own-b").await; credit_points(&app.state, uuid(&user_a), 1_000).await; let res = redeem(&app, &token_a, &id, 2).await; assert_eq!(res.status(), 201, "redeem: {:?}", res.text().await); let created: serde_json::Value = res.json().await.unwrap(); assert_eq!(created["total_points"], 400, "2 x 200"); assert_eq!(created["status"], "pending_fulfillment"); assert_eq!(created["items"][0]["qty"], 2); assert_eq!(created["items"][0]["points_price"], 200); let mine: Vec = client() .get(app.url("/api/points/redemptions")) .bearer_auth(&token_a) .send() .await .unwrap() .json() .await .unwrap(); assert_eq!(mine.len(), 1); let theirs: Vec = client() .get(app.url("/api/points/redemptions")) .bearer_auth(&token_b) .send() .await .unwrap() .json() .await .unwrap(); assert!(theirs.is_empty(), "another customer sees no redemptions"); // The balance reflects the spend. assert_eq!(points_balance(&app, uuid(&user_a)).await, 600); // Admin sees it, and a customer cannot reach the admin surface. let page: serde_json::Value = client() .get(app.url("/api/admin/points/orders")) .bearer_auth(&admin) .send() .await .unwrap() .json() .await .unwrap(); assert!(page["items"].as_array().unwrap().len() >= 1); let res = client() .get(app.url("/api/admin/points/orders")) .bearer_auth(&token_a) .send() .await .unwrap(); assert_eq!(res.status(), 403, "customer is not a platform admin"); } #[tokio::test] #[serial] async fn fulfillment_transitions_are_validated() { let app = spawn_app().await; let admin = login_admin(&app).await; let product = create_product(&app, &admin, 300, 5, true).await; let id = product["id"].as_str().unwrap().to_string(); let (token, user_id) = register_customer(&app, "pm-flow").await; credit_points(&app.state, uuid(&user_id), 2_000).await; let first: serde_json::Value = redeem(&app, &token, &id, 1) .await .json() .await .unwrap(); let first_id = first["id"].as_str().unwrap().to_string(); let res = client() .post(app.url(&format!("/api/admin/points/orders/{first_id}/fulfill"))) .bearer_auth(&admin) .send() .await .unwrap(); assert_eq!(res.status(), 200); let fulfilled: serde_json::Value = res.json().await.unwrap(); assert_eq!(fulfilled["status"], "fulfilled"); // Neither transition repeats from a terminal state. let res = client() .post(app.url(&format!("/api/admin/points/orders/{first_id}/fulfill"))) .bearer_auth(&admin) .send() .await .unwrap(); assert_eq!(res.status(), 409); let res = client() .post(app.url(&format!("/api/admin/points/orders/{first_id}/cancel"))) .bearer_auth(&admin) .send() .await .unwrap(); assert_eq!(res.status(), 409); // A pending order can be cancelled once. let second: serde_json::Value = redeem(&app, &token, &id, 1) .await .json() .await .unwrap(); let second_id = second["id"].as_str().unwrap().to_string(); let res = client() .post(app.url(&format!("/api/admin/points/orders/{second_id}/cancel"))) .bearer_auth(&admin) .send() .await .unwrap(); assert_eq!(res.status(), 200); let cancelled: serde_json::Value = res.json().await.unwrap(); assert_eq!(cancelled["status"], "cancelled"); }