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.
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user