Тесты для ключевой бизнес-логики + 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
+191
View File
@@ -170,3 +170,194 @@ pub async fn partner_guard(
Err(deny(user))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::repository::{AppRepository, PgRepository};
use crate::modules::auth::service::AuthService;
use crate::modules::billing::service::BillingService;
use crate::modules::nodes::service::NodeService;
use crate::modules::referrals::service::ReferralService;
use crate::modules::staff::service::StaffService;
use crate::modules::users::service::UserService;
use axum::{
body::Body,
http::{Request as HttpRequest, StatusCode},
middleware,
routing::get,
Extension, Router,
};
use sqlx::PgPool;
use std::sync::Arc;
use tower::ServiceExt;
fn build_state(pool: PgPool) -> ApiState {
// BillingService::new паникует без этой переменной окружения; для
// тестов ролевых гвардов её конкретное значение не имеет значения.
std::env::set_var("TON_MASTER_WALLET", "UQtest_wallet_for_guard_tests");
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
ApiState {
repo: repo.clone(),
auth_service: AuthService::new(repo.clone()),
billing_service: BillingService::new(repo.clone(), None),
node_service: NodeService::new(repo.clone()),
referral_service: ReferralService::new(repo.clone()),
user_service: UserService::new(repo.clone()),
staff_service: StaffService::new(repo.clone()),
jwt_secret: "test-secret".into(),
bot_token: "test-bot-token".into(),
bot_username: "test_bot".into(),
cookie_domain: "".into(),
cookie_secure: false,
csrf_allowed_origins: vec![],
proxy_internal_secret: "test-internal-secret".into(),
}
}
fn make_user(role: &str, can_manage_nodes: bool) -> User {
User {
id: uuid::Uuid::new_v4(),
tg_id: None,
tg_username: None,
role: role.into(),
balance: 0,
game_tickets: 0,
has_used_trial: false,
seed_hash: None,
last_hack_at: None,
data_limit_bytes: None,
data_used_bytes: 0,
data_period_reset_at: None,
username: None,
password_hash: None,
can_manage_nodes,
commission_percent: None,
partner_code: None,
referred_by_partner_id: None,
}
}
/// Строит одноразовый роутер с единственным guard'ом под тестом, инжектит
/// заданного пользователя напрямую в extensions (минуя настоящий auth_guard/JWT
/// — гварды читают только Extension<User>, см. `current_user`) и возвращает
/// итоговый HTTP-статус.
async fn status_for<G, Fut>(state: ApiState, user: User, guard: G) -> StatusCode
where
G: Fn(State<ApiState>, Request, Next) -> Fut + Clone + Send + Sync + 'static,
Fut: std::future::Future<Output = Result<Response, AppError>> + Send + 'static,
{
let app = Router::new()
.route("/", get(|| async { StatusCode::OK }))
.route_layer(middleware::from_fn_with_state(state.clone(), guard))
.layer(Extension(user))
.with_state(state);
let resp = app
.oneshot(HttpRequest::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
resp.status()
}
#[sqlx::test]
async fn test_owner_guard_matrix(pool: PgPool) {
let state = build_state(pool);
assert_eq!(
status_for(state.clone(), make_user("owner", false), owner_guard).await,
StatusCode::OK
);
assert_eq!(
status_for(state.clone(), make_user("moderator", true), owner_guard).await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
status_for(state.clone(), make_user("partner", false), owner_guard).await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
status_for(state.clone(), make_user("user", false), owner_guard).await,
StatusCode::UNAUTHORIZED
);
}
#[sqlx::test]
async fn test_staff_guard_matrix(pool: PgPool) {
let state = build_state(pool);
assert_eq!(
status_for(state.clone(), make_user("owner", false), staff_guard).await,
StatusCode::OK
);
assert_eq!(
status_for(state.clone(), make_user("moderator", false), staff_guard).await,
StatusCode::OK
);
assert_eq!(
status_for(state.clone(), make_user("partner", false), staff_guard).await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
status_for(state.clone(), make_user("user", false), staff_guard).await,
StatusCode::UNAUTHORIZED
);
}
#[sqlx::test]
async fn test_node_manage_guard_matrix(pool: PgPool) {
let state = build_state(pool);
assert_eq!(
status_for(state.clone(), make_user("owner", false), node_manage_guard).await,
StatusCode::OK,
"owner управляет нодами всегда, независимо от can_manage_nodes"
);
assert_eq!(
status_for(
state.clone(),
make_user("moderator", true),
node_manage_guard
)
.await,
StatusCode::OK
);
assert_eq!(
status_for(
state.clone(),
make_user("moderator", false),
node_manage_guard
)
.await,
StatusCode::UNAUTHORIZED,
"модератор без выданного права can_manage_nodes не должен проходить"
);
assert_eq!(
status_for(
state.clone(),
make_user("partner", false),
node_manage_guard
)
.await,
StatusCode::UNAUTHORIZED
);
}
#[sqlx::test]
async fn test_partner_guard_matrix(pool: PgPool) {
let state = build_state(pool);
assert_eq!(
status_for(state.clone(), make_user("partner", false), partner_guard).await,
StatusCode::OK
);
assert_eq!(
status_for(state.clone(), make_user("owner", false), partner_guard).await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
status_for(state.clone(), make_user("moderator", true), partner_guard).await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
status_for(state.clone(), make_user("user", false), partner_guard).await,
StatusCode::UNAUTHORIZED
);
}
}