mod common; use common::{client, register_customer, spawn_app}; use serial_test::serial; use uuid::Uuid; use vmall_api::error::ApiError; use vmall_api::models::{AccountKind, CustomerAccountEntry}; use vmall_api::modules::account::service; use vmall_api::state::AppState; async fn summary(app: &common::TestApp, token: &str) -> serde_json::Value { let res = client() .get(app.url("/api/me/stats")) .bearer_auth(token) .send() .await .unwrap(); assert_eq!(res.status(), 200, "stats failed: {:?}", res.text().await); res.json().await.unwrap() } fn user_uuid(id: &str) -> Uuid { Uuid::parse_str(id).unwrap() } async fn credit_points(state: &AppState, user_id: Uuid, amount: i64) -> CustomerAccountEntry { let mut tx = state.db.begin().await.unwrap(); let entry = service::credit( &mut tx, user_id, AccountKind::Points, None, amount, "test_credit", None, ) .await .unwrap(); tx.commit().await.unwrap(); entry } /// One debit in its own transaction, so two calls can race. async fn debit_points(state: &AppState, user_id: Uuid, amount: i64) -> Result<(), ApiError> { let mut tx = state.db.begin().await.unwrap(); match service::debit( &mut tx, user_id, AccountKind::Points, None, amount, "test_debit", None, ) .await { Ok(_) => { tx.commit().await.unwrap(); Ok(()) } Err(err) => { tx.rollback().await.ok(); Err(err) } } } async fn entry_count(state: &AppState, user_id: Uuid) -> 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", ) .bind(user_id) .fetch_one(&state.db) .await .unwrap() } #[tokio::test] #[serial] async fn new_customer_summary_is_zero() { let app = spawn_app().await; let (token, user_id) = register_customer(&app, "acct-zero").await; let stats = summary(&app, &token).await; assert_eq!(stats["balance_minor"], 0); assert_eq!(stats["frozen_minor"], 0); assert_eq!(stats["points"], 0); assert_eq!(stats["currency"], "USD"); // Registration created the three rows; the summary did not invent them. let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM customer_accounts WHERE user_id = $1") .bind(user_uuid(&user_id)) .fetch_one(&app.db) .await .unwrap(); assert_eq!(rows, 3); } #[tokio::test] #[serial] async fn summary_requires_customer() { let app = spawn_app().await; let admin = common::login_admin(&app).await; let res = client() .get(app.url("/api/me/stats")) .bearer_auth(&admin) .send() .await .unwrap(); assert_eq!(res.status(), 403, "platform admin is not a customer"); let res = client().get(app.url("/api/me/stats")).send().await.unwrap(); assert_eq!(res.status(), 401); } #[tokio::test] #[serial] async fn summary_is_owned_and_entries_are_append_only() { let app = spawn_app().await; let (token_a, user_a) = register_customer(&app, "acct-a").await; let (token_b, _user_b) = register_customer(&app, "acct-b").await; let entry = credit_points(&app.state, user_uuid(&user_a), 500).await; assert_eq!(entry.delta_minor, 500); assert_eq!(entry.balance_minor, 500); assert_eq!(entry.reason, "test_credit"); let stats_a = summary(&app, &token_a).await; assert_eq!(stats_a["points"], 500, "owner sees the credit"); let stats_b = summary(&app, &token_b).await; assert_eq!(stats_b["points"], 0, "another customer sees their own zero"); // Reads never append ledger rows, and no public API mutates balances. assert_eq!(entry_count(&app.state, user_uuid(&user_a)).await, 1); let _ = summary(&app, &token_a).await; assert_eq!(entry_count(&app.state, user_uuid(&user_a)).await, 1); for (method, status) in [("POST", 405), ("PUT", 405), ("DELETE", 405)] { let res = client() .request(method.parse().unwrap(), app.url("/api/me/stats")) .bearer_auth(&token_a) .send() .await .unwrap(); assert_eq!(res.status(), status, "{method} /api/me/stats must not mutate"); } let res = client() .get(app.url("/api/me/stats/entries")) .bearer_auth(&token_a) .send() .await .unwrap(); assert_eq!(res.status(), 404, "the public ledger listing stays out of scope"); } #[tokio::test] #[serial] async fn freeze_moves_available_to_frozen_in_balance() { let app = spawn_app().await; let (token, user_id) = register_customer(&app, "acct-freeze").await; let user = user_uuid(&user_id); let mut tx = app.state.db.begin().await.unwrap(); service::credit( &mut tx, user, AccountKind::Available, None, 1_000, "test_credit", None, ) .await .unwrap(); tx.commit().await.unwrap(); let mut tx = app.state.db.begin().await.unwrap(); let (out, into) = service::freeze(&mut tx, user, 400, "test_freeze", None) .await .unwrap(); tx.commit().await.unwrap(); assert_eq!(out.delta_minor, -400); assert_eq!(out.balance_minor, 600); assert_eq!(into.delta_minor, 400); assert_eq!(into.balance_minor, 400); let stats = summary(&app, &token).await; assert_eq!(stats["balance_minor"], 600); assert_eq!(stats["frozen_minor"], 400, "the pair stays balanced"); // One credit plus the two sides of the transfer. assert_eq!(entry_count(&app.state, user).await, 3); } #[tokio::test] #[serial] async fn competing_debits_never_go_negative() { let app = spawn_app().await; let (_token, user_id) = register_customer(&app, "acct-race").await; let user = user_uuid(&user_id); credit_points(&app.state, user, 100).await; let first = { let state = app.state.clone(); async move { debit_points(&state, user, 60).await } }; let second = { let state = app.state.clone(); async move { debit_points(&state, user, 60).await } }; let (a, b) = tokio::join!(first, second); let wins = [a.is_ok(), b.is_ok()].iter().filter(|ok| **ok).count(); assert_eq!(wins, 1, "exactly one debit fits in the balance"); let loser = a.err().or_else(|| b.err()).unwrap(); assert!(matches!(loser, ApiError::Conflict(_)), "got {loser:?}"); let stored: i64 = sqlx::query_scalar( "SELECT balance_minor FROM customer_accounts WHERE user_id = $1 AND kind = 'points'", ) .bind(user) .fetch_one(&app.db) .await .unwrap(); assert_eq!(stored, 40); assert!(stored >= 0, "no negative balance is ever stored"); // Credit plus the single successful debit. assert_eq!(entry_count(&app.state, user).await, 2); }