Тесты для ключевой бизнес-логики + 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:
+326
-11
@@ -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"][¤cy_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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user