7290f9483b
Добавляет 52 теста (было 7), приоритет: биллинг -> auth -> RBAC-гварды -> staff/выводы средств -> hack-сессии -> рефералы -> proxy_internal. Попутно найдены и исправлены реальные баги: - migrations/0004: ALTER TABLE auth_sessions падал на чистой БД, потому что 0002 эту таблицу уже дропнул — ломало --migrate на CI/новом инстансе и весь sqlx::test. Заменено на CREATE TABLE IF NOT EXISTS. - request_withdrawal: гонка при параллельных заявках на вывод (не было FOR UPDATE) — партнёр мог одновременно вывести баланс кратно больше доступного. Новый repository::request_partner_withdrawal блокирует строку партнёра и пересчитывает остаток в одной транзакции. - get_referral_stats: SUM() над BIGINT возвращал NUMERIC, sqlx падал на ColumnDecode для любого юзера с рефералами. - reward_referrer: начислял рефереру 10% при каждой повторной покупке того же приведённого юзера вместо разового бонуса — добавлен фильтр WHERE status = 'pending'. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
169 lines
6.1 KiB
Rust
169 lines
6.1 KiB
Rust
use netrunner_control_plane::db::repository::{AppRepository, PgRepository};
|
|
use sqlx::{PgPool, Row};
|
|
use std::sync::Arc;
|
|
|
|
// =====================================================================
|
|
// ТЕСТ 1: Создание пользователя и пополнение баланса
|
|
// =====================================================================
|
|
// SQLx сам создаст чистую БД, сам накатит миграции и сам передаст сюда pool
|
|
#[sqlx::test]
|
|
async fn test_upsert_user_and_add_balance(pool: PgPool) {
|
|
let repo = PgRepository::new(pool);
|
|
|
|
let tg_id = 999111222;
|
|
let user = repo
|
|
.upsert_user(tg_id, Some("GhostRunner".into()))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(user.balance, 0);
|
|
|
|
repo.add_balance(user.id, 5000).await.unwrap();
|
|
|
|
let updated_user = repo.get_user_by_id(user.id).await.unwrap().unwrap();
|
|
assert_eq!(updated_user.balance, 5000);
|
|
}
|
|
|
|
// =====================================================================
|
|
// ТЕСТ 2: Использование билетов (Cyberhack)
|
|
// =====================================================================
|
|
#[sqlx::test]
|
|
async fn test_consume_ticket_and_reward(pool: PgPool) {
|
|
let repo = PgRepository::new(pool.clone());
|
|
let tg_id = 888777666;
|
|
let user = repo.upsert_user(tg_id, None).await.unwrap();
|
|
|
|
assert_eq!(user.game_tickets, 0);
|
|
let reward_amount = 300;
|
|
|
|
let err = repo
|
|
.consume_ticket_and_reward(user.id, reward_amount)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(err, "NO_TICKETS", "Нельзя играть без токенов доступа");
|
|
|
|
let no_change_user = repo.get_user_by_id(user.id).await.unwrap().unwrap();
|
|
assert_eq!(no_change_user.balance, 0);
|
|
|
|
sqlx::query("UPDATE users SET game_tickets = 2 WHERE id = $1")
|
|
.bind(user.id)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
let success_now = repo
|
|
.consume_ticket_and_reward(user.id, reward_amount)
|
|
.await
|
|
.unwrap();
|
|
assert!(success_now);
|
|
|
|
let updated_user = repo.get_user_by_id(user.id).await.unwrap().unwrap();
|
|
assert_eq!(updated_user.game_tickets, 1);
|
|
assert_eq!(updated_user.balance, reward_amount);
|
|
}
|
|
|
|
// =====================================================================
|
|
// ТЕ==== ТЕСТ 3: Стресс-тест промокодов (Проверка блокировки FOR UPDATE)
|
|
// =====================================================================
|
|
#[sqlx::test]
|
|
async fn test_apply_promo_code_race_condition(pool: PgPool) {
|
|
let repo = Arc::new(PgRepository::new(pool.clone()));
|
|
|
|
let promo_code = "CYBER_RACE_2077";
|
|
let reward_amount = 1500i64;
|
|
|
|
sqlx::query(
|
|
"INSERT INTO promo_codes (code, reward_amount, max_uses, current_uses)
|
|
VALUES ($1, $2, 1, 0)",
|
|
)
|
|
.bind(promo_code)
|
|
.bind(reward_amount)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
let num_requests = 10;
|
|
let mut handles = vec![];
|
|
|
|
for i in 0..num_requests {
|
|
let repo_clone = repo.clone();
|
|
let code = promo_code.to_string();
|
|
|
|
let handle = tokio::spawn(async move {
|
|
let user = repo_clone.upsert_user(1000 + i, None).await.unwrap();
|
|
repo_clone.apply_promo_code(user.id, &code).await
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
let mut success_count = 0;
|
|
let mut fail_count = 0;
|
|
|
|
for handle in handles {
|
|
let result = handle.await.unwrap();
|
|
match result {
|
|
Ok(reward) => {
|
|
assert_eq!(reward, reward_amount);
|
|
success_count += 1;
|
|
}
|
|
Err(e) => {
|
|
assert!(e == "CODE_DEPLETED" || e == "ALREADY_USED");
|
|
fail_count += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert_eq!(
|
|
success_count, 1,
|
|
"Блокировка FOR UPDATE должна пропустить только один поток"
|
|
);
|
|
assert_eq!(fail_count, num_requests - 1);
|
|
|
|
let promo_record = sqlx::query("SELECT current_uses FROM promo_codes WHERE code = $1")
|
|
.bind(promo_code)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
let current_uses: i32 = promo_record.get("current_uses");
|
|
assert_eq!(current_uses, 1);
|
|
}
|
|
|
|
// =====================================================================
|
|
// ТЕСТ 4: Реферальная награда — одноразовый бонус за первую покупку.
|
|
// `reward_referrer` фильтрует по `status = 'pending'`, поэтому повторная
|
|
// покупка того же приведённого юзера (см. BillingService::confirm_payment)
|
|
// не должна начислять рефереру ещё раз.
|
|
// =====================================================================
|
|
#[sqlx::test]
|
|
async fn test_reward_referrer_is_one_time_bonus_not_repeated(pool: PgPool) {
|
|
let repo = PgRepository::new(pool);
|
|
|
|
let referrer = repo.upsert_user(1, None).await.unwrap();
|
|
let referred = repo.upsert_user(2, None).await.unwrap();
|
|
repo.create_referral(referrer.id, referred.id)
|
|
.await
|
|
.unwrap();
|
|
|
|
let first = repo.reward_referrer(referred.id, 100).await.unwrap();
|
|
assert!(first, "первое начисление должно найти строку referrals");
|
|
let after_first = repo.get_user_by_id(referrer.id).await.unwrap().unwrap();
|
|
assert_eq!(after_first.balance, 100);
|
|
|
|
// Вторая "покупка" того же приведённого юзера — начисление НЕ должно
|
|
// сработать повторно, статус уже 'rewarded'.
|
|
let second = repo.reward_referrer(referred.id, 50).await.unwrap();
|
|
assert!(
|
|
!second,
|
|
"повторное начисление должно быть заблокировано статусом 'rewarded'"
|
|
);
|
|
let after_second = repo.get_user_by_id(referrer.id).await.unwrap().unwrap();
|
|
assert_eq!(
|
|
after_second.balance, 100,
|
|
"баланс не должен измениться повторно"
|
|
);
|
|
|
|
let (_, total_earned) = repo.get_referral_stats(referrer.id).await.unwrap();
|
|
assert_eq!(total_earned, 100);
|
|
}
|