nxtgauge-backend-rust/crates/db/tests/ai_credits.rs
Ashwin Kumar Sivakumar 3c0f45f1ee feat: Complete Ask Ash AI Credits implementation (Tasks 1-10)
- Task 1: Admin endpoints for wallet management (balance, ledger, adjust, reconcile)
- Task 2: AI Credits admin UI (pricing.tsx, credit.tsx)
- Task 3: Ollama security (NetworkPolicy, prompt validation, audit logging)
- Task 4: LiteLLM integration (litellm.rs, migrated AI feature handlers)
- Task 5: Refund architecture (ai_refunds table, endpoints)
- Task 6: Coupons, promotions, referrals (order creation with coupon validation)
- Task 7: Subscription lifecycle (plan upgrades/downgrades/cancellations, cron jobs)
- Task 8: Credit expiration enforcement (daily cron task)
- Task 9: Token cost engine (ai_model_cost_config, margin view)
- Task 10: Observability (metrics tables, aggregation function)

New files:
- apps/users/src/litellm.rs (LiteLLM client)
- apps/users/src/ai_credits.rs (Charging primitives)
- apps/users/src/ai_subscription.rs (Plan lifecycle)
- apps/users/src/handlers/ai_credits.rs (Admin endpoints)
- apps/payments/src/ai_credits.rs (Package purchase)
- apps/payments/src/payu.rs (PayU integration)
- apps/cron/src/tasks/ai_credits.rs (Cron jobs)
- 9 SQL migrations for schema
- crates/db/src/models/ai_credits.rs (Repository)
- tests for ai_credits
2026-07-06 01:47:54 +05:30

206 lines
8 KiB
Rust

//! Integration tests for the Ask Ash AI credits wallet (Phase 1).
//! Requires a live Postgres reachable via TEST_DATABASE_URL with the
//! `20260317000000_init_config_schema`, `20260317000001_init_users_schema`,
//! and `20260703210000_ai_credits_wallet` migrations already applied.
//! Run manually: `TEST_DATABASE_URL=... cargo test -p db --test ai_credits`.
use db::models::ai_credits::{AiCreditsError, AiCreditsRepository};
use sqlx::PgPool;
use uuid::Uuid;
async fn pool() -> PgPool {
let url = std::env::var("TEST_DATABASE_URL")
.expect("set TEST_DATABASE_URL to run ai_credits integration tests");
sqlx::postgres::PgPoolOptions::new()
.max_connections(10)
.connect(&url)
.await
.expect("connect to test db")
}
async fn make_user(pool: &PgPool) -> Uuid {
let id = Uuid::new_v4();
sqlx::query("INSERT INTO users (id, email, password_hash) VALUES ($1, $2, 'x')")
.bind(id)
.bind(format!("{id}@test.local"))
.execute(pool)
.await
.expect("insert test user");
id
}
#[tokio::test]
async fn ensure_wallet_grants_free_plan_credits() {
let pool = pool().await;
let user_id = make_user(&pool).await;
let wallet = AiCreditsRepository::ensure_wallet(&pool, user_id)
.await
.expect("ensure_wallet");
assert_eq!(wallet.monthly_credits_total, 10);
assert_eq!(wallet.available_credits(), 10);
// Idempotent: calling again returns the same wallet, doesn't grant twice.
let wallet2 = AiCreditsRepository::ensure_wallet(&pool, user_id)
.await
.expect("ensure_wallet again");
assert_eq!(wallet2.id, wallet.id);
assert_eq!(wallet2.monthly_credits_total, 10);
}
#[tokio::test]
async fn reserve_capture_debits_wallet() {
let pool = pool().await;
let user_id = make_user(&pool).await;
AiCreditsRepository::ensure_wallet(&pool, user_id).await.unwrap();
let hold = AiCreditsRepository::try_reserve_credits(&pool, user_id, "test_feature", 3, None, None)
.await
.expect("reserve");
let mid_wallet = AiCreditsRepository::get_wallet(&pool, user_id).await.unwrap().unwrap();
assert_eq!(mid_wallet.reserved_credits, 3);
assert_eq!(mid_wallet.available_credits(), 7);
let captured = AiCreditsRepository::try_capture_reservation(&pool, hold.id, None)
.await
.expect("capture");
assert!(captured);
let final_wallet = AiCreditsRepository::get_wallet(&pool, user_id).await.unwrap().unwrap();
assert_eq!(final_wallet.reserved_credits, 0);
assert_eq!(final_wallet.monthly_credits_used, 3);
assert_eq!(final_wallet.lifetime_used_credits, 3);
assert_eq!(final_wallet.available_credits(), 7);
}
#[tokio::test]
async fn reserve_release_refunds_wallet() {
let pool = pool().await;
let user_id = make_user(&pool).await;
AiCreditsRepository::ensure_wallet(&pool, user_id).await.unwrap();
let hold = AiCreditsRepository::try_reserve_credits(&pool, user_id, "test_feature", 4, None, None)
.await
.expect("reserve");
let released = AiCreditsRepository::try_release_reservation(&pool, hold.id)
.await
.expect("release");
assert!(released);
let wallet = AiCreditsRepository::get_wallet(&pool, user_id).await.unwrap().unwrap();
assert_eq!(wallet.reserved_credits, 0);
assert_eq!(wallet.monthly_credits_used, 0);
assert_eq!(wallet.available_credits(), 10);
// Regression: releasing a hold must also roll back the daily action/
// credit counters bumped at reserve time -- a failed request (e.g. the
// LLM call errors) must not permanently burn daily quota when the user
// was never actually charged.
assert_eq!(wallet.daily_actions_used, 0, "release must roll back daily_actions_used");
assert_eq!(wallet.daily_credits_used, 0, "release must roll back daily_credits_used");
// Releasing an already-released hold is a no-op, not an error.
let released_again = AiCreditsRepository::try_release_reservation(&pool, hold.id)
.await
.expect("release again");
assert!(!released_again);
}
#[tokio::test]
async fn reserve_beyond_balance_is_rejected() {
let pool = pool().await;
let user_id = make_user(&pool).await;
AiCreditsRepository::ensure_wallet(&pool, user_id).await.unwrap();
let result = AiCreditsRepository::try_reserve_credits(&pool, user_id, "test_feature", 11, None, None).await;
// The seeded Free plan's daily_credit_limit (10) equals its
// monthly_credits_total (10), so a request for 11 trips the
// daily-limit guardrail before the balance check ever runs -- both
// are correct rejections of the same over-request, so accept either.
assert!(matches!(
result,
Err(AiCreditsError::InsufficientCredits) | Err(AiCreditsError::DailyCreditLimitReached)
));
// Balance must be untouched after a rejected reservation.
let wallet = AiCreditsRepository::get_wallet(&pool, user_id).await.unwrap().unwrap();
assert_eq!(wallet.reserved_credits, 0);
assert_eq!(wallet.available_credits(), 10);
}
#[tokio::test]
async fn reserve_is_idempotent_on_key() {
let pool = pool().await;
let user_id = make_user(&pool).await;
AiCreditsRepository::ensure_wallet(&pool, user_id).await.unwrap();
let key = format!("idem-{}", Uuid::new_v4());
let hold1 = AiCreditsRepository::try_reserve_credits(&pool, user_id, "test_feature", 5, None, Some(&key))
.await
.expect("first reserve");
let hold2 = AiCreditsRepository::try_reserve_credits(&pool, user_id, "test_feature", 5, None, Some(&key))
.await
.expect("retried reserve with same key");
assert_eq!(hold1.id, hold2.id, "retry with same idempotency key must not create a second hold");
let wallet = AiCreditsRepository::get_wallet(&pool, user_id).await.unwrap().unwrap();
assert_eq!(wallet.reserved_credits, 5, "credits must only be reserved once despite the retry");
}
/// The concrete race condition this design exists to prevent (Section 4.2
/// / G4 of the architecture doc): fire N concurrent reservations against a
/// wallet that can only afford one of them, and confirm exactly one wins.
#[tokio::test]
async fn concurrent_reservations_cannot_overdraw_wallet() {
let pool = pool().await;
let user_id = make_user(&pool).await;
let wallet = AiCreditsRepository::ensure_wallet(&pool, user_id).await.unwrap();
assert_eq!(wallet.available_credits(), 10);
// 5 concurrent requests each asking for all 10 credits. At most one
// should succeed; a broken check-then-write implementation would let
// several through and drive the balance negative.
let mut handles = Vec::new();
for _ in 0..5 {
let pool = pool.clone();
handles.push(tokio::spawn(async move {
AiCreditsRepository::try_reserve_credits(&pool, user_id, "test_feature", 10, None, None).await
}));
}
let mut successes = 0;
for h in handles {
if h.await.unwrap().is_ok() {
successes += 1;
}
}
assert_eq!(successes, 1, "exactly one concurrent reservation for the full balance should succeed");
let final_wallet = AiCreditsRepository::get_wallet(&pool, user_id).await.unwrap().unwrap();
assert_eq!(final_wallet.available_credits(), 0);
assert!(
final_wallet.monthly_credits_used <= final_wallet.monthly_credits_total,
"wallet must never be driven into overdraft"
);
}
#[tokio::test]
async fn daily_action_limit_enforced() {
let pool = pool().await;
let user_id = make_user(&pool).await;
AiCreditsRepository::ensure_wallet(&pool, user_id).await.unwrap();
// Free plan's daily_action_limit is 3 (seeded in the migration).
for _ in 0..3 {
AiCreditsRepository::try_reserve_credits(&pool, user_id, "test_feature", 1, None, None)
.await
.expect("reserve within daily action limit");
}
let fourth = AiCreditsRepository::try_reserve_credits(&pool, user_id, "test_feature", 1, None, None).await;
assert!(matches!(fourth, Err(AiCreditsError::DailyActionLimitReached)));
}