335 lines
10 KiB
Rust
335 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");
|
|
}
|