Тесты для ключевой бизнес-логики + 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
+326 -11
View File
@@ -188,9 +188,7 @@ impl BillingService {
})?
.json()
.await
.map_err(|e| {
AppError::Internal(format!("Ошибка парсинга курса TON: {}", e))
})?;
.map_err(|e| AppError::Internal(format!("Ошибка парсинга курса TON: {}", e)))?;
let rate_f64 = rate_res["rates"]["TON"]["prices"][&currency_upper]
.as_f64()
@@ -256,12 +254,15 @@ impl BillingService {
// 2. Получаем провайдера, которым был выставлен именно этот инвойс —
// раньше здесь был захардкожен "TON", что ломало подтверждение любого
// другого провайдера (см. `invoice.provider`, заполняется в `create_invoice`).
let provider = self.providers.get(invoice.provider.as_str()).ok_or_else(|| {
AppError::Internal(format!(
"Провайдер {} не сконфигурирован на этом инстансе",
invoice.provider
))
})?;
let provider = self
.providers
.get(invoice.provider.as_str())
.ok_or_else(|| {
AppError::Internal(format!(
"Провайдер {} не сконфигурирован на этом инстансе",
invoice.provider
))
})?;
// 3. Провайдер лезет в блокчейн и проверяет транзакцию
let is_valid = provider
@@ -332,8 +333,10 @@ impl BillingService {
let Some(percent) = partner.commission_percent else {
return Ok(()); // партнёр без выставленного процента — молча пропускаем
};
let Some(fiat_amount_minimal) =
self.repo.get_plan_price(&invoice.plan_name, &invoice.currency).await?
let Some(fiat_amount_minimal) = self
.repo
.get_plan_price(&invoice.plan_name, &invoice.currency)
.await?
else {
return Ok(()); // тариф/валюта более не активны — пропускаем
};
@@ -481,3 +484,315 @@ impl BillingService {
Ok(nanotons)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::repository::PgRepository;
use crate::modules::billing::providers::{InvoiceData, PaymentProvider};
use async_trait::async_trait;
use rust_decimal::Decimal;
use sqlx::PgPool;
// --- calculate_ton_invoice: чистая арифметика, без БД ---
#[test]
fn test_calculate_ton_invoice_applies_2_percent_buffer() {
// $10.00 (1000 центов) по курсу 2 USD/TON -> 5 TON чистых -> +2% буфер -> 5.1 TON
let rate = Decimal::new(2, 0);
let nanotons = BillingService::calculate_ton_invoice(1000, rate).unwrap();
assert_eq!(nanotons, 5_100_000_000);
}
#[test]
fn test_calculate_ton_invoice_zero_rate_errors() {
let err = BillingService::calculate_ton_invoice(1000, Decimal::ZERO).unwrap_err();
assert!(err.contains("не может быть нулевым"));
}
// --- confirm_payment: владение, идемпотентность, маршрутизация провайдера ---
struct FakeProvider {
id: &'static str,
verify_result: bool,
}
#[async_trait]
impl PaymentProvider for FakeProvider {
fn provider_id(&self) -> &'static str {
self.id
}
async fn create_checkout_session(
&self,
_invoice: &InvoiceData,
) -> Result<serde_json::Value, AppError> {
Ok(serde_json::json!({}))
}
async fn verify_payment(
&self,
_external_tx_id: &str,
_expected_amount: i64,
_expected_invoice_id: &str,
) -> Result<bool, AppError> {
Ok(self.verify_result)
}
}
fn service_with_fake_provider(
repo: Arc<dyn AppRepository>,
verify_result: bool,
) -> BillingService {
let mut providers: HashMap<&'static str, Box<dyn PaymentProvider>> = HashMap::new();
providers.insert(
"TEST",
Box::new(FakeProvider {
id: "TEST",
verify_result,
}),
);
BillingService {
repo,
providers,
redis: None,
}
}
#[sqlx::test]
async fn test_confirm_payment_rejects_non_owner(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = service_with_fake_provider(repo.clone(), true);
let owner = repo.upsert_user(1, None).await.unwrap();
let stranger = repo.upsert_user(2, None).await.unwrap();
let invoice_id = repo
.create_invoice(owner.id, "GHOST_1M", "TEST", "USD", 500)
.await
.unwrap();
let err = svc
.confirm_payment(stranger.id, invoice_id, "tx1")
.await
.unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
// Инвойс не должен был измениться.
let invoice = repo.get_invoice(invoice_id).await.unwrap().unwrap();
assert_eq!(invoice.status, "pending");
}
#[sqlx::test]
async fn test_confirm_payment_idempotent_when_already_paid(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
// verify_result=false: если бы идемпотентность не сработала, второй вызов
// упал бы на верификации — но код обязан выйти раньше, на шаге "already paid".
let svc = service_with_fake_provider(repo.clone(), false);
let user = repo.upsert_user(1, None).await.unwrap();
let invoice_id = repo
.create_invoice(user.id, "GHOST_1M", "TEST", "USD", 500)
.await
.unwrap();
repo.update_invoice_status(invoice_id, "paid", Some("tx0"))
.await
.unwrap();
svc.confirm_payment(user.id, invoice_id, "tx1")
.await
.expect("повторное подтверждение уже оплаченного инвойса должно быть no-op");
}
#[sqlx::test]
async fn test_confirm_payment_unknown_provider_errors(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = service_with_fake_provider(repo.clone(), true);
let user = repo.upsert_user(1, None).await.unwrap();
// Инвойс выставлен провайдером, которого нет в HashMap этого инстанса —
// регрессионный тест на баг "confirm_payment жёстко ходил в TON независимо
// от invoice.provider".
let invoice_id = repo
.create_invoice(user.id, "GHOST_1M", "NOT_CONFIGURED", "USD", 500)
.await
.unwrap();
let err = svc
.confirm_payment(user.id, invoice_id, "tx1")
.await
.unwrap_err();
assert!(matches!(err, AppError::Internal(_)));
}
#[sqlx::test]
async fn test_confirm_payment_failed_verification_marks_invoice_failed(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = service_with_fake_provider(repo.clone(), false);
let user = repo.upsert_user(1, None).await.unwrap();
let invoice_id = repo
.create_invoice(user.id, "GHOST_1M", "TEST", "USD", 500)
.await
.unwrap();
let err = svc
.confirm_payment(user.id, invoice_id, "tx1")
.await
.unwrap_err();
assert!(matches!(err, AppError::BusinessLogic(_)));
let invoice = repo.get_invoice(invoice_id).await.unwrap().unwrap();
assert_eq!(invoice.status, "failed");
// Подписка не должна была активироваться.
assert!(repo.get_subscription(user.id).await.unwrap().is_none());
}
#[sqlx::test]
async fn test_confirm_payment_success_activates_subscription_and_rewards_referrer(
pool: PgPool,
) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = service_with_fake_provider(repo.clone(), true);
let referrer = repo.upsert_user(1, None).await.unwrap();
let buyer = repo.upsert_user(2, None).await.unwrap();
repo.create_referral(referrer.id, buyer.id).await.unwrap();
let invoice_id = repo
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 500) // $5.00
.await
.unwrap();
svc.confirm_payment(buyer.id, invoice_id, "tx1")
.await
.expect("успешная верификация должна пройти");
let invoice = repo.get_invoice(invoice_id).await.unwrap().unwrap();
assert_eq!(invoice.status, "paid");
let sub = repo
.get_subscription(buyer.id)
.await
.unwrap()
.expect("подписка должна активироваться");
assert_eq!(sub.plan_name, "GHOST_1M");
assert_eq!(sub.status, "active");
// 10% от суммы инвойса (500) = 50, зачисляется рефереру на баланс.
let referrer_updated = repo.get_user_by_id(referrer.id).await.unwrap().unwrap();
assert_eq!(referrer_updated.balance, 50);
}
// --- Партнёрская комиссия: считается от get_plan_price, не от invoice.amount ---
#[sqlx::test]
async fn test_partner_commission_uses_plan_price_not_invoice_amount(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = service_with_fake_provider(repo.clone(), true);
let partner = repo
.create_staff_user(
"partner1",
"hash",
"partner",
false,
Some(Decimal::new(10, 0)), // 10%
Some("p_abc123"),
)
.await
.unwrap();
let buyer = repo.upsert_user(2, None).await.unwrap();
repo.set_referred_by_partner(buyer.id, partner.id)
.await
.unwrap();
// invoice.amount = 999999 — намеренно НЕ совпадает с реальной ценой тарифа
// (симулирует TON-инвойс, где amount — nanoTON, а не фиат), чтобы доказать,
// что комиссия считается через get_plan_price("GHOST_1M", "USD") = 500,
// а не от этого числа.
let invoice_id = repo
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 999_999)
.await
.unwrap();
svc.confirm_payment(buyer.id, invoice_id, "tx1")
.await
.unwrap();
let earnings = repo.get_partner_earnings(partner.id).await.unwrap();
let usd_earnings = earnings.iter().find(|e| e.currency == "USD").unwrap();
// 10% от 500 (реальная цена GHOST_1M/USD) = 50, а НЕ 10% от 999999.
assert_eq!(usd_earnings.total_earned, 50);
}
#[sqlx::test]
async fn test_partner_commission_idempotent_on_duplicate_confirm(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = service_with_fake_provider(repo.clone(), true);
let partner = repo
.create_staff_user(
"partner1",
"hash",
"partner",
false,
Some(Decimal::new(10, 0)),
Some("p_abc123"),
)
.await
.unwrap();
let buyer = repo.upsert_user(2, None).await.unwrap();
repo.set_referred_by_partner(buyer.id, partner.id)
.await
.unwrap();
let invoice_id = repo
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 500)
.await
.unwrap();
svc.confirm_payment(buyer.id, invoice_id, "tx1")
.await
.unwrap();
// Второй вызов — инвойс уже "paid", подтверждение — no-op (см. тест выше),
// поэтому record_partner_commission_if_referred не должен вызваться снова.
svc.confirm_payment(buyer.id, invoice_id, "tx1")
.await
.unwrap();
let earnings = repo.get_partner_earnings(partner.id).await.unwrap();
let usd_earnings = earnings.iter().find(|e| e.currency == "USD").unwrap();
assert_eq!(
usd_earnings.total_earned, 50,
"комиссия не должна начислиться дважды"
);
}
#[sqlx::test]
async fn test_partner_commission_skipped_without_percent(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = service_with_fake_provider(repo.clone(), true);
// Партнёр без выставленного процента — комиссия не должна начисляться,
// и confirm_payment не должен упасть.
let partner = repo
.create_staff_user("partner1", "hash", "partner", false, None, Some("p_abc123"))
.await
.unwrap();
let buyer = repo.upsert_user(2, None).await.unwrap();
repo.set_referred_by_partner(buyer.id, partner.id)
.await
.unwrap();
let invoice_id = repo
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 500)
.await
.unwrap();
svc.confirm_payment(buyer.id, invoice_id, "tx1")
.await
.unwrap();
let earnings = repo.get_partner_earnings(partner.id).await.unwrap();
assert!(earnings.is_empty());
}
}