//! Verifies the cron reaper's core query/release logic by reproducing it //! directly against the wallet primitives (the reaper itself lives in //! apps/cron, which can't easily be imported as a lib from here -- this //! test exercises the same "find expired holds, release them" contract //! the cron task relies on). use db::models::ai_credits::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 this test"); sqlx::postgres::PgPoolOptions::new() .max_connections(5) .connect(&url) .await .expect("connect to test db") } #[tokio::test] async fn expired_hold_is_swept_and_credits_return_to_available() { let pool = pool().await; // A fresh random user/wallet every run (matching ai_credits.rs's // make_user() pattern) rather than the fixed UUID this test used to // hardcode - against a persistent test database re-run many times over // a session, reusing one fixed user let earlier partial runs' stuck // reserved_credits (left over whenever the test panicked before its own // capture/release step) silently accumulate onto every subsequent run. let user_id = Uuid::new_v4(); // user_ai_subscriptions.user_id has a FK to users(id). sqlx::query("INSERT INTO users (id, email, password_hash) VALUES ($1, $2, 'x') ON CONFLICT (id) DO NOTHING") .bind(user_id) .bind(format!("{user_id}@test.local")) .execute(&pool) .await .unwrap(); AiCreditsRepository::ensure_wallet(&pool, user_id).await.unwrap(); // This test's sweep query below is intentionally global (it mirrors the // real cron reaper, which sweeps every wallet, not just one) - in a real // deployment the reaper runs continuously so nothing stays 'held' past // its expiry for long. Against a persistent test database re-run many // times over a session (not wiped between CI runs), other tests' // reservations that were never captured/released can accumulate and go // stale, which would make the exact-match assertion below flaky. Sweep // those first so this test starts from the same clean slate a real // continuously-running reaper would maintain. sqlx::query("DELETE FROM ai_reservation_holds WHERE status = 'held' AND expires_at < NOW()") .execute(&pool) .await .unwrap(); let hold = AiCreditsRepository::try_reserve_credits(&pool, user_id, "help_answer", 4, None, None) .await .expect("reserve"); let wallet = AiCreditsRepository::get_wallet(&pool, user_id).await.unwrap().unwrap(); assert_eq!(wallet.reserved_credits, 4, "credits should be reserved before the sweep"); // Force the hold into the past, exactly as an abandoned request would // eventually end up (the migration default is NOW() + 5 minutes). sqlx::query("UPDATE ai_reservation_holds SET expires_at = NOW() - INTERVAL '1 minute' WHERE id = $1") .bind(hold.id) .execute(&pool) .await .unwrap(); // Reproduce the cron task's sweep query + release call. let expired: Vec = sqlx::query_scalar( "SELECT id FROM ai_reservation_holds WHERE status = 'held' AND expires_at < NOW()", ) .fetch_all(&pool) .await .unwrap(); assert_eq!(expired, vec![hold.id]); for id in expired { let released = AiCreditsRepository::try_release_reservation(&pool, id).await.unwrap(); assert!(released); } let wallet = AiCreditsRepository::get_wallet(&pool, user_id).await.unwrap().unwrap(); assert_eq!(wallet.reserved_credits, 0, "reaper must release the reservation back to available"); assert_eq!(wallet.available_credits(), 10, "released credits must be fully spendable again"); let hold_row: (String,) = sqlx::query_as("SELECT status FROM ai_reservation_holds WHERE id = $1") .bind(hold.id) .fetch_one(&pool) .await .unwrap(); assert_eq!(hold_row.0, "released"); }