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