All checks were successful
build-and-release / build (catering-services) (push) Successful in 1m31s
build-and-release / build (cron) (push) Successful in 1m54s
build-and-release / build (companies) (push) Successful in 2m7s
build-and-release / build (customers) (push) Successful in 2m53s
build-and-release / build (fitness-trainers) (push) Successful in 1m29s
build-and-release / build (developers) (push) Successful in 1m43s
build-and-release / build (gateway) (push) Successful in 1m35s
build-and-release / build (employees) (push) Successful in 2m15s
build-and-release / build (jobs) (push) Successful in 1m10s
build-and-release / build (graphic-designers) (push) Successful in 2m9s
build-and-release / build (makeup-artists) (push) Successful in 1m35s
build-and-release / build (photographers) (push) Successful in 1m44s
build-and-release / build (job-seekers) (push) Successful in 3m8s
build-and-release / build (payments) (push) Successful in 2m45s
build-and-release / build (social-media-managers) (push) Successful in 2m35s
backend-integration-tests / ai-credits (push) Successful in 50s
build-and-release / build (tutors) (push) Successful in 3m10s
build-and-release / build (ugc-content-creators) (push) Successful in 2m49s
build-and-release / build (video-editors) (push) Successful in 2m48s
build-and-release / build (users) (push) Successful in 4m38s
The fixed UUID (33333333-...) meant every re-run against the same persistent test database reused the same wallet - any earlier partial run that panicked before its own capture/release step left reserved_credits permanently stuck above zero, so this run's fresh reserve (expected to bring it to exactly 4) actually reads 8, 12, etc. depending on how many times the suite had previously failed midway. Matches ai_credits.rs's make_user() pattern (Uuid::new_v4()) instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
95 lines
4 KiB
Rust
95 lines
4 KiB
Rust
//! 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<Uuid> = 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");
|
|
}
|