Implements, verifies, and archives the three remaining Wave 2 changes from openspec/MIGRATION-PLAN.md. - add-wallet-settlement (P3): demo recharge, guarded withdrawal freeze and one-time admin review, paginated own fund entries, idempotent per-shop weekly/monthly settlement statements with commission rate and one-time payout confirmation. - add-merchant-onboarding (P5): personal/enterprise applications with one live application per user, guarded review with mandatory rejection reason, and transactional shop + owner provisioning returning one-time credentials; mall onboarding/status pages and an admin review console. - add-membership-messaging (P7): platform member levels, append-only growth accrual on order completion with guarded one-way leveling, order/shipment/ refund system messages with unread/read state and soft deletion, plus the mall header unread badge. Backend: migrations 0019-0023, new wallet, settlement, merchant_onboarding, membership and messaging modules, event hooks in order/fulfillment/aftersale, and integration suites for each. Shared contract extended and all three frontends updated; code indexes, domain docs, backend guidelines and the migration tracker synced. Verification: cargo test -p vmall-api green twice consecutively; mall, admin and shop-admin builds pass; browser smoke on every new surface; openspec validate --all --strict green (33 passed). The three changes share the @vmall/shared contract, the mall mock adapter and per-app locale/nav files, so they are committed together to keep every commit buildable.
349 lines
12 KiB
Rust
349 lines
12 KiB
Rust
mod common;
|
|
|
|
use common::{client, login_admin, register_customer, spawn_app, TestApp};
|
|
use serial_test::serial;
|
|
|
|
// Wallet suite: every test provisions its own users and only asserts on rows
|
|
// it created, because the shared test database is never truncated.
|
|
|
|
async fn wallet(app: &TestApp, token: &str) -> serde_json::Value {
|
|
let res = client()
|
|
.get(app.url("/api/wallet"))
|
|
.bearer_auth(token)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 200, "{:?}", res.text().await);
|
|
res.json().await.unwrap()
|
|
}
|
|
|
|
async fn balances(app: &TestApp, token: &str) -> (i64, i64) {
|
|
let body = wallet(app, token).await;
|
|
(
|
|
body["available_minor"].as_i64().unwrap(),
|
|
body["frozen_minor"].as_i64().unwrap(),
|
|
)
|
|
}
|
|
|
|
async fn recharge(app: &TestApp, token: &str, amount_minor: i64) -> reqwest::Response {
|
|
client()
|
|
.post(app.url("/api/wallet/recharges"))
|
|
.bearer_auth(token)
|
|
.json(&serde_json::json!({ "amount_minor": amount_minor }))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
async fn withdraw(app: &TestApp, token: &str, amount_minor: i64) -> reqwest::Response {
|
|
client()
|
|
.post(app.url("/api/wallet/withdrawals"))
|
|
.bearer_auth(token)
|
|
.json(&serde_json::json!({
|
|
"amount_minor": amount_minor,
|
|
"account_details": { "method": "demo", "account": "acct-1", "holder": "Test Holder" }
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
async fn my_withdrawals(app: &TestApp, token: &str) -> Vec<serde_json::Value> {
|
|
let res = client()
|
|
.get(app.url("/api/wallet/withdrawals"))
|
|
.bearer_auth(token)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 200, "{:?}", res.text().await);
|
|
res.json().await.unwrap()
|
|
}
|
|
|
|
async fn entries(app: &TestApp, token: &str, page: i64, per_page: i64) -> serde_json::Value {
|
|
let res = client()
|
|
.get(app.url("/api/wallet/entries"))
|
|
.query(&[
|
|
("page", page.to_string()),
|
|
("per_page", per_page.to_string()),
|
|
])
|
|
.bearer_auth(token)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 200, "{:?}", res.text().await);
|
|
res.json().await.unwrap()
|
|
}
|
|
|
|
async fn review(
|
|
app: &TestApp,
|
|
admin: &str,
|
|
id: &str,
|
|
outcome: &str,
|
|
note: Option<&str>,
|
|
) -> reqwest::Response {
|
|
client()
|
|
.post(app.url(&format!("/api/admin/wallet/withdrawals/{id}/review")))
|
|
.bearer_auth(admin)
|
|
.json(&serde_json::json!({ "outcome": outcome, "note": note }))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
fn reasons(page: &serde_json::Value) -> Vec<String> {
|
|
page["items"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|e| e["reason"].as_str().unwrap().to_string())
|
|
.collect()
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn demo_recharge_credits_available_with_one_entry() {
|
|
let app = spawn_app().await;
|
|
let (token, _) = register_customer(&app, "wl-recharge").await;
|
|
|
|
let res = recharge(&app, &token, 5000).await;
|
|
assert_eq!(res.status(), 201, "{:?}", res.text().await);
|
|
let body: serde_json::Value = res.json().await.unwrap();
|
|
// The payload must label the flow as simulated.
|
|
assert_eq!(body["demo"], true);
|
|
assert_eq!(body["status"], "credited");
|
|
assert_eq!(body["amount_minor"], 5000);
|
|
assert_eq!(body["available_minor"], 5000);
|
|
|
|
assert_eq!(balances(&app, &token).await, (5000, 0));
|
|
|
|
let page = entries(&app, &token, 1, 20).await;
|
|
assert_eq!(page["total"], 1);
|
|
let entry = &page["items"][0];
|
|
assert_eq!(entry["reason"], "wallet_recharge");
|
|
assert_eq!(entry["account_kind"], "available");
|
|
assert_eq!(entry["delta_minor"], 5000);
|
|
assert_eq!(entry["balance_minor"], 5000);
|
|
assert_eq!(entry["reference_type"], "wallet_recharge");
|
|
assert!(entry["reference_id"].is_string());
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn non_positive_amounts_are_rejected() {
|
|
let app = spawn_app().await;
|
|
let (token, _) = register_customer(&app, "wl-amount").await;
|
|
|
|
assert_eq!(recharge(&app, &token, 0).await.status(), 400);
|
|
assert_eq!(recharge(&app, &token, -100).await.status(), 400);
|
|
assert_eq!(withdraw(&app, &token, 0).await.status(), 400);
|
|
|
|
// A malformed payout destination is rejected before any balance moves.
|
|
let res = client()
|
|
.post(app.url("/api/wallet/withdrawals"))
|
|
.bearer_auth(&token)
|
|
.json(&serde_json::json!({
|
|
"amount_minor": 100,
|
|
"account_details": { "method": " ", "account": "acct" }
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), 400, "{:?}", res.text().await);
|
|
assert_eq!(balances(&app, &token).await, (0, 0));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn insufficient_balance_leaves_no_application_or_balance_change() {
|
|
let app = spawn_app().await;
|
|
let (token, _) = register_customer(&app, "wl-insufficient").await;
|
|
|
|
// Nothing funded: the guarded debit fails and the insert rolls back with it.
|
|
assert_eq!(withdraw(&app, &token, 500).await.status(), 409);
|
|
assert_eq!(balances(&app, &token).await, (0, 0));
|
|
assert!(my_withdrawals(&app, &token).await.is_empty());
|
|
|
|
assert_eq!(recharge(&app, &token, 100).await.status(), 201);
|
|
assert_eq!(withdraw(&app, &token, 200).await.status(), 409);
|
|
assert_eq!(balances(&app, &token).await, (100, 0));
|
|
assert!(my_withdrawals(&app, &token).await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn withdrawal_freezes_funds_and_reject_returns_them() {
|
|
let app = spawn_app().await;
|
|
let admin = login_admin(&app).await;
|
|
let (token, _) = register_customer(&app, "wl-reject").await;
|
|
assert_eq!(recharge(&app, &token, 1000).await.status(), 201);
|
|
|
|
let res = withdraw(&app, &token, 400).await;
|
|
assert_eq!(res.status(), 201, "{:?}", res.text().await);
|
|
let created: serde_json::Value = res.json().await.unwrap();
|
|
assert_eq!(created["status"], "pending");
|
|
assert_eq!(created["amount_minor"], 400);
|
|
assert_eq!(created["account_details"]["method"], "demo");
|
|
let id = created["id"].as_str().unwrap().to_string();
|
|
|
|
// Freeze moved available -> frozen with a paired entry for each side.
|
|
assert_eq!(balances(&app, &token).await, (600, 400));
|
|
let page = entries(&app, &token, 1, 20).await;
|
|
let reasons = reasons(&page);
|
|
assert_eq!(
|
|
reasons
|
|
.iter()
|
|
.filter(|r| r.as_str() == "wallet_withdrawal_freeze")
|
|
.count(),
|
|
2
|
|
);
|
|
// One -400 out of available and one +400 into frozen.
|
|
let freeze_deltas: Vec<i64> = page["items"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|e| e["reason"] == "wallet_withdrawal_freeze")
|
|
.map(|e| e["delta_minor"].as_i64().unwrap())
|
|
.collect();
|
|
assert!(freeze_deltas.contains(&-400) && freeze_deltas.contains(&400));
|
|
|
|
// The platform queue sees it as pending; a customer may not review.
|
|
let queue: Vec<serde_json::Value> = client()
|
|
.get(app.url("/api/admin/wallet/withdrawals"))
|
|
.query(&[("status", "pending")])
|
|
.bearer_auth(&admin)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert!(queue.iter().any(|w| w["id"] == created["id"]));
|
|
assert_eq!(
|
|
review(&app, &token, &id, "approve", None).await.status(),
|
|
403
|
|
);
|
|
|
|
let res = review(&app, &admin, &id, "reject", Some("no payout channel")).await;
|
|
assert_eq!(res.status(), 200, "{:?}", res.text().await);
|
|
let rejected: serde_json::Value = res.json().await.unwrap();
|
|
assert_eq!(rejected["status"], "rejected");
|
|
assert_eq!(rejected["review_note"], "no payout channel");
|
|
assert!(rejected["reviewed_at"].is_string());
|
|
|
|
// Funds returned to available.
|
|
assert_eq!(balances(&app, &token).await, (1000, 0));
|
|
let page = entries(&app, &token, 1, 20).await;
|
|
let release_deltas: Vec<i64> = page["items"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|e| e["reason"] == "wallet_withdrawal_rejected")
|
|
.map(|e| e["delta_minor"].as_i64().unwrap())
|
|
.collect();
|
|
assert_eq!(release_deltas.len(), 2);
|
|
assert!(release_deltas.contains(&400) && release_deltas.contains(&-400));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn approve_consumes_frozen_funds_and_repeat_review_conflicts() {
|
|
let app = spawn_app().await;
|
|
let admin = login_admin(&app).await;
|
|
let (token, _) = register_customer(&app, "wl-approve").await;
|
|
assert_eq!(recharge(&app, &token, 1000).await.status(), 201);
|
|
|
|
let created: serde_json::Value = withdraw(&app, &token, 600).await.json().await.unwrap();
|
|
let id = created["id"].as_str().unwrap().to_string();
|
|
assert_eq!(balances(&app, &token).await, (400, 600));
|
|
|
|
let res = review(&app, &admin, &id, "approve", None).await;
|
|
assert_eq!(res.status(), 200, "{:?}", res.text().await);
|
|
assert_eq!(res.json::<serde_json::Value>().await.unwrap()["status"], "approved");
|
|
assert_eq!(balances(&app, &token).await, (400, 0));
|
|
|
|
// Exactly one approved entry, and a repeat review changes nothing.
|
|
let page = entries(&app, &token, 1, 20).await;
|
|
let approved: Vec<&serde_json::Value> = page["items"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|e| e["reason"] == "wallet_withdrawal_approved")
|
|
.collect();
|
|
assert_eq!(approved.len(), 1);
|
|
assert_eq!(approved[0]["account_kind"], "frozen");
|
|
assert_eq!(approved[0]["delta_minor"], -600);
|
|
assert_eq!(approved[0]["balance_minor"], 0);
|
|
|
|
let before = entries(&app, &token, 1, 20).await["total"]
|
|
.as_i64()
|
|
.unwrap();
|
|
assert_eq!(
|
|
review(&app, &admin, &id, "approve", None).await.status(),
|
|
409
|
|
);
|
|
assert_eq!(
|
|
review(&app, &admin, &id, "reject", None).await.status(),
|
|
409
|
|
);
|
|
assert_eq!(balances(&app, &token).await, (400, 0));
|
|
assert_eq!(
|
|
entries(&app, &token, 1, 20).await["total"].as_i64().unwrap(),
|
|
before
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn concurrent_withdrawals_cannot_overdraw() {
|
|
let app = spawn_app().await;
|
|
let (token, _) = register_customer(&app, "wl-race").await;
|
|
assert_eq!(recharge(&app, &token, 1000).await.status(), 201);
|
|
|
|
let (a, b) = tokio::join!(withdraw(&app, &token, 1000), withdraw(&app, &token, 1000));
|
|
let statuses = [a.status().as_u16(), b.status().as_u16()];
|
|
assert!(
|
|
statuses.contains(&201) && statuses.contains(&409),
|
|
"expected one success and one conflict, got {statuses:?}"
|
|
);
|
|
|
|
// No overdraw: exactly one application, balances still non-negative.
|
|
assert_eq!(balances(&app, &token).await, (0, 1000));
|
|
assert_eq!(my_withdrawals(&app, &token).await.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn entry_pages_are_paginated_and_user_isolated() {
|
|
let app = spawn_app().await;
|
|
let (alice, _) = register_customer(&app, "wl-alice").await;
|
|
let (bob, _) = register_customer(&app, "wl-bob").await;
|
|
|
|
for amount in [100, 200, 300] {
|
|
assert_eq!(recharge(&app, &alice, amount).await.status(), 201);
|
|
}
|
|
assert_eq!(recharge(&app, &bob, 999).await.status(), 201);
|
|
|
|
let first = entries(&app, &alice, 1, 2).await;
|
|
assert_eq!(first["total"], 3);
|
|
assert_eq!(first["items"].as_array().unwrap().len(), 2);
|
|
let second = entries(&app, &alice, 2, 2).await;
|
|
assert_eq!(second["items"].as_array().unwrap().len(), 1);
|
|
|
|
// Newest first: the 300 credit leads.
|
|
assert_eq!(first["items"][0]["delta_minor"], 300);
|
|
assert_eq!(second["items"][0]["delta_minor"], 100);
|
|
|
|
// Bob only ever sees his own single entry.
|
|
let bob_page = entries(&app, &bob, 1, 20).await;
|
|
assert_eq!(bob_page["total"], 1);
|
|
let alice_ids: Vec<&str> = first["items"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.chain(second["items"].as_array().unwrap())
|
|
.map(|e| e["id"].as_str().unwrap())
|
|
.collect();
|
|
let bob_id = bob_page["items"][0]["id"].as_str().unwrap();
|
|
assert!(!alice_ids.contains(&bob_id));
|
|
}
|