Тесты для ключевой бизнес-логики + 3 найденных бага

Добавляет 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>
This commit is contained in:
2026-07-09 01:18:15 +07:00
parent 375b333978
commit 7290f9483b
12 changed files with 1451 additions and 41 deletions
+100 -4
View File
@@ -63,7 +63,11 @@ pub trait AppRepository: Send + Sync {
) -> Result<Option<(Option<Uuid>, bool, chrono::DateTime<chrono::Utc>)>, sqlx::Error>;
/// Помечает сессию подтверждённой; `false`, если код не найден, уже
/// подтверждён или истёк (защита от повторного использования/гонки).
async fn verify_auth_session(&self, auth_code: &str, user_id: Uuid) -> Result<bool, sqlx::Error>;
async fn verify_auth_session(
&self,
auth_code: &str,
user_id: Uuid,
) -> Result<bool, sqlx::Error>;
// --- REFRESH-СЕССИИ (единая cookie-авторизация) ---
async fn create_refresh_session(
@@ -208,6 +212,19 @@ pub trait AppRepository: Send + Sync {
amount: i64,
note: Option<&str>,
) -> Result<WithdrawalRequest, sqlx::Error>;
/// Атомарная версия для записи: блокирует строку партнёра (`FOR UPDATE`) и
/// пересчитывает доступный остаток внутри той же транзакции перед вставкой
/// заявки — без этого несколько параллельных заявок на весь остаток могут
/// пройти одновременно и увести партнёра в минус (та же гонка, от которой
/// защищает `FOR UPDATE` в `apply_promo_code`). `Err("INSUFFICIENT_BALANCE")`
/// — запрошенная сумма больше доступного остатка.
async fn request_partner_withdrawal(
&self,
partner_id: Uuid,
currency: &str,
amount: i64,
note: Option<&str>,
) -> Result<WithdrawalRequest, String>;
/// `status_filter` — `None` = все заявки (используется owner/moderator).
async fn list_withdrawal_requests(
&self,
@@ -394,7 +411,11 @@ impl AppRepository for PgRepository {
}
#[instrument(skip(self))]
async fn verify_auth_session(&self, auth_code: &str, user_id: Uuid) -> Result<bool, sqlx::Error> {
async fn verify_auth_session(
&self,
auth_code: &str,
user_id: Uuid,
) -> Result<bool, sqlx::Error> {
let r = sqlx::query(
"UPDATE auth_sessions SET user_id = $1, is_verified = TRUE \
WHERE auth_code = $2 AND is_verified = FALSE AND expires_at > NOW()",
@@ -821,7 +842,10 @@ impl AppRepository for PgRepository {
#[instrument(skip(self))]
async fn reward_referrer(&self, referred_id: Uuid, amount: i64) -> Result<bool, sqlx::Error> {
let mut tx = self.pool.begin().await?;
let res: Option<(Uuid,)> = sqlx::query_as("UPDATE referrals SET status = 'rewarded', total_earned = total_earned + $1 WHERE referred_id = $2 RETURNING referrer_id")
// WHERE status = 'pending' — разовый бонус за первую покупку приведённого
// юзера, а не revenue share навсегда: без этого условия UPDATE срабатывал
// при каждом вызове (т.е. при каждой последующей покупке того же юзера).
let res: Option<(Uuid,)> = sqlx::query_as("UPDATE referrals SET status = 'rewarded', total_earned = total_earned + $1 WHERE referred_id = $2 AND status = 'pending' RETURNING referrer_id")
.bind(amount).bind(referred_id).fetch_optional(&mut *tx).await?;
if let Some((r_id,)) = res {
@@ -840,7 +864,7 @@ impl AppRepository for PgRepository {
#[instrument(skip(self))]
async fn get_referral_stats(&self, user_id: Uuid) -> Result<(i64, i64), sqlx::Error> {
let row: (Option<i64>, Option<i64>) = sqlx::query_as(
"SELECT COUNT(*), SUM(total_earned) FROM referrals WHERE referrer_id = $1",
"SELECT COUNT(*), SUM(total_earned)::BIGINT FROM referrals WHERE referrer_id = $1",
)
.bind(user_id)
.fetch_one(&self.pool)
@@ -1148,6 +1172,78 @@ impl AppRepository for PgRepository {
.await
}
#[instrument(skip(self))]
async fn request_partner_withdrawal(
&self,
partner_id: Uuid,
currency: &str,
amount: i64,
note: Option<&str>,
) -> Result<WithdrawalRequest, String> {
let mut tx = self.pool.begin().await.map_err(|e| e.to_string())?;
// Блокируем строку партнёра — сериализует конкурентные заявки на вывод
// от одного и того же партнёра, пока эта транзакция не завершится.
sqlx::query("SELECT id FROM users WHERE id = $1 FOR UPDATE")
.bind(partner_id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let earned: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(amount), 0)::BIGINT FROM partner_commissions \
WHERE partner_id = $1 AND currency = $2",
)
.bind(partner_id)
.bind(currency)
.fetch_one(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let withdrawn: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(amount), 0)::BIGINT FROM withdrawal_requests \
WHERE partner_id = $1 AND currency = $2 AND status IN ('approved', 'paid')",
)
.bind(partner_id)
.bind(currency)
.fetch_one(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let pending: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(amount), 0)::BIGINT FROM withdrawal_requests \
WHERE partner_id = $1 AND currency = $2 AND status = 'pending'",
)
.bind(partner_id)
.bind(currency)
.fetch_one(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let available = earned - withdrawn - pending;
if amount > available {
tx.rollback().await.map_err(|e| e.to_string())?;
return Err("INSUFFICIENT_BALANCE".to_string());
}
let req = sqlx::query_as::<_, WithdrawalRequest>(
"INSERT INTO withdrawal_requests (id, partner_id, currency, amount, note) \
VALUES ($1, $2, $3, $4, $5) \
RETURNING id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by",
)
.bind(Uuid::new_v4())
.bind(partner_id)
.bind(currency)
.bind(amount)
.bind(note)
.fetch_one(&mut *tx)
.await
.map_err(|e| e.to_string())?;
tx.commit().await.map_err(|e| e.to_string())?;
Ok(req)
}
#[instrument(skip(self))]
async fn list_withdrawal_requests(
&self,