Files
vmall/apps/api/tests/wallet.rs
T
james 9904696e76 feat: wave 2 migration (P3, P5, P7 openspec changes)
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.
2026-09-25 15:25:29 +00:00

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));
}