Platform-owned points products and a redemption order lifecycle kept separate from cash orders. Redeeming locks the published product, reserves stock, creates the order, debits points through the archived customer-accounts ledger with the order as reference, and snapshots the line in one transaction; a failure leaves no order, no stock change, and no ledger entry. Fulfilment moves only from pending_fulfillment, and customers see only their own redemptions. Demo seeding credits the demo customer through the same guarded credit path with reason seed, once, so no balance is ever written absolutely. Surfaces (admin console, mall points page) and product seeding follow.
348 lines
10 KiB
Rust
348 lines
10 KiB
Rust
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<serde_json::Value> = 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<serde_json::Value> = 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<serde_json::Value> = 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<serde_json::Value> = 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");
|
|
}
|