Тесты для ключевой бизнес-логики + 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:
+100
-4
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user