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 { 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 { 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 = 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 = 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 = 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::().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)); }