From 7901f60de6d43136f3861b17c3cbb3b8f28b6526 Mon Sep 17 00:00:00 2001 From: Beast Date: Fri, 12 Jun 2026 17:32:29 +0800 Subject: [PATCH 01/11] feat: clean unused endpoints --- src/routes/address.rs | 67 ++++------------------------------------ src/routes/auth.rs | 11 +------ src/routes/raid_quest.rs | 22 ------------- 3 files changed, 7 insertions(+), 93 deletions(-) diff --git a/src/routes/address.rs b/src/routes/address.rs index 41f45f1..98a4c35 100644 --- a/src/routes/address.rs +++ b/src/routes/address.rs @@ -1,65 +1,10 @@ -use axum::{ - handler::Handler, - middleware, - routing::{get, post}, - Router, -}; +use axum::{handler::Handler, middleware, routing::get, Router}; -use crate::{ - handlers::address::{ - associate_eth_address, associate_x_handle, dissociate_eth_address, dissociate_x_account, - handle_aggregate_address_stats, handle_get_address_reward_status_by_id, handle_get_address_stats, - handle_get_addresses, handle_get_leaderboard, handle_get_opted_in_position, handle_get_opted_in_users, - handle_update_reward_program_status, retrieve_associated_accounts, update_eth_address, - }, - http_server::AppState, - middlewares::jwt_auth, -}; +use crate::{handlers::address::handle_get_addresses, http_server::AppState, middlewares::jwt_auth}; pub fn address_routes(state: AppState) -> Router { - Router::new() - .route("/addresses", get(handle_get_addresses.layer(middleware::from_fn_with_state(state.clone(), jwt_auth::jwt_admin_auth)))) - .route("/addresses/leaderboard", get(handle_get_leaderboard)) - .route("/addresses/opted-in", get(handle_get_opted_in_users)) - .route( - "/addresses/my-position", - get( - handle_get_opted_in_position.layer(middleware::from_fn_with_state( - state.clone(), - jwt_auth::jwt_auth, - )), - ), - ) - .route( - "/addresses/stats", - get( - handle_aggregate_address_stats.layer(middleware::from_fn_with_state( - state.clone(), - jwt_auth::jwt_auth, - )), - ), - ) - .route("/addresses/:id/stats", get(handle_get_address_stats)) - .route( - "/addresses/:id/reward-program", - get(handle_get_address_reward_status_by_id).put( - handle_update_reward_program_status.layer(middleware::from_fn_with_state( - state.clone(), - jwt_auth::jwt_auth, - )), - ), - ) - .route("/addresses/associations", get(retrieve_associated_accounts).layer(middleware::from_fn_with_state(state.clone(), jwt_auth::jwt_auth))) - .route( - "/addresses/associations/eth", - post(associate_eth_address - .layer(middleware::from_fn_with_state(state.clone(), jwt_auth::jwt_auth))) - .put(update_eth_address.layer(middleware::from_fn_with_state(state.clone(), jwt_auth::jwt_auth))) - .delete(dissociate_eth_address.layer(middleware::from_fn_with_state(state.clone(), jwt_auth::jwt_auth))), - ) - .route( - "/addresses/associations/x", - post(associate_x_handle.layer(middleware::from_fn_with_state(state.clone(), jwt_auth::jwt_auth))) - .delete(dissociate_x_account.layer(middleware::from_fn_with_state(state, jwt_auth::jwt_auth))), - ) + Router::new().route( + "/addresses", + get(handle_get_addresses.layer(middleware::from_fn_with_state(state.clone(), jwt_auth::jwt_admin_auth))), + ) } diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 782b049..d232e16 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -1,8 +1,5 @@ use crate::{ - handlers::auth::{ - auth_admin, auth_me, generate_x_oauth_link, handle_admin_login, handle_x_oauth, handle_x_oauth_callback, - request_challenge, verify_login, - }, + handlers::auth::{auth_admin, auth_me, handle_admin_login, request_challenge, verify_login}, http_server::AppState, middlewares::jwt_auth, }; @@ -21,12 +18,6 @@ pub fn auth_routes(state: AppState) -> Router { "/auth/me", get(auth_me.layer(middleware::from_fn_with_state(state.clone(), jwt_auth::jwt_auth))), ) - .route("/auth/x", get(handle_x_oauth)) - .route( - "/auth/x/link", - get(generate_x_oauth_link.layer(middleware::from_fn_with_state(state.clone(), jwt_auth::jwt_auth))), - ) - .route("/auth/x/callback", get(handle_x_oauth_callback)) .route("/auth/admin/login", post(handle_admin_login)) .route( "/auth/admin/me", diff --git a/src/routes/raid_quest.rs b/src/routes/raid_quest.rs index df9c8e8..3048079 100644 --- a/src/routes/raid_quest.rs +++ b/src/routes/raid_quest.rs @@ -23,28 +23,6 @@ pub fn raid_quest_routes(state: AppState) -> Router { handle_create_raid.layer(middleware::from_fn_with_state(state.clone(), jwt_auth::jwt_admin_auth)), ), ) - .route( - "/raid-quests/submissions", - post( - handle_create_raid_submission.layer(middleware::from_fn_with_state(state.clone(), jwt_auth::jwt_auth)), - ), - ) - .route( - "/raid-quests/submissions/me", - get(handle_get_active_raid_raider_submissions - .layer(middleware::from_fn_with_state(state.clone(), jwt_auth::jwt_auth))), - ) - .route( - "/raid-quests/submissions/:id", - delete( - handle_delete_raid_submission.layer(middleware::from_fn_with_state(state.clone(), jwt_auth::jwt_auth)), - ), - ) - .route( - "/raid-quests/raiders/:raider_id/leaderboards/:raid_id", - get(handle_get_specific_raider_raid_leaderboard), - ) - .route("/raid-quests/leaderboards/:raid_id", get(handle_get_raid_leaderboard)) .route( "/raid-quests/:raid_id", delete(handle_delete_raid.layer(middleware::from_fn_with_state(state.clone(), jwt_auth::jwt_admin_auth))), From f48d7797a1bfde4e10f72e40e039adea6b0d8561 Mon Sep 17 00:00:00 2001 From: Beast Date: Fri, 12 Jun 2026 21:42:06 +0800 Subject: [PATCH 02/11] feat: cleanup dead codes 2nd pass --- config/default.toml | 3 - config/example.toml | 3 - config/test.toml | 3 - src/config.rs | 17 - src/db_persistence.rs | 13 +- src/errors.rs | 8 +- src/handlers/address.rs | 976 +-------------------------- src/handlers/auth.rs | 337 +-------- src/handlers/mod.rs | 15 +- src/handlers/raid_quest.rs | 575 +--------------- src/http_server.rs | 16 +- src/main.rs | 9 +- src/models/address.rs | 41 -- src/models/auth.rs | 10 - src/models/eth_association.rs | 86 --- src/models/mod.rs | 4 - src/models/opt_in.rs | 26 - src/models/raid_leaderboard.rs | 44 -- src/models/raid_submission.rs | 21 +- src/models/x_association.rs | 56 -- src/repositories/address.rs | 193 +----- src/repositories/eth_association.rs | 184 ----- src/repositories/mod.rs | 3 - src/repositories/opt_in.rs | 307 --------- src/repositories/raid_leaderboard.rs | 324 +-------- src/repositories/raid_submission.rs | 40 +- src/repositories/referral.rs | 58 +- src/repositories/x_association.rs | 128 ---- src/routes/raid_quest.rs | 7 +- src/services/graphql_client.rs | 401 ----------- src/utils/eth_address_validator.rs | 132 ---- src/utils/mod.rs | 1 - src/utils/test_app_state.rs | 12 +- src/utils/test_db.rs | 54 +- src/utils/x_url.rs | 35 - 35 files changed, 101 insertions(+), 4041 deletions(-) delete mode 100644 src/models/eth_association.rs delete mode 100644 src/models/opt_in.rs delete mode 100644 src/models/raid_leaderboard.rs delete mode 100644 src/models/x_association.rs delete mode 100644 src/repositories/eth_association.rs delete mode 100644 src/repositories/opt_in.rs delete mode 100644 src/repositories/x_association.rs delete mode 100644 src/utils/eth_address_validator.rs diff --git a/config/default.toml b/config/default.toml index 2021a34..e4b6920 100644 --- a/config/default.toml +++ b/config/default.toml @@ -55,9 +55,6 @@ callback_url = "http://localhost:3000/api/auth/x/callback" client_id = "WlVrcm4xSEpXQ2l3TURFM3lLZnE6MTpjaQ" client_secret = "lfXc45dZLqYTzP62Ms32EhXinGQzxcIP9TvjJml2B-h0T1nIJK" -[x_association] -keywords = "@quantusnetwork" - [tweet_sync] api_key = "some-key" interval_in_hours = 24 diff --git a/config/example.toml b/config/example.toml index e9b9ed1..e154504 100644 --- a/config/example.toml +++ b/config/example.toml @@ -65,9 +65,6 @@ callback_url = "http://localhost:12345/example/callback" client_id = "example-id" client_secret = "example-secret" -[x_association] -keywords = "@quantusnetwork" - [tweet_sync] api_key = "some-key" interval_in_hours = 24 diff --git a/config/test.toml b/config/test.toml index a201afa..4bcdb7c 100644 --- a/config/test.toml +++ b/config/test.toml @@ -55,9 +55,6 @@ callback_url = "http://localhost:12345/api/auth/x/callback" client_id = "test-id" client_secret = "test-secret" -[x_association] -keywords = "@quantusnetwork" - [tweet_sync] api_key = "some-key" interval_in_hours = 24 diff --git a/src/config.rs b/src/config.rs index ef455ba..dfdbc66 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,7 +18,6 @@ pub struct Config { pub tg_bot: TelegramBotConfig, pub raid_leaderboard: RaidLeaderboardConfig, pub alert: AlertConfig, - pub x_association: XAssociationConfig, pub remote_configs: RemoteConfigsConfig, pub risk_checker: RiskCheckerConfig, pub exchange_rate: ExchangeRateConfig, @@ -98,11 +97,6 @@ pub struct AlertConfig { pub webhook_url: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct XAssociationConfig { - pub keywords: String, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RiskCheckerConfig { pub etherscan_api_key: String, @@ -155,10 +149,6 @@ impl Config { format!("{}:{}", self.server.host, self.server.port) } - pub fn get_base_api_url(&self) -> &str { - &self.server.base_api_url - } - pub fn get_jwt_expiration(&self) -> chrono::Duration { chrono::Duration::hours(self.jwt.exp_in_hours) } @@ -175,10 +165,6 @@ impl Config { time::Duration::from_secs(self.raid_leaderboard.tweets_req_interval_in_secs) } - pub fn get_x_association_keywords(&self) -> &str { - &self.x_association.keywords - } - pub fn get_cors_allowed_origins(&self) -> Vec { self.server .cors_allowed_origins @@ -260,9 +246,6 @@ impl Default for Config { alert: AlertConfig { webhook_url: "https://your-webhook-url.com".to_string(), }, - x_association: XAssociationConfig { - keywords: "quantus".to_string(), - }, remote_configs: RemoteConfigsConfig { wallet_configs_file: "wallet_configs/default_configs.json".to_string(), }, diff --git a/src/db_persistence.rs b/src/db_persistence.rs index cf822bb..08757ef 100644 --- a/src/db_persistence.rs +++ b/src/db_persistence.rs @@ -1,16 +1,14 @@ use sqlx::{postgres::PgPoolOptions, PgPool}; use crate::repositories::admin::AdminRepository; -use crate::repositories::eth_association::EthAssociationRepository; use crate::repositories::raid_leaderboard::RaidLeaderboardRepository; use crate::repositories::raid_quest::RaidQuestRepository; use crate::repositories::raid_submission::RaidSubmissionRepository; use crate::repositories::relevant_tweet::RelevantTweetRepository; use crate::repositories::tweet_author::TweetAuthorRepository; use crate::repositories::tweet_pull_usage::TweetPullUsageRepository; -use crate::repositories::x_association::XAssociationRepository; use crate::repositories::DbResult; -use crate::repositories::{address::AddressRepository, opt_in::OptInRepository, referral::ReferralRepository}; +use crate::repositories::{address::AddressRepository, referral::ReferralRepository}; #[derive(Debug, thiserror::Error)] pub enum DbError { @@ -30,9 +28,6 @@ pub enum DbError { pub struct DbPersistence { pub addresses: AddressRepository, pub referrals: ReferralRepository, - pub opt_ins: OptInRepository, - pub x_associations: XAssociationRepository, - pub eth_associations: EthAssociationRepository, pub admin: AdminRepository, pub relevant_tweets: RelevantTweetRepository, pub tweet_authors: TweetAuthorRepository, @@ -53,9 +48,6 @@ impl DbPersistence { let addresses = AddressRepository::new(&pool); let referrals = ReferralRepository::new(&pool); - let opt_ins = OptInRepository::new(&pool); - let x_associations = XAssociationRepository::new(&pool); - let eth_associations = EthAssociationRepository::new(&pool); let admin = AdminRepository::new(&pool); let relevant_tweets = RelevantTweetRepository::new(&pool); let tweet_authors = TweetAuthorRepository::new(&pool); @@ -68,9 +60,6 @@ impl DbPersistence { pool, addresses, referrals, - opt_ins, - x_associations, - eth_associations, admin, relevant_tweets, tweet_authors, diff --git a/src/errors.rs b/src/errors.rs index e69321b..caeac42 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -9,7 +9,7 @@ use tracing::error; use crate::{ db_persistence::DbError, - handlers::{address::AddressHandlerError, auth::AuthHandlerError, referral::ReferralHandlerError, HandlerError}, + handlers::{auth::AuthHandlerError, referral::ReferralHandlerError, HandlerError}, models::ModelError, services::{ exchange_rate_service::ExchangeRateError, graphql_client::GraphqlError, risk_checker_service::RiskCheckerError, @@ -140,12 +140,6 @@ fn map_handler_error(err: HandlerError) -> (StatusCode, String) { HandlerError::Auth(err) => match err { AuthHandlerError::Unauthorized(err) => (StatusCode::UNAUTHORIZED, err), - AuthHandlerError::OAuth(err) => (StatusCode::BAD_REQUEST, err), - }, - - HandlerError::Address(err) => match err { - AddressHandlerError::InvalidQueryParams(err) => (StatusCode::BAD_REQUEST, err), - AddressHandlerError::Unauthorized(err) => (StatusCode::UNAUTHORIZED, err), }, HandlerError::Referral(err) => match err { diff --git a/src/handlers/address.rs b/src/handlers/address.rs index 52c7c06..cbc8da8 100644 --- a/src/handlers/address.rs +++ b/src/handlers/address.rs @@ -1,153 +1,20 @@ use axum::{ - extract::{self, Query, State}, - response::NoContent, + extract::{Query, State}, Extension, Json, }; use crate::{ - db_persistence::DbError, handlers::{ - calculate_total_pages, validate_pagination_query, HandlerError, LeaderboardQueryParams, ListQueryParams, - PaginatedResponse, PaginationMetadata, + calculate_total_pages, validate_pagination_query, ListQueryParams, PaginatedResponse, PaginationMetadata, }, http_server::AppState, models::{ - address::{ - Address, AddressFilter, AddressSortColumn, AddressStatsResponse, AddressWithOptInAndAssociations, - AddressWithRank, AggregateStatsQueryParams, AssociatedAccountsResponse, OptedInPositionResponse, - RewardProgramStatusPayload, - }, + address::{AddressFilter, AddressSortColumn, AddressWithOptInAndAssociations}, admin::Admin, - eth_association::{ - AssociateEthAddressRequest, AssociateEthAddressResponse, EthAssociation, EthAssociationInput, - }, - x_association::{AssociateXHandleRequest, XAssociation, XAssociationInput}, }, AppError, }; -use rusx::resources::{user::UserParams, UserField}; - -use super::SuccessResponse; - -#[derive(Debug, thiserror::Error)] -pub enum AddressHandlerError { - #[error("{0}")] - Unauthorized(String), - #[error("{0}")] - InvalidQueryParams(String), -} - -pub async fn handle_update_reward_program_status( - State(state): State, - Extension(user): Extension
, - extract::Path(id): extract::Path, - extract::Json(payload): Json, -) -> Result { - if user.quan_address.0 != id { - return Err(AppError::Handler(HandlerError::Address( - AddressHandlerError::Unauthorized("You can only update your own reward program status".to_string()), - ))); - } - tracing::debug!("Updating address reward status to {}", payload.new_status); - - if payload.new_status { - state.db.opt_ins.create(&id).await?; - } else { - state.db.opt_ins.delete(&id).await?; - } - - Ok(NoContent) -} - -pub async fn handle_get_address_stats( - State(state): State, - extract::Path(id): extract::Path, -) -> Result>, AppError> { - tracing::info!("Getting address stats..."); - - let referrals = state.db.referrals.count_by_referrer(id.clone()).await? as u64; - let stats = state.graphql_client.get_address_stats(id.clone()).await?; - - let data = AddressStatsResponse { - referrals, - referral_events: 0, - immediate_txs: stats.total_transactions, - reversible_txs: stats.total_reversible_transactions, - mining_events: stats.total_mined_blocks, - mining_rewards: stats.total_mining_rewards, - }; - - Ok(SuccessResponse::new(data)) -} - -pub async fn handle_aggregate_address_stats( - State(state): State, - Extension(user): Extension
, - Query(params): Query, -) -> Result>, AppError> { - tracing::info!("Aggregate addresses stats..."); - - if params.addresses.is_empty() { - return Err(AppError::Handler(HandlerError::Address( - AddressHandlerError::InvalidQueryParams( - "Addresses query parameter should be defined and not empty!".to_string(), - ), - ))); - } - - let referrals = state.db.referrals.find_all_by_referrer(user.quan_address.0).await?; - let referred_addresses: Vec = referrals.iter().map(|acc| acc.referee_address.0.clone()).collect(); - let referral_events = state - .graphql_client - .get_addresses_events_count(referred_addresses) - .await?; - let stats = state - .graphql_client - .get_addresses_stats(params.addresses.clone()) - .await?; - - let data = AddressStatsResponse { - referrals: referrals.len() as u64, - referral_events, - immediate_txs: stats.total_transactions, - reversible_txs: stats.total_reversible_transactions, - mining_events: stats.total_mined_blocks, - mining_rewards: stats.total_mining_rewards, - }; - - Ok(SuccessResponse::new(data)) -} - -pub async fn handle_get_leaderboard( - State(state): State, - Query(params): Query, -) -> Result>, AppError> { - tracing::info!("Getting leadeboard data..."); - - validate_pagination_query(params.page, params.page_size)?; - let total_items = state - .db - .addresses - .get_leaderboard_total_items(params.referral_code.clone()) - .await? as u32; - let total_pages = calculate_total_pages(params.page_size, total_items); - - let addresses = state.db.addresses.get_leaderboard_entries(¶ms).await?; - - let response = PaginatedResponse:: { - data: addresses, - meta: PaginationMetadata { - page: params.page, - page_size: params.page_size, - total_items, - total_pages, - }, - }; - - Ok(Json(response)) -} - pub async fn handle_get_addresses( State(state): State, Extension(_): Extension, @@ -178,851 +45,43 @@ pub async fn handle_get_addresses( Ok(Json(response)) } -pub async fn handle_get_address_reward_status_by_id( - State(state): State, - extract::Path(id): extract::Path, -) -> Result>, AppError> { - tracing::info!("Getting address by id {}", id); - - if state.db.addresses.find_by_id(&id).await?.is_none() { - return Err(AppError::Database(DbError::AddressNotFound( - "Address not found".to_string(), - ))); - } - - let is_opted_in = state.db.opt_ins.find_by_address(&id).await?.is_some(); - Ok(SuccessResponse::new(is_opted_in)) -} - -pub async fn associate_eth_address( - State(state): State, - Extension(user): Extension
, - Json(payload): Json, -) -> Result, AppError> { - tracing::info!( - "Received ETH association request for quan_address: {} -> eth_addres: {}", - user.quan_address.0, - payload.eth_address, - ); - - let new_association = EthAssociation::new(EthAssociationInput { - quan_address: user.quan_address.0, - eth_address: payload.eth_address, - })?; - - state.db.eth_associations.create(&new_association).await?; - tracing::info!( - "Created association for quan_address {} with eth_address {}", - new_association.quan_address.0, - new_association.eth_address.0 - ); - - let response = AssociateEthAddressResponse { - success: true, - message: "ETH account associated successfully".to_string(), - }; - - Ok(Json(response)) -} - -pub async fn associate_x_handle( - State(state): State, - Extension(user): Extension
, - Json(payload): Json, -) -> Result { - tracing::info!( - "Received X handle association request for quan_address: {} -> username: {}", - user.quan_address.0, - payload.username, - ); - - let mut params = UserParams::new(); - params.user_fields = Some(vec![UserField::Description, UserField::Username]); - - let user_resp = state - .twitter_gateway - .users() - .get_by_username(&payload.username, Some(params)) - .await - .map_err(|e| { - tracing::error!("Failed to fetch user by username {}: {:?}", payload.username, e); - AppError::Handler(HandlerError::Address(AddressHandlerError::InvalidQueryParams(format!( - "Failed to verify Twitter username: {}", - e - )))) - })?; - - let twitter_user = user_resp.data.ok_or_else(|| { - AppError::Handler(HandlerError::Address(AddressHandlerError::InvalidQueryParams( - "Twitter user not found".to_string(), - ))) - })?; - - let bio = twitter_user.description.unwrap_or_default(); - let x_association_keywords = state.config.get_x_association_keywords(); - if !bio.to_lowercase().contains(&x_association_keywords.to_lowercase()) { - return Err(AppError::Handler(HandlerError::Address( - AddressHandlerError::Unauthorized(format!( - "Twitter bio must mention '{}' to verify ownership", - x_association_keywords - )), - ))); - } - - let new_association = XAssociation::new(XAssociationInput { - quan_address: user.quan_address.0, - username: twitter_user.username, - })?; - - state.db.x_associations.create(&new_association).await?; - tracing::info!( - "Created association for quan_address {} with X username {}", - new_association.quan_address.0, - new_association.username - ); - - Ok(NoContent) -} - -pub async fn update_eth_address( - State(state): State, - Extension(user): Extension
, - Json(payload): Json, -) -> Result, AppError> { - tracing::info!( - "Received update request for quan_address: {} -> eth_addres: {}", - user.quan_address.0, - payload.eth_address, - ); - - let new_association = EthAssociation::new(EthAssociationInput { - quan_address: user.quan_address.0, - eth_address: payload.eth_address, - })?; - - state.db.eth_associations.update_eth_address(&new_association).await?; - - tracing::info!( - "Association updated for quan_address {} with eth_address {}", - new_association.quan_address.0, - new_association.eth_address.0 - ); - - let response = AssociateEthAddressResponse { - success: true, - message: "ETH account association updated successfully".to_string(), - }; - - Ok(Json(response)) -} - -pub async fn dissociate_eth_address( - State(state): State, - Extension(user): Extension
, -) -> Result { - tracing::info!("Dissociating ETH address from quan address {}", user.quan_address.0,); - - state.db.eth_associations.delete(&user.quan_address).await?; - - Ok(NoContent) -} - -pub async fn dissociate_x_account( - State(state): State, - Extension(user): Extension
, -) -> Result { - tracing::info!("Dissociating X account from quan address {}", user.quan_address.0,); - - state.db.x_associations.delete(&user.quan_address).await?; - - Ok(NoContent) -} - -pub async fn retrieve_associated_accounts( - State(state): State, - Extension(user): Extension
, -) -> Result>, AppError> { - tracing::info!("Retrieving associated accounts..."); - - let (x_username, eth_address) = tokio::try_join!( - state.db.x_associations.find_by_address(&user.quan_address), - state.db.eth_associations.find_by_quan_address(&user.quan_address) - )?; - - Ok(SuccessResponse::new(AssociatedAccountsResponse { - eth_address: eth_address.map(|v| v.eth_address.0), - x_username: x_username.map(|v| v.username), - })) -} - -pub async fn handle_get_opted_in_users( - State(state): State, -) -> Result>>, AppError> { - tracing::info!("Getting first 100 opted-in users"); - - let opt_ins = state.db.opt_ins.get_all_ordered(100).await?; - - Ok(SuccessResponse::new(opt_ins)) -} - -pub async fn handle_get_opted_in_position( - State(state): State, - Extension(user): Extension
, -) -> Result>, AppError> { - tracing::info!("Getting opted-in position for {}", user.quan_address.0); - - if let Some(opt_in) = state.db.opt_ins.find_by_address(&user.quan_address.0).await? { - Ok(SuccessResponse::new(OptedInPositionResponse { - quan_address: user.quan_address.0, - position: opt_in.opt_in_number as i64, - is_opted_in: true, - })) - } else { - Ok(SuccessResponse::new(OptedInPositionResponse { - quan_address: user.quan_address.0, - position: 0, - is_opted_in: false, - })) - } -} - #[cfg(test)] mod tests { use super::*; use crate::{ - middlewares::jwt_auth::jwt_auth, - models::x_association::{XAssociation, XAssociationInput}, + models::admin::Admin, utils::{ - test_app_state::{create_test_app_state, generate_test_token}, - test_db::{create_persisted_address, create_persisted_eth_association, reset_database}, + test_app_state::create_test_app_state, + test_db::{create_persisted_address, create_persisted_eth_association, create_persisted_opt_in, reset_database}, }, }; use axum::{ body::Body, - http::{self, Request, StatusCode}, - middleware, - routing::{delete, get, post, put}, - Router, + http::{Request, StatusCode}, + routing::get, + Extension, Router, }; - use serde_json::json; use tower::ServiceExt; - use uuid::Uuid; // Required for .oneshot() - - use rusx::{ - resources::{ - user::{User, UserApi}, - TwitterApiResponse, - }, - MockTwitterGateway, MockUserApi, - }; - use std::sync::Arc; - - #[tokio::test] - async fn test_associate_x_handle_success() { - let mut state = create_test_app_state().await; - reset_database(&state.db.pool).await; - - // 1. Setup User & Token - let user = create_persisted_address(&state.db.addresses, "108").await; - let token = generate_test_token(&state.config.jwt.secret, &user.quan_address.0); - - // 2. Mock Twitter Gateway - let mut mock_gateway = MockTwitterGateway::new(); - let mut mock_user_api = MockUserApi::new(); - - // Expect get_by_username - let x_association_keywords = state.config.get_x_association_keywords().to_string(); - mock_user_api.expect_get_by_username().returning(move |_, _| { - Ok(TwitterApiResponse { - data: Some(User { - id: "u1".to_string(), - name: "Test User".to_string(), - username: "test_user".to_string(), - description: Some(format!("I love {}", x_association_keywords)), // Contains keyword from config - public_metrics: None, - }), - includes: None, - meta: None, - }) - }); - - let user_api_arc: Arc = Arc::new(mock_user_api); - mock_gateway.expect_users().return_const(user_api_arc); - - state.twitter_gateway = Arc::new(mock_gateway); - - // 3. Setup Router - let router = Router::new() - .route("/associate-x", post(associate_x_handle)) - .layer(middleware::from_fn_with_state(state.clone(), jwt_auth)) - .with_state(state.clone()); - - // 4. Request - let payload = json!({ "username": "test_user" }); - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/associate-x") - .header(http::header::CONTENT_TYPE, "application/json") - .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) - .body(Body::from(serde_json::to_string(&payload).unwrap())) - .unwrap(), - ) - .await - .unwrap(); - - // 5. Assert - assert_eq!(response.status(), StatusCode::NO_CONTENT); - - // Check DB - let assoc = state - .db - .x_associations - .find_by_address(&user.quan_address) - .await - .unwrap(); - assert!(assoc.is_some()); - assert_eq!(assoc.unwrap().username, "test_user"); - } - - #[tokio::test] - async fn test_associate_x_handle_fails_bad_bio() { - let mut state = create_test_app_state().await; - reset_database(&state.db.pool).await; - - let user = create_persisted_address(&state.db.addresses, "109").await; - let token = generate_test_token(&state.config.jwt.secret, &user.quan_address.0); - - let mut mock_gateway = MockTwitterGateway::new(); - let mut mock_user_api = MockUserApi::new(); - - mock_user_api.expect_get_by_username().returning(|_, _| { - Ok(TwitterApiResponse { - data: Some(User { - id: "u1".to_string(), - name: "Test User".to_string(), - username: "test_user".to_string(), - description: Some("No keyword here".to_string()), // Missing keyword - public_metrics: None, - }), - includes: None, - meta: None, - }) - }); - - let user_api_arc: Arc = Arc::new(mock_user_api); - mock_gateway.expect_users().return_const(user_api_arc); - state.twitter_gateway = Arc::new(mock_gateway); - - let router = Router::new() - .route("/associate-x", post(associate_x_handle)) - .layer(middleware::from_fn_with_state(state.clone(), jwt_auth)) - .with_state(state); - - let payload = json!({ "username": "test_user" }); - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/associate-x") - .header(http::header::CONTENT_TYPE, "application/json") - .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) - .body(Body::from(serde_json::to_string(&payload).unwrap())) - .unwrap(), - ) - .await - .unwrap(); - - // Should return 401 Unauthorized - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - } - - #[tokio::test] - async fn test_associate_x_handle_case_insensitive_success() { - let mut state = create_test_app_state().await; - reset_database(&state.db.pool).await; - - // 1. Setup User & Token - let user = create_persisted_address(&state.db.addresses, "110").await; - let token = generate_test_token(&state.config.jwt.secret, &user.quan_address.0); - - // 2. Mock Twitter Gateway - let mut mock_gateway = MockTwitterGateway::new(); - let mut mock_user_api = MockUserApi::new(); - - // Expect get_by_username - let x_association_keywords = state.config.get_x_association_keywords().to_string(); - // Create a lowercase version of the mention for the bio - let lowercase_x_association_keywords = x_association_keywords.to_lowercase(); - - mock_user_api.expect_get_by_username().returning(move |_, _| { - Ok(TwitterApiResponse { - data: Some(User { - id: "u1".to_string(), - name: format!("Test User {}", lowercase_x_association_keywords), - username: "test_user".to_string(), - description: Some(format!("I love {}", lowercase_x_association_keywords)), // Contains lowercase keyword - public_metrics: None, - }), - includes: None, - meta: None, - }) - }); - - let user_api_arc: Arc = Arc::new(mock_user_api); - mock_gateway.expect_users().return_const(user_api_arc); - - state.twitter_gateway = Arc::new(mock_gateway); - - // 3. Setup Router - let router = Router::new() - .route("/associate-x", post(associate_x_handle)) - .layer(middleware::from_fn_with_state(state.clone(), jwt_auth)) - .with_state(state.clone()); - - // 4. Request - let payload = json!({ "username": "test_user" }); - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/associate-x") - .header(http::header::CONTENT_TYPE, "application/json") - .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) - .body(Body::from(serde_json::to_string(&payload).unwrap())) - .unwrap(), - ) - .await - .unwrap(); - - // 5. Assert - Should be successful even with different case - assert_eq!(response.status(), StatusCode::NO_CONTENT); - - // Check DB - let assoc = state - .db - .x_associations - .find_by_address(&user.quan_address) - .await - .unwrap(); - assert!(assoc.is_some()); - } - - #[tokio::test] - async fn test_update_eth_address_success() { - let state = create_test_app_state().await; - reset_database(&state.db.pool).await; - - // 1. Setup: Create user, token, and INITIAL association - let user = create_persisted_address(&state.db.addresses, "103").await; - let token = generate_test_token(&state.config.jwt.secret, &user.quan_address.0); - - // Create initial ETH link - let initial_eth = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; - let initial_assoc = EthAssociation::new(EthAssociationInput { - quan_address: user.quan_address.0.clone(), - eth_address: initial_eth.to_string(), - }) - .unwrap(); - state.db.eth_associations.create(&initial_assoc).await.unwrap(); - - // 2. Setup Router for UPDATE - let router = Router::new() - .route("/associate-eth", put(update_eth_address)) // Using PUT handler - .layer(middleware::from_fn_with_state(state.clone(), jwt_auth)) - .with_state(state.clone()); - - // 3. Request: Send NEW eth address - let new_eth = "0x00000000219ab540356cBB839Cbe05303d7705Fa"; - let payload = json!({ "eth_address": new_eth }); - - let response = router - .oneshot( - Request::builder() - .method("PUT") - .uri("/associate-eth") - .header(http::header::CONTENT_TYPE, "application/json") - .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) - .body(Body::from(serde_json::to_string(&payload).unwrap())) - .unwrap(), - ) - .await - .unwrap(); - - // 4. Assertions - assert_eq!(response.status(), StatusCode::OK); - - // 5. Verify DB has NEW address, not OLD - let saved_assoc = state - .db - .eth_associations - .find_by_quan_address(&user.quan_address) - .await - .unwrap() - .unwrap(); - - assert_eq!(saved_assoc.eth_address.0, new_eth); - assert_ne!(saved_assoc.eth_address.0, initial_eth); - } - - #[tokio::test] - async fn test_dissociate_eth_address_success() { - let state = create_test_app_state().await; - reset_database(&state.db.pool).await; - - // 1. Setup: User, Token, and Association - let user = create_persisted_address(&state.db.addresses, "104").await; - let token = generate_test_token(&state.config.jwt.secret, &user.quan_address.0); - - let assoc = EthAssociation::new(EthAssociationInput { - quan_address: user.quan_address.0.clone(), - eth_address: "0x00000000219ab540356cBB839Cbe05303d7705Fa".to_string(), - }) - .unwrap(); - state.db.eth_associations.create(&assoc).await.unwrap(); - - // 2. Router - let router = Router::new() - .route("/associate-eth", delete(dissociate_eth_address)) - .layer(middleware::from_fn_with_state(state.clone(), jwt_auth)) - .with_state(state.clone()); - - // 3. Request - let response = router - .oneshot( - Request::builder() - .method("DELETE") - .uri("/associate-eth") - .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - // 4. Assert - assert_eq!(response.status(), StatusCode::NO_CONTENT); - - // 5. Verify DB is empty - let saved_assoc = state - .db - .eth_associations - .find_by_quan_address(&user.quan_address) - .await - .unwrap(); - - assert!(saved_assoc.is_none()); - } - - #[tokio::test] - async fn test_dissociate_eth_address_idempotency() { - // Test that deleting when nothing exists returns 204 (not 404 or 500) - let state = create_test_app_state().await; - reset_database(&state.db.pool).await; - - let user = create_persisted_address(&state.db.addresses, "105").await; - let token = generate_test_token(&state.config.jwt.secret, &user.quan_address.0); - - // NOTE: No association created in DB - - let router = Router::new() - .route("/associate-eth", delete(dissociate_eth_address)) - .layer(middleware::from_fn_with_state(state.clone(), jwt_auth)) - .with_state(state); - - let response = router - .oneshot( - Request::builder() - .method("DELETE") - .uri("/associate-eth") - .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::NO_CONTENT); - } - - #[tokio::test] - async fn test_retrieve_associated_accounts_full() { - let state = create_test_app_state().await; - reset_database(&state.db.pool).await; - - let user = create_persisted_address(&state.db.addresses, "106").await; - let token = generate_test_token(&state.config.jwt.secret, &user.quan_address.0); - - // Setup X Association - let x_assoc = XAssociation::new(XAssociationInput { - quan_address: user.quan_address.0.clone(), - username: "test_twitter_user".to_string(), - }) - .unwrap(); - state.db.x_associations.create(&x_assoc).await.unwrap(); - - // Setup ETH Association - let eth_addr_str = "0x00000000219ab540356cBB839Cbe05303d7705Fa"; - let eth_assoc = EthAssociation::new(EthAssociationInput { - quan_address: user.quan_address.0.clone(), - eth_address: eth_addr_str.to_string(), - }) - .unwrap(); - state.db.eth_associations.create(ð_assoc).await.unwrap(); - - let router = Router::new() - .route("/associated-accounts", get(retrieve_associated_accounts)) - .layer(middleware::from_fn_with_state(state.clone(), jwt_auth)) - .with_state(state); - - let response = router - .oneshot( - Request::builder() - .method("GET") - .uri("/associated-accounts") - .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - - let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); - let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); - - // Verify structure matches AssociatedAccountsResponse - let data = &body_json["data"]; - assert_eq!(data["x_username"], "test_twitter_user"); - assert_eq!(data["eth_address"], eth_addr_str); - } - - #[tokio::test] - async fn test_retrieve_associated_accounts_empty() { - let state = create_test_app_state().await; - reset_database(&state.db.pool).await; - - let user = create_persisted_address(&state.db.addresses, "107").await; - let token = generate_test_token(&state.config.jwt.secret, &user.quan_address.0); - - // NOTE: No associations created - - let router = Router::new() - .route("/associated-accounts", get(retrieve_associated_accounts)) - .layer(middleware::from_fn_with_state(state.clone(), jwt_auth)) - .with_state(state); - - let response = router - .oneshot( - Request::builder() - .method("GET") - .uri("/associated-accounts") - .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - - let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); - let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); - - let data = &body_json["data"]; - assert!(data["x_username"].is_null()); - assert!(data["eth_address"].is_null()); - } - - #[tokio::test] - async fn test_dissociate_x_account_success() { - let state = create_test_app_state().await; - reset_database(&state.db.pool).await; - - // Setup Data: Create user & Token - let user = create_persisted_address(&state.db.addresses, "101").await; - let token = generate_test_token(&state.config.jwt.secret, &user.quan_address.0); - let new_association = XAssociation::new(XAssociationInput { - quan_address: user.quan_address.0, - username: "test_twitter".to_string(), - }) - .unwrap(); - state.db.x_associations.create(&new_association).await.unwrap(); - - // Setup Router: MUST include the jwt_auth middleware - let router = Router::new() - .route("/associate-x", delete(dissociate_x_account)) - .layer(middleware::from_fn_with_state(state.clone(), jwt_auth)) - .with_state(state.clone()); - - let response = router - .oneshot( - Request::builder() - .method("DELETE") - .uri("/associate-x") - .header(http::header::CONTENT_TYPE, "application/json") - .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - // Assertions - assert_eq!(response.status(), StatusCode::NO_CONTENT); - - // Verification in DB - let saved_assoc = sqlx::query_as::<_, XAssociation>("SELECT * FROM x_associations WHERE username = $1") - .bind(new_association.username) - .fetch_optional(&state.db.pool) - .await - .unwrap(); - - assert!(saved_assoc.is_none()); - } - - #[tokio::test] - async fn test_associate_eth_address_success() { - let state = create_test_app_state().await; - reset_database(&state.db.pool).await; - - // A. Setup Data: Create user & Token - let user = create_persisted_address(&state.db.addresses, "101").await; - let token = generate_test_token(&state.config.jwt.secret, &user.quan_address.0); - - // B. Setup Router: MUST include the jwt_auth middleware - let router = Router::new() - .route("/associate-eth", post(associate_eth_address)) - .layer(middleware::from_fn_with_state(state.clone(), jwt_auth)) - .with_state(state.clone()); - - // C. Perform Request - let payload = json!({ "eth_address": "0x00000000219ab540356cBB839Cbe05303d7705Fa" }); - - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/associate-eth") - .header(http::header::CONTENT_TYPE, "application/json") - .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) - .body(Body::from(serde_json::to_string(&payload).unwrap())) - .unwrap(), - ) - .await - .unwrap(); - - // D. Assertions - assert_eq!(response.status(), StatusCode::OK); - - let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); - let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); - - assert_eq!(body_json["success"], true); - assert_eq!(body_json["message"], "ETH account associated successfully"); - - // E. Verification in DB - let saved_assoc = state - .db - .eth_associations - .find_by_quan_address(&user.quan_address) - .await - .unwrap(); - - assert!(saved_assoc.is_some()); - assert_eq!(saved_assoc.unwrap().quan_address.0, user.quan_address.0); - } - - #[tokio::test] - async fn test_associate_eth_address_fails_without_token() { - let state = create_test_app_state().await; - reset_database(&state.db.pool).await; - - let router = Router::new() - .route("/associate-eth", post(associate_eth_address)) - .layer(middleware::from_fn_with_state(state.clone(), jwt_auth)) - .with_state(state); - - let payload = json!({ "eth_address": "0x00000000219ab540356cBB839Cbe05303d7705Fa" }); - - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/associate-eth") - .header(http::header::CONTENT_TYPE, "application/json") - // NO Authorization header - .body(Body::from(serde_json::to_string(&payload).unwrap())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - } - - #[tokio::test] - async fn test_associate_eth_address_fails_invalid_payload() { - let state = create_test_app_state().await; - reset_database(&state.db.pool).await; - - let user = create_persisted_address(&state.db.addresses, "102").await; - let token = generate_test_token(&state.config.jwt.secret, &user.quan_address.0); - - let router = Router::new() - .route("/associate-eth", post(associate_eth_address)) - .layer(middleware::from_fn_with_state(state.clone(), jwt_auth)) - .with_state(state); - - let payload = json!({ "eth_address": "invalid_eth_address" }); - - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/associate-eth") - .header(http::header::CONTENT_TYPE, "application/json") - .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) - .body(Body::from(serde_json::to_string(&payload).unwrap())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - } + use uuid::Uuid; #[tokio::test] async fn test_handle_get_addresses_success() { let state = create_test_app_state().await; reset_database(&state.db.pool).await; - // 1. Setup Data - // Create 3 Addresses let addr1 = create_persisted_address(&state.db.addresses, "A1").await; let addr2 = create_persisted_address(&state.db.addresses, "A2").await; let addr3 = create_persisted_address(&state.db.addresses, "A3").await; - // Setup Opt-In for Addr1 (using the repo method seen in your code) - state - .db - .opt_ins - .create(&addr1.quan_address.0) - .await - .expect("Failed to create opt-in"); + create_persisted_opt_in(&state.db.pool, &addr1.quan_address.0).await; - // Setup Eth Association for Addr2 create_persisted_eth_association( - &state.db.eth_associations, + &state.db.pool, &addr2.quan_address.0, "0x00000000219ab540356cBB839Cbe05303d7705Fa", ) .await; - // 2. Mock Admin - // NOTE: Adjust this instantiation to match your Admin struct definition let admin = Admin { id: Uuid::new_v4(), username: "new-user".to_string(), @@ -1031,41 +90,34 @@ mod tests { created_at: chrono::Utc::now(), }; - // 3. Setup Router - // We inject the Admin extension directly to bypass auth middleware logic for this unit test let router = Router::new() .route("/", get(handle_get_addresses)) .layer(Extension(admin)) .with_state(state); - // 4. Request let response = router .oneshot( Request::builder() .method("GET") - .uri("/?page=1&page_size=10") // Test default pagination + .uri("/?page=1&page_size=10") .body(Body::empty()) .unwrap(), ) .await .unwrap(); - // 5. Assertions assert_eq!(response.status(), StatusCode::OK); let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); - // Check Pagination Meta let meta = &body_json["meta"]; assert_eq!(meta["total_items"], 3); assert_eq!(meta["page"], 1); - // Check Data content let data = body_json["data"].as_array().unwrap(); assert_eq!(data.len(), 3); - // Find Address 1 (Should be opted in) let res_addr1 = data .iter() .find(|x| x["address"]["quan_address"] == addr1.quan_address.0) @@ -1073,14 +125,12 @@ mod tests { assert_eq!(res_addr1["is_opted_in"], true); assert!(!res_addr1["opt_in_number"].is_null()); - // Find Address 2 (Should have Eth Address) let res_addr2 = data .iter() .find(|x| x["address"]["quan_address"] == addr2.quan_address.0) .unwrap(); assert_eq!(res_addr2["eth_address"], "0x00000000219ab540356cBB839Cbe05303d7705Fa"); - // Find Address 3 (Clean) let res_addr3 = data .iter() .find(|x| x["address"]["quan_address"] == addr3.quan_address.0) diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index fdf818a..3b98bc1 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -1,14 +1,12 @@ use argon2::{Argon2, PasswordHash, PasswordVerifier}; use axum::{ - extract::{Query, State}, + extract::State, http::StatusCode, - response::{Json, Redirect}, + response::Json, Extension, }; use chrono::Utc; use jsonwebtoken::{encode, EncodingKey, Header}; -use rusx::auth::TwitterCallbackParams; -use tower_cookies::{Cookie, Cookies}; use uuid::Uuid; use crate::{ @@ -18,11 +16,7 @@ use crate::{ models::{ address::{Address, AddressInput}, admin::{Admin, AdminAuthCheckResponse, AdminClaims, AdminLoginPayload, AdminLoginResponse}, - auth::{ - GenerateOAuthLinkResponse, OauthTokenQuery, RequestChallengeBody, RequestChallengeResponse, TokenClaims, - VerifyLoginBody, VerifyLoginResponse, - }, - x_association::{XAssociation, XAssociationInput}, + auth::{RequestChallengeBody, RequestChallengeResponse, TokenClaims, VerifyLoginBody, VerifyLoginResponse}, }, services::signature_service::SignatureService, utils::{generate_referral_code::generate_referral_code, jwt::get_default_jwt_config}, @@ -34,8 +28,6 @@ use tracing::{debug, warn}; pub enum AuthHandlerError { #[error("Not authorized: {0}")] Unauthorized(String), - #[error("Oauth error: {0}")] - OAuth(String), } pub async fn request_challenge( @@ -148,140 +140,6 @@ pub async fn auth_me(Extension(address): Extension
) -> Result, - cookies: Cookies, - Query(params): Query, -) -> Result { - tracing::info!("Handling x oauth request..."); - - let quan_address = { - let Some(address) = state.twitter_oauth_tokens.write().await.remove(¶ms.token) else { - return Err(AppError::Handler(HandlerError::Auth(AuthHandlerError::OAuth( - "Invalid or expired token".to_string(), - )))); - }; - - address - }; - - tracing::info!("Quan address from token: {}", quan_address); - - let (auth_url, verifier) = state.twitter_gateway.generate_auth_url(); - let session_id = format!("{}|{}", quan_address, uuid::Uuid::new_v4()); - - tracing::info!("Session id in cookies: {}", session_id); - - tracing::info!("Creating oauth session"); - state - .oauth_sessions - .lock() - .unwrap() - .insert(session_id.clone(), verifier); - cookies.add(Cookie::new("oauth_session", session_id)); - - tracing::info!("Returning oauth url..."); - - Ok(Redirect::to(&auth_url)) -} - -pub async fn generate_x_oauth_link( - State(state): State, - Extension(user): Extension
, -) -> Result, AppError> { - tracing::info!("Generating oauth url..."); - - let twitter_oauth_token = Uuid::new_v4().to_string(); - state - .twitter_oauth_tokens - .write() - .await - .insert(twitter_oauth_token.clone(), user.quan_address.0.clone()); - - tracing::info!("Returning oauth request url..."); - let request_link = format!( - "{}/auth/x?token={}", - state.config.get_base_api_url(), - twitter_oauth_token - ); - - Ok(Json(GenerateOAuthLinkResponse { url: request_link })) -} - -pub async fn handle_x_oauth_callback( - State(state): State, - cookies: Cookies, - Query(params): Query, -) -> Result { - tracing::info!("Handling x oauth callback..."); - - let session_id = match cookies.get("oauth_session") { - Some(cookie) => cookie.value().to_string(), - None => { - return Err(AppError::Handler(HandlerError::Auth(AuthHandlerError::OAuth( - "No session cookie found. Please try again.".to_string(), - )))) - } - }; - - tracing::info!("Session id found: {}", session_id); - - let verifier = { - let Some(chal) = state.oauth_sessions.lock().unwrap().remove(&session_id) else { - return Err(AppError::Handler(HandlerError::Auth(AuthHandlerError::OAuth(format!( - "Session {} expired or invalid", - &session_id - ))))); - }; - - chal - }; - - tracing::debug!("Exchanging code {} for access token...", params.code); - let token = state.twitter_gateway.exchange_code(params.code, verifier).await?; - let authenticated_gateway = state.twitter_gateway.with_token(token.access_token)?; - - let user_resp = authenticated_gateway.users().get_me().await?; - let x_handle = user_resp - .data - .ok_or_else(|| { - HandlerError::Auth(AuthHandlerError::OAuth( - "Failed getting username from twitter API".to_string(), - )) - })? - .username; - - tracing::debug!("Do X association..."); - let quan_address = { - let Some(address) = session_id.split_once('|').map(|(left, _)| left) else { - return Err(AppError::Handler(HandlerError::Auth(AuthHandlerError::OAuth( - "Session id malformed".to_string(), - )))); - }; - - address.to_string() - }; - - let new_association = XAssociation::new(XAssociationInput { - quan_address, - username: x_handle, - })?; - - state.db.x_associations.create(&new_association).await?; - tracing::info!( - "Created association for quan_address {} with X username {}", - new_association.quan_address.0, - new_association.username - ); - - let redirect_url = format!( - "{}/oauth?platform=x&payload={}", - state.config.blockchain.website_url, new_association.username - ); - - Ok(Redirect::to(&redirect_url)) -} - pub async fn handle_admin_login( State(state): State, Json(body): Json, @@ -339,206 +197,21 @@ pub async fn auth_admin( #[cfg(test)] mod tests { - use std::sync::Arc; - use crate::{ - handlers::auth::handle_x_oauth_callback, - http_server::AppState, - models::x_association::XAssociation, routes::auth::auth_routes, - utils::{ - test_app_state::create_test_app_state, - test_db::{create_persisted_address, reset_database}, - }, + utils::test_app_state::create_test_app_state, }; - use axum::{body::Body, http, routing::get}; + use axum::{body::Body, http}; use qp_rusty_crystals_dilithium::SensitiveBytes32; - use rusx::{ - auth::TwitterToken, - resources::{ - user::{User, UserApi}, - TwitterApiResponse, - }, - MockTwitterGateway, MockUserApi, PkceCodeVerifier, TwitterGateway, - }; use sp_core::crypto::{self, Ss58AddressFormat, Ss58Codec}; use sp_runtime::traits::IdentifyAccount; use tower::ServiceExt; - use tower_cookies::CookieManagerLayer; async fn test_app() -> axum::Router { let state = create_test_app_state().await; auth_routes(state.clone()).with_state(state) } - fn auth_callback_router(state: AppState) -> axum::Router { - axum::Router::new() - .route("/auth/x/callback", get(handle_x_oauth_callback)) - .layer(CookieManagerLayer::new()) // Crucial for testing Cookies - .with_state(state) - } - - #[tokio::test] - async fn test_x_oauth_callback_invalid_session() { - let state = create_test_app_state().await; - let app = auth_callback_router(state); - - // We send a cookie, but we DO NOT add anything to state.oauth_sessions - let response = app - .oneshot( - http::Request::builder() - .method("GET") - .uri("/auth/x/callback?code=123&state=abc") - .header(http::header::COOKIE, "oauth_session=invalid_session_id") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - // Should fail because session id is not in the HashMap - assert_ne!(response.status(), http::StatusCode::SEE_OTHER); - } - - #[tokio::test] - async fn test_x_oauth_callback_missing_cookie() { - let state = create_test_app_state().await; - let app = auth_callback_router(state); - - let response = app - .oneshot( - http::Request::builder() - .method("GET") - // No Cookie Header - .uri("/auth/x/callback?code=123&state=abc") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - // Should fail because cookie is missing - // Assuming AppError maps to something other than 307 Redirect (likely 400 or 500) - assert_ne!(response.status(), http::StatusCode::SEE_OTHER); - assert_ne!(response.status(), http::StatusCode::OK); - } - - #[tokio::test] - async fn test_x_oauth_callback_success() { - // 1. Setup Data - let mut state = create_test_app_state().await; - reset_database(&state.db.pool).await; - - let test_user = create_persisted_address(&state.db.addresses, "101").await; - let session_uuid = "random-uuid"; - let session_id = format!("{}|{}", test_user.quan_address.0, session_uuid); - let verifier = PkceCodeVerifier::new("random".to_string()); - let expected_username = "quantus"; - - // 2. Prepare Mocks - - // A. Mock the User API - let mut mock_user_api = MockUserApi::new(); - mock_user_api.expect_get_me().times(1).returning(move || { - Ok(TwitterApiResponse:: { - data: Some(User { - id: "101".to_string(), - name: "Quantus Network".to_string(), - username: expected_username.to_string(), - description: Some("Quantus Network".to_string()), - public_metrics: Default::default(), - }), - includes: Default::default(), - meta: Default::default(), - }) - }); - - // B. Mock the Authenticated Gateway - let mut mock_auth_gateway = MockTwitterGateway::new(); - - // Explicit cast to Arc for return_const - let user_api_arc: Arc = Arc::new(mock_user_api); - mock_auth_gateway.expect_users().times(1).return_const(user_api_arc); - - // Prepare the gateway to be returned by with_token - // Note: We also cast this to Arc to ensure the closure return type matches perfectly - let auth_gateway_arc: Arc = Arc::new(mock_auth_gateway); - - // C. Mock the Main Gateway (Entry point) - let mut mock_main_gateway = MockTwitterGateway::new(); - - // Expect code exchange - mock_main_gateway - .expect_exchange_code() - // Use .to_string() inside eq() because the function argument is String - .with( - mockall::predicate::eq("valid_auth_code".to_string()), - mockall::predicate::always(), - ) - .times(1) - .returning(|_, _| { - Ok(TwitterToken { - access_token: "mock_access_token".to_string(), - refresh_token: None, - expires_in: None, - }) - }); - - // Expect transition to authenticated state - let result_gateway = auth_gateway_arc.clone(); - mock_main_gateway - .expect_with_token() - // Use .to_string() inside eq() here as well - .with(mockall::predicate::eq("mock_access_token".to_string())) - .times(1) - // returning expects a closure that returns SdkResult> - // Since we cast auth_gateway_arc above, this now matches perfectly - .returning(move |_| Ok(result_gateway.clone())); - - // 3. Inject the mock! - state.twitter_gateway = Arc::new(mock_main_gateway); - - // Populate the session store - state - .oauth_sessions - .lock() - .unwrap() - .insert(session_id.clone(), verifier); - - // 4. Create Router - let app = auth_callback_router(state.clone()); - - // 5. Execute Request - let response = app - .oneshot( - http::Request::builder() - .method("GET") - .uri("/auth/x/callback?code=valid_auth_code&state=xyz") - .header(http::header::COOKIE, format!("oauth_session={}", session_id)) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - // 6. Assertions - assert_eq!(response.status(), http::StatusCode::SEE_OTHER); - - // Check Redirect Location - let location = response.headers().get("location").unwrap().to_str().unwrap(); - assert!(location.contains(&format!("payload={}", expected_username))); - - // Check DB Side Effects - let saved_assoc = sqlx::query_as::<_, XAssociation>("SELECT * FROM x_associations WHERE username = $1") - .bind(expected_username) - .fetch_optional(&state.db.pool) - .await - .unwrap(); - - assert!(saved_assoc.is_some()); - assert_eq!(saved_assoc.unwrap().quan_address.0, test_user.quan_address.0); - } - #[tokio::test] async fn auth_challenge_and_verify_flow() { crypto::set_default_ss58_version(Ss58AddressFormat::custom(189)); diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 59226da..261a24d 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use std::fmt::Display; use crate::{ - handlers::{address::AddressHandlerError, auth::AuthHandlerError, referral::ReferralHandlerError}, + handlers::{auth::AuthHandlerError, referral::ReferralHandlerError}, AppError, }; @@ -21,8 +21,6 @@ pub mod tweet_author; pub enum HandlerError { #[error("Referral handler error")] Referral(#[from] ReferralHandlerError), - #[error("Address handler error")] - Address(#[from] AddressHandlerError), #[error("Auth handler error")] Auth(#[from] AuthHandlerError), @@ -95,17 +93,6 @@ pub struct ListQueryParams { pub order: SortDirection, } -#[derive(Debug, Deserialize)] -pub struct LeaderboardQueryParams { - #[serde(default = "default_page")] - pub page: u32, - - #[serde(default = "default_page_size")] - pub page_size: u32, - - pub referral_code: Option, -} - fn default_page() -> u32 { 1 } diff --git a/src/handlers/raid_quest.rs b/src/handlers/raid_quest.rs index e6f1b83..8e5ed86 100644 --- a/src/handlers/raid_quest.rs +++ b/src/handlers/raid_quest.rs @@ -1,36 +1,23 @@ use axum::{ - extract::{self, Path, Query, State}, - http::StatusCode, + extract::{Path, Query, State}, response::NoContent, Extension, Json, }; use crate::{ - db_persistence::DbError, handlers::{ - auth::AuthHandlerError, calculate_total_pages, validate_pagination_query, HandlerError, LeaderboardQueryParams, - ListQueryParams, PaginatedResponse, PaginationMetadata, + calculate_total_pages, validate_pagination_query, ListQueryParams, PaginatedResponse, PaginationMetadata, }, http_server::AppState, models::{ - address::Address, admin::Admin, - raid_leaderboard::RaidLeaderboard, raid_quest::{CreateRaidQuest, RaidQuest, RaidQuestFilter, RaidQuestSortColumn}, - raid_submission::{CreateRaidSubmission, RaidSubmissionInput, RaiderSubmissions}, - x_association::XAssociation, }, - utils::x_url::{build_x_status_url, parse_x_status_url}, AppError, }; use super::SuccessResponse; -// ----------------------------------------------------------------------------- -// Admin Handlers -// ----------------------------------------------------------------------------- - -/// Create a new Raid Quest (Admin Only) pub async fn handle_create_raid( State(state): State, Extension(_admin): Extension, @@ -43,7 +30,6 @@ pub async fn handle_create_raid( Ok(SuccessResponse::new(raid_id)) } -/// Finish/End a Raid Quest (Admin Only) pub async fn handle_finish_raid( State(state): State, Extension(_admin): Extension, @@ -80,11 +66,6 @@ pub async fn handle_delete_raid( Ok(NoContent) } -// ----------------------------------------------------------------------------- -// Public/User Handlers -// ----------------------------------------------------------------------------- - -/// Lists raid quests with pagination, and filtering. pub async fn handle_get_raid_quests( State(state): State, Query(params): Query>, @@ -110,205 +91,29 @@ pub async fn handle_get_raid_quests( Ok(Json(response)) } -/// Get Leaderboard for a specific Raid -pub async fn handle_get_raid_leaderboard( - State(state): State, - Path(raid_id): Path, - Query(params): Query, -) -> Result>, AppError> { - validate_pagination_query(params.page, params.page_size)?; - - let total_items = state - .db - .raid_leaderboards - .get_total_items(raid_id, params.referral_code.clone()) - .await? as u32; - let total_pages = calculate_total_pages(params.page_size, total_items); - - let offset = (params.page - 1) * params.page_size; - let entries = state - .db - .raid_leaderboards - .get_entries(raid_id, params.page_size as i64, offset as i64, params.referral_code) - .await?; - - let response = PaginatedResponse { - data: entries, - meta: PaginationMetadata { - page: params.page, - page_size: params.page_size, - total_items, - total_pages, - }, - }; - - Ok(Json(response)) -} - -/// Get Leaderboard for a specific raider in a specific raid -pub async fn handle_get_specific_raider_raid_leaderboard( - State(state): State, - Path((raider_id, raid_id)): Path<(String, i32)>, -) -> Result>, AppError> { - let Some(raider_leaderboard) = state.db.raid_leaderboards.get_raider_entry(raid_id, &raider_id).await? else { - return Err(AppError::Database(DbError::RecordNotFound( - "No raider leaderboard is found".to_string(), - ))); - }; - - Ok(SuccessResponse::new(raider_leaderboard)) -} - -pub async fn handle_get_active_raid_raider_submissions( - State(state): State, - Extension(user): Extension
, -) -> Result>, AppError> { - let (current_active_raid, user_x) = get_active_raid_and_x_association(&state, &user).await?; - - let submissions = state - .db - .raid_submissions - .find_by_raider(current_active_raid.id, &user.quan_address.0) - .await?; - let raider_submissions: Vec = submissions - .iter() - .map(|submission| build_x_status_url(&user_x.username, &submission.id)) - .collect(); - - Ok(SuccessResponse::new(RaiderSubmissions { - current_raid: current_active_raid, - submissions: raider_submissions, - })) -} - -pub async fn handle_create_raid_submission( - State(state): State, - Extension(user): Extension
, - extract::Json(payload): Json, -) -> Result<(StatusCode, Json>), AppError> { - let (current_active_raid, _user_x) = get_active_raid_and_x_association(&state, &user).await?; - - let Some((_reply_username, reply_id)) = parse_x_status_url(&payload.tweet_reply_link) else { - return Err(AppError::Handler(HandlerError::InvalidBody( - "Couldn't parse tweet reply link".to_string(), - ))); - }; - - let new_raid_submission = CreateRaidSubmission { - id: reply_id, - raid_id: current_active_raid.id, - raider_id: user.quan_address.0, - }; - - let created_id = state.db.raid_submissions.create(&new_raid_submission).await?; - - Ok((StatusCode::CREATED, SuccessResponse::new(created_id))) -} - -pub async fn handle_delete_raid_submission( - State(state): State, - Extension(user): Extension
, - Path(submission_id): Path, -) -> Result { - let Some(raid_submission) = state.db.raid_submissions.find_by_id(&submission_id).await? else { - return Err(AppError::Database(DbError::RecordNotFound(format!( - "Couldn't find raid submission with id {}", - submission_id - )))); - }; - - if raid_submission.raider_id != user.quan_address.0 { - return Err(AppError::Handler(HandlerError::Auth(AuthHandlerError::Unauthorized( - "Only raid submission owner can delete".to_string(), - )))); - } - - state.db.raid_submissions.delete(&submission_id).await?; - - Ok(NoContent) -} - -async fn get_active_raid_and_x_association( - state: &AppState, - user: &Address, -) -> Result<(RaidQuest, XAssociation), AppError> { - let Some(current_active_raid) = state.db.raid_quests.find_active().await? else { - return Err(AppError::Database(DbError::RecordNotFound( - "No active raid is found".to_string(), - ))); - }; - let Some(user_x) = state.db.x_associations.find_by_address(&user.quan_address).await? else { - return Err(AppError::Database(DbError::RecordNotFound( - "User doesn't have X association".to_string(), - ))); - }; - Ok((current_active_raid, user_x)) -} - #[cfg(test)] mod tests { use axum::{ body::Body, extract::Request, http::{self, StatusCode}, - routing::{delete, get, post, put}, + routing::{get, post, put}, Extension, Router, }; - use chrono::Utc; use serde_json::Value; use tower::ServiceExt; use crate::{ handlers::raid_quest::{ - handle_create_raid, handle_create_raid_submission, handle_delete_raid_submission, handle_finish_raid, - handle_get_raid_quests, handle_get_specific_raider_raid_leaderboard, handle_revert_to_active_raid, - }, - models::{ - raid_quest::CreateRaidQuest, raid_submission::RaidSubmissionInput, relevant_tweet::NewTweetPayload, - tweet_author::NewAuthorPayload, + handle_create_raid, handle_finish_raid, handle_get_raid_quests, handle_revert_to_active_raid, }, + models::raid_quest::CreateRaidQuest, utils::{ test_app_state::create_test_app_state, - test_db::{create_mock_admin, create_persisted_address, reset_database}, + test_db::{create_mock_admin, reset_database}, }, }; - // Helper to seed the strict Foreign Key chain required for submissions - // Author -> Tweet -> (Submission uses Tweet ID) - async fn seed_target_tweet(state: &crate::http_server::AppState, tweet_id: &str) { - let author_id = "auth_seed_1"; - - // 1. Upsert Author - let author = NewAuthorPayload { - id: author_id.to_string(), - name: "Target Author".to_string(), - username: "target_auth".to_string(), - followers_count: 0, - following_count: 0, - tweet_count: 0, - listed_count: 0, - like_count: 0, - media_count: 0, - is_ignored: Some(true), - }; - state.db.tweet_authors.upsert_many(&vec![author]).await.unwrap(); - - // 2. Upsert Tweet - let tweet = NewTweetPayload { - id: tweet_id.to_string(), - author_id: author_id.to_string(), - text: "Target Tweet Text".to_string(), - impression_count: 0, - reply_count: 0, - retweet_count: 0, - like_count: 0, - created_at: Utc::now(), - }; - state.db.relevant_tweets.upsert_many(&vec![tweet]).await.unwrap(); - } - - // --- Admin Tests --- - #[tokio::test] async fn test_admin_create_raid() { let state = create_test_app_state().await; @@ -348,7 +153,6 @@ mod tests { let state = create_test_app_state().await; reset_database(&state.db.pool).await; - // 1. Create Active Raid let create_payload = CreateRaidQuest { name: "Active Raid".to_string(), }; @@ -359,7 +163,6 @@ mod tests { .layer(Extension(create_mock_admin())) .with_state(state.clone()); - // 2. Finish It let response = router .oneshot( Request::builder() @@ -373,7 +176,6 @@ mod tests { assert_eq!(response.status(), StatusCode::NO_CONTENT); - // 3. Verify in DB let raid = state.db.raid_quests.find_active().await.unwrap(); assert!(raid.is_none()); } @@ -383,7 +185,6 @@ mod tests { let state = create_test_app_state().await; reset_database(&state.db.pool).await; - // 1. Create Raid and immediately finish it let create_payload = CreateRaidQuest { name: "Finished Raid".to_string(), }; @@ -395,7 +196,6 @@ mod tests { .layer(Extension(create_mock_admin())) .with_state(state.clone()); - // 2. Revert let response = router .oneshot( Request::builder() @@ -409,20 +209,16 @@ mod tests { assert_eq!(response.status(), StatusCode::NO_CONTENT); - // 3. Verify end_date is NULL let raid = state.db.raid_quests.find_active().await.unwrap(); assert!(raid.is_some()); assert!(raid.unwrap().end_date.is_none()) } - // --- Public Get Handlers --- - #[tokio::test] async fn test_get_raid_quests_pagination() { let state = create_test_app_state().await; reset_database(&state.db.pool).await; - // Seed 2 raids let raid_id = state .db .raid_quests @@ -432,7 +228,6 @@ mod tests { .await .unwrap(); state.db.raid_quests.finish(raid_id).await.unwrap(); - // Sleep briefly to ensure timestamp difference if sorting by date tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; state .db @@ -467,362 +262,4 @@ mod tests { assert_eq!(data.len(), 2); assert_eq!(body["meta"]["total_items"], 2); } - - #[tokio::test] - async fn test_get_specific_raider_leaderboard() { - let state = create_test_app_state().await; - reset_database(&state.db.pool).await; - - // 1. Setup: Raid, User, Target Tweet, Submission - let raid_id = state - .db - .raid_quests - .create(&CreateRaidQuest { name: "Raid".into() }) - .await - .unwrap(); - let user = create_persisted_address(&state.db.addresses, "user_A").await; - - seed_target_tweet(&state, "target_123").await; - - // Directly insert into DB to bypass handler logic for seeding - sqlx::query("INSERT INTO raid_submissions (id, raid_id, target_id, raider_id, impression_count) VALUES ($1, $2, $3, $4, 10)") - .bind("sub_1") - .bind(raid_id) - .bind("target_123") - .bind(&user.quan_address.0) - .execute(&state.db.pool) - .await - .unwrap(); - - // Must refresh view! - state.db.raid_leaderboards.refresh().await.unwrap(); - - let router = Router::new() - .route( - "/raiders/:raider_id/leaderboard/:raid_id", - get(handle_get_specific_raider_raid_leaderboard), - ) - .with_state(state); - - // 2. Request - let response = router - .oneshot( - Request::builder() - .method("GET") - .uri(format!("/raiders/{}/leaderboard/{}", user.quan_address.0, raid_id,)) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); - let body: Value = serde_json::from_slice(&body_bytes).unwrap(); - - assert_eq!(body["data"]["raider"]["address"], user.quan_address.0); - assert_eq!(body["data"]["total_impressions"], 10); - } - - #[tokio::test] - async fn test_create_raid_submission_success() { - let state = create_test_app_state().await; - reset_database(&state.db.pool).await; - - // 1. Setup Active Raid - let raid_id = state - .db - .raid_quests - .create(&CreateRaidQuest { name: "Active".into() }) - .await - .unwrap(); - - // 2. Setup User - let user = create_persisted_address(&state.db.addresses, "submitter").await; - - // 3. Seed Target Tweet (Required for Foreign Key) - let target_tweet_id = "1868000000000000000"; - seed_target_tweet(&state, target_tweet_id).await; - - // 4. Setup X Association - // The handler requires the user to have an X account, and that account - // must match the username in the 'tweet_reply_link' (which is "me" below). - sqlx::query("INSERT INTO x_associations (quan_address, username, created_at) VALUES ($1, $2, NOW())") - .bind(&user.quan_address.0) - .bind("me") // Must match the username in the payload URL - .execute(&state.db.pool) - .await - .expect("Failed to create X association"); - - let router = Router::new() - .route("/submissions", post(handle_create_raid_submission)) - .layer(Extension(user)) - .with_state(state.clone()); - - // 5. Payload - // Reply Link -> ID 999999999, Username "me" - let payload = RaidSubmissionInput { - tweet_reply_link: "https://x.com/me/status/999999999".to_string(), - }; - - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/submissions") - .header(http::header::CONTENT_TYPE, "application/json") - .body(Body::from(serde_json::to_string(&payload).unwrap())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::CREATED); - - // 6. Verify in DB - let sub = state.db.raid_submissions.find_by_id("999999999").await.unwrap(); - assert!(sub.is_some()); - let sub = sub.unwrap(); - assert_eq!(sub.raid_id, raid_id); - assert_eq!(&sub.id, "999999999"); - assert!(sub.target_id.is_none()); - } - - #[tokio::test] - async fn test_create_raid_submission_case_insensitive_success() { - let state = create_test_app_state().await; - reset_database(&state.db.pool).await; - - // 1. Setup Active Raid - let raid_id = state - .db - .raid_quests - .create(&CreateRaidQuest { name: "Active".into() }) - .await - .unwrap(); - - // 2. Setup User - let user = create_persisted_address(&state.db.addresses, "submitter").await; - - // 3. Seed Target Tweet (Required for Foreign Key) - let target_tweet_id = "1868000000000000000"; - seed_target_tweet(&state, target_tweet_id).await; - - // 4. Setup X Association - // The handler requires the user to have an X account, and that account - // must match the username in the 'tweet_reply_link' (which is "me" below). - sqlx::query("INSERT INTO x_associations (quan_address, username, created_at) VALUES ($1, $2, NOW())") - .bind(&user.quan_address.0) - .bind("wao") // Must match the username in the payload URL - .execute(&state.db.pool) - .await - .expect("Failed to create X association"); - - let router = Router::new() - .route("/submissions", post(handle_create_raid_submission)) - .layer(Extension(user)) - .with_state(state.clone()); - - // 5. Payload - // Reply Link -> ID 999999999, Username "me" - let payload = RaidSubmissionInput { - tweet_reply_link: "https://x.com/WaO/status/999999999".to_string(), - }; - - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/submissions") - .header(http::header::CONTENT_TYPE, "application/json") - .body(Body::from(serde_json::to_string(&payload).unwrap())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::CREATED); - - // 6. Verify in DB - let sub = state.db.raid_submissions.find_by_id("999999999").await.unwrap(); - assert!(sub.is_some()); - let sub = sub.unwrap(); - assert_eq!(sub.raid_id, raid_id); - assert_eq!(&sub.id, "999999999"); - assert!(sub.target_id.is_none()); - } - - #[tokio::test] - async fn test_create_raid_submission_fails_no_active_raid() { - let state = create_test_app_state().await; - reset_database(&state.db.pool).await; - - // 1. Create a raid and finish it (No Active Raid) - let raid_id = state - .db - .raid_quests - .create(&CreateRaidQuest { name: "Ended".into() }) - .await - .unwrap(); - state.db.raid_quests.finish(raid_id).await.unwrap(); - - let user = create_persisted_address(&state.db.addresses, "submitter").await; - - let router = Router::new() - .route("/submissions", post(handle_create_raid_submission)) - .layer(Extension(user)) - .with_state(state); - - let payload = RaidSubmissionInput { - tweet_reply_link: "https://x.com/b/status/200".into(), - }; - - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/submissions") - .header(http::header::CONTENT_TYPE, "application/json") - .body(Body::from(serde_json::to_string(&payload).unwrap())) - .unwrap(), - ) - .await - .unwrap(); - - // 404/RecordNotFound for No Active Raid - assert!(response.status().is_server_error() || response.status() == StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn test_create_raid_submission_fails_invalid_url() { - let state = create_test_app_state().await; - reset_database(&state.db.pool).await; - - let _raid_id = state - .db - .raid_quests - .create(&CreateRaidQuest { name: "Active".into() }) - .await - .unwrap(); - let user = create_persisted_address(&state.db.addresses, "submitter").await; - - let router = Router::new() - .route("/submissions", post(handle_create_raid_submission)) - .layer(Extension(user)) - .with_state(state); - - let payload = RaidSubmissionInput { - tweet_reply_link: "https://x.com/b/dwdwdwt/dwdwd".into(), - }; - - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/submissions") - .header(http::header::CONTENT_TYPE, "application/json") - .body(Body::from(serde_json::to_string(&payload).unwrap())) - .unwrap(), - ) - .await - .unwrap(); - - // 400 Bad Request / Handler Error - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn test_delete_raid_submission_success() { - let state = create_test_app_state().await; - reset_database(&state.db.pool).await; - - let raid_id = state - .db - .raid_quests - .create(&CreateRaidQuest { name: "Active".into() }) - .await - .unwrap(); - let user = create_persisted_address(&state.db.addresses, "owner").await; - - // Seed FKs - seed_target_tweet(&state, "target_X").await; - - // Manually Insert Submission - let submission_id = "sub_to_delete"; - sqlx::query("INSERT INTO raid_submissions (id, raid_id, target_id, raider_id) VALUES ($1, $2, 'target_X', $3)") - .bind(submission_id) - .bind(raid_id) - .bind(&user.quan_address.0) - .execute(&state.db.pool) - .await - .unwrap(); - - let router = Router::new() - .route("/submissions/:id", delete(handle_delete_raid_submission)) - .layer(Extension(user)) - .with_state(state.clone()); - - let response = router - .oneshot( - Request::builder() - .method("DELETE") - .uri(format!("/submissions/{}", submission_id)) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::NO_CONTENT); - - // Verify Deletion - let found = state.db.raid_submissions.find_by_id(submission_id).await.unwrap(); - assert!(found.is_none()); - } - - #[tokio::test] - async fn test_delete_raid_submission_unauthorized() { - let state = create_test_app_state().await; - reset_database(&state.db.pool).await; - - let raid_id = state - .db - .raid_quests - .create(&CreateRaidQuest { name: "Active".into() }) - .await - .unwrap(); - let owner = create_persisted_address(&state.db.addresses, "owner").await; - let attacker = create_persisted_address(&state.db.addresses, "attacker").await; - - seed_target_tweet(&state, "target_X").await; - - // Submission belongs to OWNER - let submission_id = "sub_protected"; - sqlx::query("INSERT INTO raid_submissions (id, raid_id, target_id, raider_id) VALUES ($1, $2, 'target_X', $3)") - .bind(submission_id) - .bind(raid_id) - .bind(&owner.quan_address.0) - .execute(&state.db.pool) - .await - .unwrap(); - - let router = Router::new() - .route("/submissions/:id", delete(handle_delete_raid_submission)) - .layer(Extension(attacker)) // Attacker tries to delete - .with_state(state); - - let response = router - .oneshot( - Request::builder() - .method("DELETE") - .uri(format!("/submissions/{}", submission_id)) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - // Should return 401 Unauthorized (or 403 Forbidden depending on your error mapping) - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - } } diff --git a/src/http_server.rs b/src/http_server.rs index f803b07..15f102d 100644 --- a/src/http_server.rs +++ b/src/http_server.rs @@ -1,11 +1,8 @@ use axum::http::Method; use axum::{middleware, response::Json, routing::get, Router}; -use rusx::{PkceCodeVerifier, TwitterGateway}; +use rusx::TwitterGateway; use serde::{Deserialize, Serialize}; -use std::{ - collections::HashMap, - sync::{Arc, Mutex}, -}; +use std::{collections::HashMap, sync::Arc}; use tower::ServiceBuilder; use tower_cookies::CookieManagerLayer; use tower_http::{ @@ -19,7 +16,7 @@ use crate::{ metrics::{metrics_handler, track_metrics, Metrics}, routes::api_routes, services::{risk_checker_service::RiskCheckerService, wallet_config_service::WalletConfigService}, - Config, GraphqlClient, + Config, }; use chrono::{DateTime, Utc}; use tokio::sync::RwLock; @@ -28,14 +25,11 @@ use tokio::sync::RwLock; pub struct AppState { pub db: Arc, pub metrics: Arc, - pub graphql_client: Arc, pub wallet_config_service: Arc, pub risk_checker_service: Arc, pub exchange_rate_service: Arc, pub config: Arc, pub challenges: Arc>>, - pub oauth_sessions: Arc>>, - pub twitter_oauth_tokens: Arc>>, pub twitter_gateway: Arc, } @@ -87,7 +81,6 @@ async fn health_check() -> Json { /// Start the HTTP server pub async fn start_server( db: Arc, - graphql_client: Arc, twitter_gateway: Arc, bind_address: &str, config: Arc, @@ -95,7 +88,6 @@ pub async fn start_server( let state = AppState { db, metrics: Arc::new(Metrics::new()), - graphql_client, wallet_config_service: Arc::new(WalletConfigService::new( config.remote_configs.wallet_configs_file.clone(), )?), @@ -104,8 +96,6 @@ pub async fn start_server( config, twitter_gateway, challenges: Arc::new(RwLock::new(HashMap::new())), - oauth_sessions: Arc::new(Mutex::new(HashMap::new())), - twitter_oauth_tokens: Arc::new(RwLock::new(HashMap::new())), }; let app = create_router(state); diff --git a/src/main.rs b/src/main.rs index 77033d6..7e77639 100644 --- a/src/main.rs +++ b/src/main.rs @@ -88,18 +88,11 @@ async fn main() -> AppResult<()> { let telegram_service = Arc::new(TelegramService::new(config.tg_bot.clone())); let alert_service = Arc::new(AlertService::new(config.clone(), db.tweet_pull_usage.clone())); let server_db = db.clone(); - let graphql_client = Arc::new(graphql_client.clone()); let server_addr_clone = server_address.clone(); let server_config = Arc::new(config.clone()); let server_twitter_gateway = twitter_gateway.clone(); let server_task = tokio::spawn(async move { - http_server::start_server( - server_db, - graphql_client, - server_twitter_gateway, - &server_addr_clone, - server_config, - ) + http_server::start_server(server_db, server_twitter_gateway, &server_addr_clone, server_config) .await .map_err(|e| AppError::Server(e.to_string())) }); diff --git a/src/models/address.rs b/src/models/address.rs index f999c78..f30f9cc 100644 --- a/src/models/address.rs +++ b/src/models/address.rs @@ -111,47 +111,6 @@ pub struct AddressInput { pub referral_code: String, } -#[derive(Debug, Clone, Deserialize)] -pub struct RewardProgramStatusPayload { - pub new_status: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AddressStatsResponse { - pub referrals: u64, - pub referral_events: u64, - pub immediate_txs: u64, - pub reversible_txs: u64, - pub mining_events: u64, - pub mining_rewards: String, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct AggregateStatsQueryParams { - #[serde(default)] - pub addresses: Vec, -} - -#[derive(Debug, Serialize)] -pub struct OptedInPositionResponse { - pub quan_address: String, - pub position: i64, - pub is_opted_in: bool, -} - -#[derive(sqlx::FromRow, Debug, Clone, Serialize)] -pub struct AddressWithRank { - #[sqlx(flatten)] - pub address: Address, - pub rank: i64, -} - -#[derive(Debug, Serialize)] -pub struct AssociatedAccountsResponse { - pub eth_address: Option, - pub x_username: Option, -} - #[derive(Debug, Serialize, sqlx::FromRow)] pub struct AddressWithOptInAndAssociations { #[sqlx(flatten)] diff --git a/src/models/auth.rs b/src/models/auth.rs index 965a625..783dc70 100644 --- a/src/models/auth.rs +++ b/src/models/auth.rs @@ -28,13 +28,3 @@ pub struct VerifyLoginBody { pub struct VerifyLoginResponse { pub access_token: String, } - -#[derive(Deserialize)] -pub struct OauthTokenQuery { - pub token: String, -} - -#[derive(Debug, Serialize)] -pub struct GenerateOAuthLinkResponse { - pub url: String, -} diff --git a/src/models/eth_association.rs b/src/models/eth_association.rs deleted file mode 100644 index 9b27050..0000000 --- a/src/models/eth_association.rs +++ /dev/null @@ -1,86 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::{postgres::PgRow, FromRow, Row}; - -use crate::{ - models::{address::QuanAddress, ModelError, ModelResult}, - utils::eth_address_validator::is_valid_eth_address, -}; - -#[derive(Debug, Deserialize, Serialize, Clone, sqlx::Type)] -#[sqlx(transparent)] -pub struct EthAddress(pub String); -impl EthAddress { - pub fn from(input: &str) -> Result { - if !is_valid_eth_address(input) { - return Err(String::from("Invalid ETH address")); - } - - Ok(EthAddress(input.to_string())) - } -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct EthAssociation { - pub quan_address: QuanAddress, - pub eth_address: EthAddress, - pub created_at: Option>, -} - -impl EthAssociation { - pub fn new(input: EthAssociationInput) -> ModelResult { - let quan_address = match QuanAddress::from(&input.quan_address) { - Ok(name) => name, - Err(e) => { - tracing::error!(error = %e, "Invalid quan address input"); - return Err(ModelError::InvalidInput); - } - }; - - let eth_address = match EthAddress::from(&input.eth_address) { - Ok(eth_address) => eth_address, - Err(e) => { - tracing::error!(error = %e, "Invalid ETH address input"); - return Err(ModelError::InvalidInput); - } - }; - - Ok(EthAssociation { - quan_address, - eth_address, - created_at: None, - }) - } -} - -impl<'r> FromRow<'r, PgRow> for EthAssociation { - fn from_row(row: &'r PgRow) -> Result { - let quan_address = row.try_get("quan_address")?; - let eth_address = row.try_get("eth_address")?; - let created_at = row.try_get("created_at")?; - - Ok(EthAssociation { - quan_address, - eth_address, - created_at, - }) - } -} - -// An unvalidated version that we can deserialize directly from JSON -#[derive(Debug, Deserialize)] -pub struct EthAssociationInput { - pub quan_address: String, - pub eth_address: String, -} - -#[derive(Debug, Deserialize)] -pub struct AssociateEthAddressRequest { - pub eth_address: String, -} - -#[derive(Debug, Serialize)] -pub struct AssociateEthAddressResponse { - pub success: bool, - pub message: String, -} diff --git a/src/models/mod.rs b/src/models/mod.rs index bd4e57a..c2aa00f 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -11,13 +11,9 @@ pub type ModelResult = Result; pub mod address; pub mod admin; pub mod auth; -pub mod eth_association; -pub mod opt_in; -pub mod raid_leaderboard; pub mod raid_quest; pub mod raid_submission; pub mod referrals; pub mod relevant_tweet; pub mod tweet_author; pub mod tweet_pull_usage; -pub mod x_association; diff --git a/src/models/opt_in.rs b/src/models/opt_in.rs deleted file mode 100644 index 7765cad..0000000 --- a/src/models/opt_in.rs +++ /dev/null @@ -1,26 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::{postgres::PgRow, FromRow, Row}; - -use crate::models::address::QuanAddress; - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct OptIn { - pub quan_address: QuanAddress, - pub opt_in_number: i32, - pub created_at: DateTime, -} - -impl<'r> FromRow<'r, PgRow> for OptIn { - fn from_row(row: &'r PgRow) -> Result { - let quan_address = row.try_get("quan_address")?; - let opt_in_number = row.try_get("opt_in_number")?; - let created_at = row.try_get("created_at")?; - - Ok(OptIn { - quan_address, - opt_in_number, - created_at, - }) - } -} diff --git a/src/models/raid_leaderboard.rs b/src/models/raid_leaderboard.rs deleted file mode 100644 index ce0d27e..0000000 --- a/src/models/raid_leaderboard.rs +++ /dev/null @@ -1,44 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::{postgres::PgRow, FromRow, Row}; - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct RaiderInfo { - pub address: String, - pub referral_code: Option, - pub referrals_count: i32, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct RaidLeaderboard { - pub raid_id: i32, - pub rank: Option, - pub raider: RaiderInfo, - pub total_submissions: Option, - pub total_impressions: Option, - pub total_replies: Option, - pub total_retweets: Option, - pub total_likes: Option, - pub last_activity: Option>, -} - -// Manual implementation to map flat SQL joins to nested struct -impl<'r> FromRow<'r, PgRow> for RaidLeaderboard { - fn from_row(row: &'r PgRow) -> Result { - Ok(RaidLeaderboard { - raid_id: row.try_get("raid_id")?, - rank: row.try_get("rank")?, - raider: RaiderInfo { - address: row.try_get("quan_address")?, - referral_code: row.try_get("referral_code")?, - referrals_count: row.try_get::, _>("referrals_count")?.unwrap_or(0), - }, - total_submissions: row.try_get("total_submissions")?, - total_impressions: row.try_get("total_impressions")?, - total_replies: row.try_get("total_replies")?, - total_retweets: row.try_get("total_retweets")?, - total_likes: row.try_get("total_likes")?, - last_activity: row.try_get("last_activity")?, - }) - } -} diff --git a/src/models/raid_submission.rs b/src/models/raid_submission.rs index 5d623da..b563d02 100644 --- a/src/models/raid_submission.rs +++ b/src/models/raid_submission.rs @@ -1,10 +1,12 @@ -use chrono::{DateTime, Utc}; use rusx::resources::tweet::{Tweet as TwitterTweet, TweetPublicMetrics}; use serde::{Deserialize, Serialize}; -use sqlx::{postgres::PgRow, FromRow, Row}; -use crate::models::raid_quest::RaidQuest; +#[cfg(test)] +use chrono::{DateTime, Utc}; +#[cfg(test)] +use sqlx::{postgres::PgRow, FromRow, Row}; +#[cfg(test)] #[derive(Debug, Serialize, Deserialize, Clone)] pub struct RaidSubmission { pub id: String, @@ -20,6 +22,7 @@ pub struct RaidSubmission { pub created_at: DateTime, } +#[cfg(test)] impl<'r> FromRow<'r, PgRow> for RaidSubmission { fn from_row(row: &'r PgRow) -> Result { let id = row.try_get("id")?; @@ -50,6 +53,7 @@ impl<'r> FromRow<'r, PgRow> for RaidSubmission { } } +#[cfg(test)] #[derive(Debug, Deserialize, Serialize)] pub struct CreateRaidSubmission { pub id: String, @@ -57,11 +61,6 @@ pub struct CreateRaidSubmission { pub raider_id: String, } -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct RaidSubmissionInput { - pub tweet_reply_link: String, -} - #[derive(Debug, Clone, Deserialize)] pub struct UpdateRaidSubmissionStats { pub id: String, @@ -86,12 +85,6 @@ impl From<&TwitterTweet> for UpdateRaidSubmissionStats { } } -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct RaiderSubmissions { - pub current_raid: RaidQuest, - pub submissions: Vec, -} - #[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] pub struct ValidRaidSubmissionWithRaiderUsername { pub raid_submission_id: String, diff --git a/src/models/x_association.rs b/src/models/x_association.rs deleted file mode 100644 index ed30fe6..0000000 --- a/src/models/x_association.rs +++ /dev/null @@ -1,56 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::{postgres::PgRow, FromRow, Row}; - -use crate::models::{address::QuanAddress, ModelError, ModelResult}; - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct XAssociation { - pub quan_address: QuanAddress, - pub username: String, - pub created_at: Option>, -} - -impl XAssociation { - pub fn new(input: XAssociationInput) -> ModelResult { - let quan_address = match QuanAddress::from(&input.quan_address) { - Ok(name) => name, - Err(e) => { - tracing::error!(error = %e, "Invalid quan address input"); - return Err(ModelError::InvalidInput); - } - }; - - Ok(XAssociation { - quan_address, - username: input.username, - created_at: None, - }) - } -} - -impl<'r> FromRow<'r, PgRow> for XAssociation { - fn from_row(row: &'r PgRow) -> Result { - let quan_address = row.try_get("quan_address")?; - let username = row.try_get("username")?; - let created_at = row.try_get("created_at")?; - - Ok(XAssociation { - quan_address, - username, - created_at, - }) - } -} - -// An unvalidated version that we can deserialize directly from JSON -#[derive(Debug, Deserialize)] -pub struct XAssociationInput { - pub quan_address: String, - pub username: String, -} - -#[derive(Debug, Deserialize)] -pub struct AssociateXHandleRequest { - pub username: String, -} diff --git a/src/repositories/address.rs b/src/repositories/address.rs index 5ebd8b6..6a21e11 100644 --- a/src/repositories/address.rs +++ b/src/repositories/address.rs @@ -2,8 +2,8 @@ use sqlx::{PgPool, Postgres, QueryBuilder}; use crate::{ db_persistence::DbError, - handlers::{LeaderboardQueryParams, ListQueryParams}, - models::address::{Address, AddressFilter, AddressSortColumn, AddressWithOptInAndAssociations, AddressWithRank}, + handlers::ListQueryParams, + models::address::{Address, AddressFilter, AddressSortColumn, AddressWithOptInAndAssociations}, repositories::{calculate_page_offset, DbResult, QueryBuilderExt}, }; @@ -78,19 +78,6 @@ impl AddressRepository { } } - fn push_leaderboard_base_query<'a>(qb: &mut QueryBuilder<'a, sqlx::Postgres>) { - qb.push(" FROM addresses WHERE referrals_count > 0"); - } - - fn push_leaderboard_filter_query_if_possible<'a>( - qb: &mut QueryBuilder<'a, sqlx::Postgres>, - with_referral_code: Option, - ) { - if let Some(code) = with_referral_code { - qb.push(" AND referral_code ILIKE ").push_bind(format!("{}%", code)); - } - } - pub fn new(pool: &PgPool) -> Self { Self { pool: pool.clone() } } @@ -190,16 +177,6 @@ impl AddressRepository { Ok(address) } - pub async fn get_leaderboard_total_items(&self, with_referral_code: Option) -> DbResult { - let mut qb = QueryBuilder::new("SELECT COUNT(*) "); - AddressRepository::push_leaderboard_base_query(&mut qb); - AddressRepository::push_leaderboard_filter_query_if_possible(&mut qb, with_referral_code); - - let total_items = qb.build_query_scalar().fetch_one(&self.pool).await?; - - Ok(total_items) - } - #[allow(dead_code)] pub async fn find_all(&self) -> DbResult> { let addresses = sqlx::query_as::<_, Address>("SELECT * FROM addresses") @@ -209,33 +186,6 @@ impl AddressRepository { Ok(addresses) } - pub async fn get_leaderboard_entries(&self, params: &LeaderboardQueryParams) -> DbResult> { - let mut qb = QueryBuilder::new( - "WITH ranked_addresses AS ( - SELECT - *, - ROW_NUMBER() OVER (ORDER BY referrals_count DESC) as rank - ", - ); - - AddressRepository::push_leaderboard_base_query(&mut qb); - qb.push(") SELECT * FROM ranked_addresses WHERE 1=1"); - - AddressRepository::push_leaderboard_filter_query_if_possible(&mut qb, params.referral_code.clone()); - - let offset = calculate_page_offset(params.page, params.page_size); - qb.push(" ORDER BY rank LIMIT ") - .push_bind(params.page_size as i64) - .push(" OFFSET ") - .push_bind(offset as i64); - - let query = qb.build_query_as::(); - - let addresses = query.fetch_all(&self.pool).await?; - - Ok(addresses) - } - pub async fn increment_referrals_count(&self, quan_address: &str) -> DbResult { let new_count = sqlx::query_scalar::<_, i32>( r#" @@ -305,7 +255,8 @@ mod tests { use crate::models::address::{Address, AddressInput}; use crate::utils::test_app_state::create_test_app_state; use crate::utils::test_db::{ - create_persisted_address, create_persisted_eth_association, create_persisted_x_association, reset_database, + create_persisted_address, create_persisted_eth_association, create_persisted_opt_in, + create_persisted_x_association, reset_database, }; use sqlx::PgPool; @@ -332,18 +283,6 @@ mod tests { Address::new(input).unwrap() } - fn create_mock_address_with_referrals_count(id: &str, code: &str, referrals_count: i32) -> Address { - let input = AddressInput { - quan_address: format!("qz_test_address_{}", id), - referral_code: code.to_string(), - }; - - let mut address = Address::new(input).unwrap(); - address.referrals_count = referrals_count; - - address - } - #[tokio::test] async fn test_create_and_find_by_id() { let repo = setup_test_repository().await; @@ -357,122 +296,6 @@ mod tests { assert_eq!(found.referral_code, "ref001"); } - #[tokio::test] - async fn test_create_many_and_get_total_items() { - let repo = setup_test_repository().await; - let address = create_mock_address("001", "REF001"); - let address2 = create_mock_address_with_referrals_count("002", "REF002", 9); - let address3 = create_mock_address_with_referrals_count("003", "REF003", 10); - let address4 = create_mock_address_with_referrals_count("004", "REF004", 11); - - repo.create_many([address, address2, address3, address4].to_vec()) - .await - .unwrap(); - - let total_items = repo.get_leaderboard_total_items(None).await.unwrap(); - assert_eq!(total_items, 3); - } - - #[tokio::test] - async fn test_get_total_items_with_referral_code_filter() { - let repo = setup_test_repository().await; - let address = create_mock_address("001", "REF001"); - let address2 = create_mock_address_with_referrals_count("002", "REF002", 9); - let address3 = create_mock_address_with_referrals_count("003", "REF003", 10); - let address4 = create_mock_address_with_referrals_count("004", "REF004", 11); - - repo.create_many([address, address2, address3, address4].to_vec()) - .await - .unwrap(); - - let total_items = repo - .get_leaderboard_total_items(Some(String::from("REF003"))) - .await - .unwrap(); - assert_eq!(total_items, 1); - } - - #[tokio::test] - async fn test_get_leaderboard() { - let repo = setup_test_repository().await; - let address1 = create_mock_address_with_referrals_count("001", "REF001", 0); - let address2 = create_mock_address_with_referrals_count("002", "REF002", 10); - let address3 = create_mock_address_with_referrals_count("003", "REF003", 5); - let address4 = create_mock_address_with_referrals_count("004", "REF004", 8); - - repo.create_many(vec![address1, address2.clone(), address3, address4]) - .await - .unwrap(); - - let addresses = repo - .get_leaderboard_entries(&LeaderboardQueryParams { - page: 1, - page_size: 1, - referral_code: None, - }) - .await - .unwrap(); - assert_eq!(addresses.len(), 1); - - let first_index_address = addresses.first().unwrap(); - assert_eq!(first_index_address.address.quan_address.0, address2.quan_address.0); - } - - #[tokio::test] - async fn test_get_leaderboard_omit_zero_referral() { - let repo = setup_test_repository().await; - let address1 = create_mock_address_with_referrals_count("001", "REF001", 0); - let address2 = create_mock_address_with_referrals_count("002", "REF002", 10); - let address3 = create_mock_address_with_referrals_count("003", "REF003", 5); - let address4 = create_mock_address_with_referrals_count("004", "REF004", 8); - - repo.create_many(vec![address1, address2.clone(), address3, address4]) - .await - .unwrap(); - - let addresses = repo - .get_leaderboard_entries(&LeaderboardQueryParams { - page: 1, - page_size: 4, - referral_code: None, - }) - .await - .unwrap(); - assert_eq!(addresses.len(), 3); - - let first_index_address = addresses.first().unwrap(); - assert_eq!(first_index_address.address.quan_address.0, address2.quan_address.0); - } - - #[tokio::test] - async fn test_get_leaderboard_with_referral_code_filter() { - let repo = setup_test_repository().await; - let address1 = create_mock_address_with_referrals_count("001", "REF001", 0); - let address2 = create_mock_address_with_referrals_count("002", "REF002", 10); - let address3 = create_mock_address_with_referrals_count("003", "REF003", 5); - let address4 = create_mock_address_with_referrals_count("004", "REF004", 8); - let address5 = create_mock_address_with_referrals_count("005", "REF005", 7); - - repo.create_many(vec![address1, address2, address3.clone(), address4, address5.clone()]) - .await - .unwrap(); - - let addresses = repo - .get_leaderboard_entries(&LeaderboardQueryParams { - page: 1, - page_size: 4, - referral_code: Some(String::from("REF005")), - }) - .await - .unwrap(); - assert_eq!(addresses.len(), 1); - - let first_index_address = addresses.first().unwrap(); - assert_eq!(first_index_address.address.quan_address.0, address5.quan_address.0); - - assert_eq!(first_index_address.rank, 3); - } - #[tokio::test] async fn test_create_and_find_by_referral_code() { let repo = setup_test_repository().await; @@ -584,17 +407,17 @@ mod tests { reset_database(&state.db.pool).await; let address = create_persisted_address(&state.db.addresses, "REF501").await; - state.db.opt_ins.create(&address.quan_address.0).await.unwrap(); - create_persisted_x_association(&state.db.x_associations, &address.quan_address.0, "address-1").await; + create_persisted_opt_in(&state.db.pool, &address.quan_address.0).await; + create_persisted_x_association(&state.db.pool, &address.quan_address.0, "address-1").await; create_persisted_eth_association( - &state.db.eth_associations, + &state.db.pool, &address.quan_address.0, "0x00000000219ab540356cBB839Cbe05303d7705Fa", ) .await; let address2 = create_persisted_address(&state.db.addresses, "REF502").await; - create_persisted_x_association(&state.db.x_associations, &address2.quan_address.0, "address-2").await; + create_persisted_x_association(&state.db.pool, &address2.quan_address.0, "address-2").await; let address3 = create_persisted_address(&state.db.addresses, "REF503").await; diff --git a/src/repositories/eth_association.rs b/src/repositories/eth_association.rs deleted file mode 100644 index 0de90ae..0000000 --- a/src/repositories/eth_association.rs +++ /dev/null @@ -1,184 +0,0 @@ -use sqlx::PgPool; - -use crate::{ - models::{address::QuanAddress, eth_association::EthAssociation}, - repositories::DbResult, -}; - -#[derive(Clone, Debug)] -pub struct EthAssociationRepository { - pool: PgPool, -} - -impl EthAssociationRepository { - pub fn new(pool: &PgPool) -> Self { - Self { pool: pool.clone() } - } - - pub async fn create(&self, new_association: &EthAssociation) -> DbResult { - let association = sqlx::query_as::<_, EthAssociation>( - r#" - INSERT INTO eth_associations (quan_address, eth_address) - VALUES ($1, $2) - RETURNING quan_address, eth_address, created_at - "#, - ) - .bind(&new_association.quan_address.0) - .bind(&new_association.eth_address.0) - .fetch_one(&self.pool) - .await?; - - Ok(association) - } - - pub async fn find_by_quan_address(&self, quan_address: &QuanAddress) -> DbResult> { - let association = sqlx::query_as::<_, EthAssociation>("SELECT * FROM eth_associations WHERE quan_address = $1") - .bind(&quan_address.0) - .fetch_optional(&self.pool) - .await?; - - Ok(association) - } - - pub async fn update_eth_address(&self, new_association: &EthAssociation) -> DbResult { - let association = sqlx::query_as::<_, EthAssociation>( - r#" - UPDATE eth_associations - SET eth_address = $2 - WHERE quan_address = $1 - RETURNING * - "#, - ) - .bind(&new_association.quan_address.0) - .bind(&new_association.eth_address.0) - .fetch_one(&self.pool) - .await?; - - Ok(association) - } - - pub async fn delete(&self, quan_address: &QuanAddress) -> DbResult<()> { - sqlx::query("DELETE FROM eth_associations WHERE quan_address = $1") - .bind(&quan_address.0) - .execute(&self.pool) - .await?; - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - config::Config, - models::eth_association::EthAssociationInput, - repositories::address::AddressRepository, - utils::test_db::{create_persisted_address, reset_database}, - }; - use sqlx::PgPool; - - // Helper to set up test repositories. - async fn setup_test_repositories() -> (AddressRepository, EthAssociationRepository) { - let config = Config::load_test_env().expect("Failed to load configuration for tests"); - let pool = PgPool::connect(config.get_database_url()) - .await - .expect("Failed to create pool."); - - reset_database(&pool).await; - - (AddressRepository::new(&pool), EthAssociationRepository::new(&pool)) - } - - #[tokio::test] - async fn test_create_and_find_association() { - let (address_repo, eth_repo) = setup_test_repositories().await; - - // Create Parent Address - let address = create_persisted_address(&address_repo, "user_01").await; - - // Create ETH Association - let input = EthAssociationInput { - quan_address: address.quan_address.0.clone(), - eth_address: "0x00000000219ab540356cBB839Cbe05303d7705Fa".to_string(), - }; - let new_association = EthAssociation::new(input).unwrap(); - - let created = eth_repo.create(&new_association).await.unwrap(); - - // Check returned value - assert_eq!(created.eth_address.0, "0x00000000219ab540356cBB839Cbe05303d7705Fa"); - assert!(created.created_at.is_some()); - - // Verify by finding by Quan Address - let found = eth_repo - .find_by_quan_address(&address.quan_address) - .await - .unwrap() - .unwrap(); - - assert_eq!(found.quan_address.0, address.quan_address.0); - assert_eq!(found.eth_address.0, "0x00000000219ab540356cBB839Cbe05303d7705Fa"); - } - - #[tokio::test] - async fn test_update_eth_address() { - let (address_repo, eth_repo) = setup_test_repositories().await; - - let address = create_persisted_address(&address_repo, "user_03").await; - - // Initial Create - let initial_input = EthAssociationInput { - quan_address: address.quan_address.0.clone(), - eth_address: "0x00000000219ab540356cBB839Cbe05303d7705Fa".to_string(), - }; - let initial_association = EthAssociation::new(initial_input).unwrap(); - eth_repo.create(&initial_association).await.unwrap(); - - // Update - let new_input = EthAssociationInput { - quan_address: address.quan_address.0.clone(), - eth_address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2".to_string(), - }; - let new_association = EthAssociation::new(new_input).unwrap(); - let updated = eth_repo.update_eth_address(&new_association).await.unwrap(); - - assert_eq!(updated.eth_address.0, new_association.eth_address.0); - - // Verify in DB - let found = eth_repo - .find_by_quan_address(&address.quan_address) - .await - .unwrap() - .unwrap(); - assert_eq!(found.eth_address.0, new_association.eth_address.0); - } - - #[tokio::test] - async fn test_delete_association() { - let (address_repo, eth_repo) = setup_test_repositories().await; - - let address = create_persisted_address(&address_repo, "user_04").await; - - let input = EthAssociationInput { - quan_address: address.quan_address.0.clone(), - eth_address: "0x00000000219ab540356cBB839Cbe05303d7705Fa".to_string(), - }; - let new_association = EthAssociation::new(input).unwrap(); - eth_repo.create(&new_association).await.unwrap(); - - // Verify it exists - assert!(eth_repo - .find_by_quan_address(&address.quan_address) - .await - .unwrap() - .is_some()); - - // Delete - eth_repo.delete(&address.quan_address).await.unwrap(); - - // Verify it is gone - let found = eth_repo.find_by_quan_address(&address.quan_address).await.unwrap(); - assert!(found.is_none()); - } -} diff --git a/src/repositories/mod.rs b/src/repositories/mod.rs index 547105c..2608128 100644 --- a/src/repositories/mod.rs +++ b/src/repositories/mod.rs @@ -6,8 +6,6 @@ pub type DbResult = Result; pub mod address; pub mod admin; -pub mod eth_association; -pub mod opt_in; pub mod raid_leaderboard; pub mod raid_quest; pub mod raid_submission; @@ -15,7 +13,6 @@ pub mod referral; pub mod relevant_tweet; pub mod tweet_author; pub mod tweet_pull_usage; -pub mod x_association; pub trait QueryBuilderExt { fn push_condition(&mut self, sql: &str, where_started: &mut bool); diff --git a/src/repositories/opt_in.rs b/src/repositories/opt_in.rs deleted file mode 100644 index a7c59cd..0000000 --- a/src/repositories/opt_in.rs +++ /dev/null @@ -1,307 +0,0 @@ -use sqlx::PgPool; - -use crate::{models::opt_in::OptIn, repositories::DbResult}; - -#[derive(Clone, Debug)] -pub struct OptInRepository { - pool: PgPool, -} - -impl OptInRepository { - pub fn new(pool: &PgPool) -> Self { - Self { pool: pool.clone() } - } - - pub async fn create(&self, quan_address: &str) -> DbResult { - let opt_in = sqlx::query_as::<_, OptIn>( - r#" - INSERT INTO opt_ins (quan_address) - VALUES ($1) - ON CONFLICT (quan_address) DO UPDATE - SET quan_address = EXCLUDED.quan_address - RETURNING * - "#, - ) - .bind(quan_address) - .fetch_one(&self.pool) - .await?; - - Ok(opt_in) - } - - pub async fn delete(&self, quan_address: &str) -> DbResult<()> { - sqlx::query("DELETE FROM opt_ins WHERE quan_address = $1") - .bind(quan_address) - .execute(&self.pool) - .await?; - - Ok(()) - } - - pub async fn find_by_address(&self, quan_address: &str) -> DbResult> { - let opt_in = sqlx::query_as::<_, OptIn>("SELECT * FROM opt_ins WHERE quan_address = $1") - .bind(quan_address) - .fetch_optional(&self.pool) - .await?; - - Ok(opt_in) - } - - pub async fn get_all_ordered(&self, limit: i64) -> DbResult> { - let opt_ins = sqlx::query_as::<_, OptIn>("SELECT * FROM opt_ins ORDER BY created_at ASC LIMIT $1") - .bind(limit) - .fetch_all(&self.pool) - .await?; - - Ok(opt_ins) - } -} - -#[cfg(test)] -mod tests { - // NOTE: These tests must run sequentially (--test-threads=1) to avoid database conflicts - // Run with: cargo test --lib opt_in -- --test-threads=1 - use super::*; - use crate::config::Config; - use crate::models::address::{Address, AddressInput}; - use crate::repositories::address::AddressRepository; - use crate::utils::test_db::reset_database; - use sqlx::{postgres::PgPoolOptions, PgPool}; - use sqlx::{Pool, Postgres}; - use std::time::Duration; - use tokio::time::sleep; - - async fn setup_test_repository() -> (OptInRepository, AddressRepository, PgPool) { - let config = Config::load_test_env().expect("Failed to load configuration for tests"); - let pool = PgPoolOptions::new() - .max_connections(5) - .connect(config.get_database_url()) - .await - .expect("Failed to create pool."); - - reset_database(&pool).await; - - let opt_in_repo = OptInRepository::new(&pool); - let address_repo = AddressRepository::new(&pool); - - (opt_in_repo, address_repo, pool) - } - - async fn count_records(pool: &Pool) -> DbResult { - let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM opt_ins") - .fetch_one(pool) - .await?; - - Ok(count) - } - - fn create_test_address(id: &str) -> Address { - let input = AddressInput { - quan_address: format!("qz_test_{}", id), - referral_code: format!("ref_{}", id), - }; - Address::new(input).unwrap() - } - - #[tokio::test] - async fn test_create_and_find_by_address() { - let (opt_in_repo, address_repo, _pool) = setup_test_repository().await; - let address = create_test_address("test_create_001"); - - address_repo.create(&address).await.unwrap(); - let count = count_records(&_pool).await.unwrap(); - let opt_in = opt_in_repo.create(&address.quan_address.0).await.unwrap(); - - assert_eq!(opt_in.quan_address.0, address.quan_address.0); - assert_eq!(opt_in.opt_in_number, (count + 1) as i32); - - let found = opt_in_repo - .find_by_address(&address.quan_address.0) - .await - .unwrap() - .unwrap(); - assert_eq!(found.quan_address.0, address.quan_address.0); - assert_eq!(found.opt_in_number, (count + 1) as i32); - } - - #[tokio::test] - async fn test_delete() { - let (opt_in_repo, address_repo, _pool) = setup_test_repository().await; - let address = create_test_address("test_delete_001"); - - address_repo.create(&address).await.unwrap(); - opt_in_repo.create(&address.quan_address.0).await.unwrap(); - - assert!(opt_in_repo - .find_by_address(&address.quan_address.0) - .await - .unwrap() - .is_some()); - - opt_in_repo.delete(&address.quan_address.0).await.unwrap(); - - assert!(opt_in_repo - .find_by_address(&address.quan_address.0) - .await - .unwrap() - .is_none()); - } - - #[tokio::test] - async fn test_get_all_ordered() { - let (opt_in_repo, address_repo, _pool) = setup_test_repository().await; - - let addr1 = create_test_address("test_ordered_001"); - let addr2 = create_test_address("test_ordered_002"); - let addr3 = create_test_address("test_ordered_003"); - - address_repo.create(&addr1).await.unwrap(); - address_repo.create(&addr2).await.unwrap(); - address_repo.create(&addr3).await.unwrap(); - - opt_in_repo.create(&addr1.quan_address.0).await.unwrap(); - sleep(Duration::from_millis(10)).await; - - opt_in_repo.create(&addr2.quan_address.0).await.unwrap(); - sleep(Duration::from_millis(10)).await; - - opt_in_repo.create(&addr3.quan_address.0).await.unwrap(); - - let all = opt_in_repo.get_all_ordered(100).await.unwrap(); - assert_eq!(all.len(), 3); - assert_eq!(all[0].quan_address.0, addr1.quan_address.0); - assert_eq!(all[1].quan_address.0, addr2.quan_address.0); - assert_eq!(all[2].quan_address.0, addr3.quan_address.0); - } - - #[tokio::test] - async fn test_get_all_ordered_with_limit() { - let (opt_in_repo, address_repo, _pool) = setup_test_repository().await; - - let addr1 = create_test_address("test_limit_001"); - let addr2 = create_test_address("test_limit_002"); - let addr3 = create_test_address("test_limit_003"); - - address_repo.create(&addr1).await.unwrap(); - address_repo.create(&addr2).await.unwrap(); - address_repo.create(&addr3).await.unwrap(); - - opt_in_repo.create(&addr1.quan_address.0).await.unwrap(); - sleep(Duration::from_millis(10)).await; - - opt_in_repo.create(&addr2.quan_address.0).await.unwrap(); - sleep(Duration::from_millis(10)).await; - - opt_in_repo.create(&addr3.quan_address.0).await.unwrap(); - - let limited = opt_in_repo.get_all_ordered(2).await.unwrap(); - assert_eq!(limited.len(), 2); - assert_eq!(limited[0].quan_address.0, addr1.quan_address.0); - assert_eq!(limited[1].quan_address.0, addr2.quan_address.0); - } - - #[tokio::test] - async fn test_count() { - let (opt_in_repo, address_repo, _pool) = setup_test_repository().await; - - assert_eq!(count_records(&_pool).await.unwrap(), 0); - - let addr1 = create_test_address("test_count_001"); - let addr2 = create_test_address("test_count_002"); - let addr3 = create_test_address("test_count_003"); - - address_repo.create(&addr1).await.unwrap(); - address_repo.create(&addr2).await.unwrap(); - address_repo.create(&addr3).await.unwrap(); - - opt_in_repo.create(&addr1.quan_address.0).await.unwrap(); - assert_eq!(count_records(&_pool).await.unwrap(), 1); - - opt_in_repo.create(&addr2.quan_address.0).await.unwrap(); - assert_eq!(count_records(&_pool).await.unwrap(), 2); - - opt_in_repo.create(&addr3.quan_address.0).await.unwrap(); - assert_eq!(count_records(&_pool).await.unwrap(), 3); - - opt_in_repo.delete(&addr2.quan_address.0).await.unwrap(); - assert_eq!(count_records(&_pool).await.unwrap(), 2); - } - - #[tokio::test] - async fn test_create_duplicate_updates_existing() { - let (opt_in_repo, address_repo, _pool) = setup_test_repository().await; - let address = create_test_address("test_duplicate_001"); - - address_repo.create(&address).await.unwrap(); - - let opt_in1 = opt_in_repo.create(&address.quan_address.0).await.unwrap(); - let first_created_at = opt_in1.created_at; - - sleep(Duration::from_millis(10)).await; - - let opt_in2 = opt_in_repo.create(&address.quan_address.0).await.unwrap(); - - assert_eq!(opt_in2.quan_address.0, address.quan_address.0); - assert_eq!(opt_in2.opt_in_number, opt_in1.opt_in_number); - assert_eq!(opt_in2.created_at, first_created_at); - } - - #[tokio::test] - async fn test_find_by_address_not_found() { - let (opt_in_repo, _address_repo, _pool) = setup_test_repository().await; - - let result = opt_in_repo.find_by_address("qz_nonexistent").await.unwrap(); - assert!(result.is_none()); - } - - #[tokio::test] - async fn test_delete_nonexistent_no_error() { - let (opt_in_repo, _address_repo, _pool) = setup_test_repository().await; - - let result = opt_in_repo.delete("qz_nonexistent").await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_sequential_opt_in_numbering() { - let (opt_in_repo, address_repo, _pool) = setup_test_repository().await; - - let addr1 = create_test_address("test_seq_001"); - let addr2 = create_test_address("test_seq_002"); - let addr3 = create_test_address("test_seq_003"); - - address_repo.create(&addr1).await.unwrap(); - address_repo.create(&addr2).await.unwrap(); - address_repo.create(&addr3).await.unwrap(); - - assert_eq!(count_records(&_pool).await.unwrap(), 0); - - let opt_in1 = opt_in_repo.create(&addr1.quan_address.0).await.unwrap(); - assert_eq!(opt_in1.opt_in_number, 1); - assert_eq!(count_records(&_pool).await.unwrap(), 1); - - let opt_in2 = opt_in_repo.create(&addr2.quan_address.0).await.unwrap(); - assert_eq!(opt_in2.opt_in_number, 2); - assert_eq!(count_records(&_pool).await.unwrap(), 2); - - let opt_in3 = opt_in_repo.create(&addr3.quan_address.0).await.unwrap(); - assert_eq!(opt_in3.opt_in_number, 3); - assert_eq!(count_records(&_pool).await.unwrap(), 3); - - let all = opt_in_repo.get_all_ordered(100).await.unwrap(); - assert_eq!(all[0].opt_in_number, 1); - assert_eq!(all[1].opt_in_number, 2); - assert_eq!(all[2].opt_in_number, 3); - } - - #[tokio::test] - async fn test_opt_in_timestamps_are_set() { - let (opt_in_repo, address_repo, _pool) = setup_test_repository().await; - let address = create_test_address("test_timestamp_001"); - - address_repo.create(&address).await.unwrap(); - let opt_in = opt_in_repo.create(&address.quan_address.0).await.unwrap(); - - assert!(!opt_in.created_at.to_rfc3339().is_empty()); - } -} diff --git a/src/repositories/raid_leaderboard.rs b/src/repositories/raid_leaderboard.rs index 77b14c0..ac7a421 100644 --- a/src/repositories/raid_leaderboard.rs +++ b/src/repositories/raid_leaderboard.rs @@ -1,5 +1,5 @@ -use crate::{models::raid_leaderboard::RaidLeaderboard, repositories::DbResult}; -use sqlx::{PgPool, QueryBuilder}; +use crate::repositories::DbResult; +use sqlx::PgPool; #[derive(Clone, Debug)] pub struct RaidLeaderboardRepository { @@ -17,140 +17,13 @@ impl RaidLeaderboardRepository { .await?; Ok(()) } - - /// Retrieves the top raiders with their Rank and Address info. - pub async fn get_entries( - &self, - raid_id: i32, - limit: i64, - offset: i64, - referral_code: Option, - ) -> DbResult> { - let mut qb = QueryBuilder::new( - "WITH ranked_entries AS ( - SELECT - l.raid_id, - l.total_submissions, - l.total_impressions, - l.total_replies, - l.total_retweets, - l.total_likes, - l.last_activity, - l.raider_id, - RANK() OVER (ORDER BY l.total_impressions DESC) as rank - FROM raid_leaderboards l - WHERE l.raid_id = ", - ); - - qb.push_bind(raid_id); - qb.push(" AND l.total_impressions > 0 "); - qb.push(" ) "); - - qb.push( - "SELECT - r.raid_id, - r.total_submissions, - r.total_impressions, - r.total_replies, - r.total_retweets, - r.total_likes, - r.last_activity, - r.rank, - a.quan_address, - a.referral_code, - a.referrals_count - FROM ranked_entries r - JOIN addresses a ON r.raider_id = a.quan_address - WHERE 1=1 ", - ); - - if let Some(code) = referral_code { - qb.push(" AND a.referral_code ILIKE "); - qb.push_bind(format!("{}%", code)); - } - - qb.push(" ORDER BY r.total_impressions DESC LIMIT "); - qb.push_bind(limit); - qb.push(" OFFSET "); - qb.push_bind(offset); - - let leaderboard = qb.build_query_as::().fetch_all(&self.pool).await?; - - Ok(leaderboard) - } - - /// Get a specific user's stats, rank, and populated address info. - pub async fn get_raider_entry(&self, raid_id: i32, raider_id: &str) -> DbResult> { - // CTE calculates rank first, then we join addresses on the result - let query = " - WITH ranked_entries AS ( - SELECT - *, - RANK() OVER (ORDER BY total_impressions DESC) as rank - FROM raid_leaderboards - WHERE raid_id = $1 - ) - SELECT - r.raid_id, - r.total_submissions, - r.total_impressions, - r.total_replies, - r.total_retweets, - r.total_likes, - r.last_activity, - r.rank, - -- Joined Address Data - a.quan_address, - a.referral_code, - a.referrals_count - FROM ranked_entries r - JOIN addresses a ON r.raider_id = a.quan_address - WHERE r.raider_id = $2 AND r.total_impressions > 0 - "; - - let entry = sqlx::query_as::<_, RaidLeaderboard>(query) - .bind(raid_id) - .bind(raider_id) - .fetch_optional(&self.pool) - .await?; - - Ok(entry) - } - - pub async fn get_total_items(&self, raid_id: i32, referral_code: Option) -> DbResult { - let mut qb = QueryBuilder::new("SELECT COUNT(*) FROM raid_leaderboards l "); - - if referral_code.is_some() { - qb.push(" JOIN addresses a ON l.raider_id = a.quan_address "); - } - - qb.push(" WHERE l.raid_id = "); - qb.push_bind(raid_id); - - qb.push(" AND l.total_impressions > 0 "); - - if let Some(code) = referral_code { - qb.push(" AND a.referral_code ILIKE "); - qb.push_bind(format!("{}%", code)); - } - - let total_items = qb.build_query_scalar().fetch_one(&self.pool).await?; - - Ok(total_items) - } } #[cfg(test)] mod tests { use super::*; - use crate::config::Config; - use crate::utils::test_db::reset_database; + use crate::{config::Config, utils::test_db::reset_database}; use sqlx::PgPool; - use uuid::Uuid; - - // ------------------------------------------------------------------------- - // Setup & Helpers - // ------------------------------------------------------------------------- async fn setup_test_repository() -> RaidLeaderboardRepository { let config = Config::load_test_env().expect("Failed to load configuration for tests"); @@ -158,203 +31,20 @@ mod tests { .await .expect("Failed to create pool."); - // Clean database to ensure the view starts empty reset_database(&pool).await; RaidLeaderboardRepository::new(&pool) } - struct ScenarioData { - raid_id: i32, - user_high: String, // Rank 1 - user_mid: String, // Rank 2 - user_low: String, // Rank 3 - } - - /// Seeds a complete scenario: - /// 1. One active Raid - /// 2. Three Users (High, Mid, Low stats) with Address profiles - /// 3. Submissions for each user with defined impression counts to force ranking - async fn seed_leaderboard_scenario(pool: &PgPool) -> ScenarioData { - // 1. Create Raid - let raid_id: i32 = sqlx::query_scalar( - "INSERT INTO raid_quests (name, start_date) VALUES ('Leaderboard Raid', NOW()) RETURNING id", - ) - .fetch_one(pool) - .await - .unwrap(); - - // 2. Create Tweet Author (Required for FK) - sqlx::query("INSERT INTO tweet_authors (id, name, username) VALUES ('auth_1', 'Auth', 'auth')") - .execute(pool) - .await - .unwrap(); - - // 3. Create 3 Users and their Submissions - let users = vec![ - ("user_high", "REF_HIGH", 1000), // 1000 Impressions - ("user_mid", "REF_MID", 500), // 500 Impressions - ("user_low", "REF_LOW", 100), // 100 Impressions - ("user_zero", "REF_ZERO", 0), // 0 Impressions (Ignored) - ]; - - for (uid, ref_code, impressions) in &users { - // A. Create Address - sqlx::query("INSERT INTO addresses (quan_address, referral_code) VALUES ($1, $2)") - .bind(uid) - .bind(ref_code) - .execute(pool) - .await - .unwrap(); - - // B. Create Target Tweet (Unique per user to avoid unique constraint issues if any) - let tweet_id = format!("tweet_{}", uid); - sqlx::query( - "INSERT INTO relevant_tweets (id, author_id, text, created_at) VALUES ($1, 'auth_1', 'text', NOW())", - ) - .bind(&tweet_id) - .execute(pool) - .await - .unwrap(); - - // C. Create Submission - sqlx::query( - "INSERT INTO raid_submissions (id, raid_id, target_id, raider_id, impression_count) - VALUES ($1, $2, $3, $4, $5)", - ) - .bind(Uuid::new_v4().to_string()) - .bind(raid_id) - .bind(tweet_id) - .bind(uid) - .bind(impressions) - .execute(pool) - .await - .unwrap(); - } - - ScenarioData { - raid_id, - user_high: "user_high".to_string(), - user_mid: "user_mid".to_string(), - user_low: "user_low".to_string(), - } - } - - // ------------------------------------------------------------------------- - // Tests - // ------------------------------------------------------------------------- - - #[tokio::test] - async fn test_refresh_and_get_entries_ranking() { - let repo = setup_test_repository().await; - let data = seed_leaderboard_scenario(&repo.pool).await; - - // CRITICAL: We must refresh the view, or it will be empty - repo.refresh().await.expect("Failed to refresh view"); - - // 1. Get Top 3 - let entries = repo.get_entries(data.raid_id, 10, 0, None).await.unwrap(); - - assert_eq!(entries.len(), 3); - - // 2. Verify Ranking (High -> Mid -> Low) - let first = &entries[0]; - assert_eq!(first.raider.address, data.user_high); - assert_eq!(first.rank, Some(1)); - assert_eq!(first.total_impressions, Some(1000)); - assert_eq!(first.raider.referral_code, Some("REF_HIGH".to_string())); - - let second = &entries[1]; - assert_eq!(second.raider.address, data.user_mid); - assert_eq!(second.rank, Some(2)); - - let third = &entries[2]; - assert_eq!(third.raider.address, data.user_low); - assert_eq!(third.rank, Some(3)); - } - - #[tokio::test] - async fn test_get_raider_entry_specific_rank() { - let repo = setup_test_repository().await; - let data = seed_leaderboard_scenario(&repo.pool).await; - repo.refresh().await.unwrap(); - - // 1. Fetch the Middle User (should be Rank 2) - let entry = repo.get_raider_entry(data.raid_id, &data.user_mid).await.unwrap(); - - assert!(entry.is_some()); - let stats = entry.unwrap(); - - assert_eq!(stats.raider.address, data.user_mid); - assert_eq!(stats.rank, Some(2)); // Confirms CTE rank calculation works for single row - assert_eq!(stats.total_impressions, Some(500)); - } - #[tokio::test] - async fn test_pagination() { + async fn test_refresh_materialized_view() { let repo = setup_test_repository().await; - let data = seed_leaderboard_scenario(&repo.pool).await; - repo.refresh().await.unwrap(); - // 1. Limit 1, Offset 1 (Should skip Rank 1, return Rank 2) - let page = repo.get_entries(data.raid_id, 1, 1, None).await.unwrap(); - - assert_eq!(page.len(), 1); - assert_eq!(page[0].raider.address, data.user_mid); - assert_eq!(page[0].rank, Some(2)); - } - - #[tokio::test] - async fn test_view_is_isolated_by_raid_id() { - let repo = setup_test_repository().await; - // 1. This creates the "Active" raid starting NOW() - let data = seed_leaderboard_scenario(&repo.pool).await; - - // 2. Create a separate, "Old" raid that definitively ended in the past. - // Using "NOW() - INTERVAL '1 HOUR'" for the end_date ensures it does not - // overlap with the raid created in step 1. - let other_raid_id: i32 = sqlx::query_scalar( - "INSERT INTO raid_quests (name, start_date, end_date) - VALUES ('Other Raid', NOW() - INTERVAL '2 DAYS', NOW() - INTERVAL '1 HOUR') - RETURNING id", - ) - .fetch_one(&repo.pool) - .await - .unwrap(); - - // 3. Refresh to include this new raid in the view - repo.refresh().await.unwrap(); - - // Query the empty/old raid - let entries = repo.get_entries(other_raid_id, 10, 0, None).await.unwrap(); - assert!(entries.is_empty(), "Should not return entries from a different raid"); - - // Query the populated raid - let entries_main = repo.get_entries(data.raid_id, 10, 0, None).await.unwrap(); - assert_eq!(entries_main.len(), 3); - } - - #[tokio::test] - async fn test_get_entries_filtered_by_referral_code() { - let repo = setup_test_repository().await; - let data = seed_leaderboard_scenario(&repo.pool).await; - repo.refresh().await.unwrap(); - - // user_mid has REF_MID - let entries = repo - .get_entries(data.raid_id, 10, 0, Some("REF_MID".to_string())) + sqlx::query("INSERT INTO raid_quests (name, start_date) VALUES ('Refresh Raid', NOW())") + .execute(&repo.pool) .await .unwrap(); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].raider.address, data.user_mid); - // Rank should be preserved from global ranking (Rank 2) - assert_eq!(entries[0].rank, Some(2)); - - let total = repo - .get_total_items(data.raid_id, Some("REF_MID".to_string())) - .await - .unwrap(); - assert_eq!(total, 1); + repo.refresh().await.expect("Failed to refresh view"); } } diff --git a/src/repositories/raid_submission.rs b/src/repositories/raid_submission.rs index 1d4c533..deb425c 100644 --- a/src/repositories/raid_submission.rs +++ b/src/repositories/raid_submission.rs @@ -1,27 +1,34 @@ -use sqlx::{PgPool, Postgres, QueryBuilder}; +use sqlx::{PgPool, QueryBuilder}; use crate::{ - db_persistence::DbError, - models::raid_submission::{ - CreateRaidSubmission, RaidSubmission, UpdateRaidSubmissionStats, ValidRaidSubmissionWithRaiderUsername, - }, + models::raid_submission::{UpdateRaidSubmissionStats, ValidRaidSubmissionWithRaiderUsername}, repositories::DbResult, }; +#[cfg(test)] +use crate::{ + db_persistence::DbError, + models::raid_submission::{CreateRaidSubmission, RaidSubmission}, +}; +#[cfg(test)] +use sqlx::Postgres; + #[derive(Clone, Debug)] pub struct RaidSubmissionRepository { pool: PgPool, } impl RaidSubmissionRepository { - fn create_select_base_query<'a>() -> QueryBuilder<'a, Postgres> { - QueryBuilder::new("SELECT * FROM raid_submissions") - } - pub fn new(pool: &PgPool) -> Self { Self { pool: pool.clone() } } + #[cfg(test)] + fn create_select_base_query<'a>() -> QueryBuilder<'a, Postgres> { + QueryBuilder::new("SELECT * FROM raid_submissions") + } + + #[cfg(test)] pub async fn create(&self, submission: &CreateRaidSubmission) -> DbResult { let created_id = sqlx::query_scalar::<_, String>( " @@ -45,6 +52,7 @@ impl RaidSubmissionRepository { } } + #[cfg(test)] pub async fn delete(&self, submission_id: &str) -> DbResult<()> { sqlx::query("DELETE FROM raid_submissions WHERE id = $1") .bind(submission_id) @@ -54,6 +62,7 @@ impl RaidSubmissionRepository { Ok(()) } + #[cfg(test)] pub async fn find_by_id(&self, id: &str) -> DbResult> { let mut qb = Self::create_select_base_query(); qb.push(" WHERE id = "); @@ -76,19 +85,6 @@ impl RaidSubmissionRepository { Ok(submissions) } - pub async fn find_by_raider(&self, raid_id: i32, raider_id: &str) -> DbResult> { - let mut qb = Self::create_select_base_query(); - qb.push(" WHERE raid_id = "); - qb.push_bind(raid_id); - qb.push(" AND raider_id = "); - qb.push_bind(raider_id); - qb.push(" ORDER BY created_at DESC"); - - let submissions = qb.build_query_as().fetch_all(&self.pool).await?; - - Ok(submissions) - } - pub async fn update_stats_many(&self, updates: &[UpdateRaidSubmissionStats]) -> DbResult { if updates.is_empty() { return Ok(0); diff --git a/src/repositories/referral.rs b/src/repositories/referral.rs index cac3e71..fdea7ef 100644 --- a/src/repositories/referral.rs +++ b/src/repositories/referral.rs @@ -27,6 +27,7 @@ impl ReferralRepository { Ok(created_id) } + #[cfg(test)] pub async fn find_all_by_referrer(&self, quan_address: String) -> DbResult> { let referrals = sqlx::query_as::<_, Referral>("SELECT * FROM referrals WHERE referrer_address = $1") .bind(quan_address) @@ -36,15 +37,6 @@ impl ReferralRepository { Ok(referrals) } - pub async fn count_by_referrer(&self, quan_address: String) -> DbResult { - let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM referrals WHERE referrer_address = $1") - .bind(quan_address) - .fetch_one(&self.pool) - .await?; - - Ok(count) - } - pub async fn find_by_referee(&self, quan_address: String) -> DbResult> { let referral = sqlx::query_as::<_, Referral>("SELECT * FROM referrals WHERE referee_address = $1") .bind(quan_address.clone()) @@ -181,54 +173,6 @@ mod tests { assert_eq!(results.len(), 2); } - #[tokio::test] - async fn test_count_by_referrer() { - let (address_repo, referral_repo) = setup_test_repositories().await; - - let referrer = create_persisted_address(&address_repo, "referrer_02").await; - let referee1 = create_persisted_address(&address_repo, "referee_02a").await; - let referee2 = create_persisted_address(&address_repo, "referee_02b").await; - // This one should not be found in the results - let other_referrer = create_persisted_address(&address_repo, "other_referrer").await; - - // Create two referrals from the same referrer - referral_repo - .create( - &Referral::new(ReferralData { - referrer_address: referrer.quan_address.0.clone(), - referee_address: referee1.quan_address.0.clone(), - }) - .unwrap(), - ) - .await - .unwrap(); - referral_repo - .create( - &Referral::new(ReferralData { - referrer_address: referrer.quan_address.0.clone(), - referee_address: referee2.quan_address.0.clone(), - }) - .unwrap(), - ) - .await - .unwrap(); - // Create an unrelated referral with a different referee - let other_referee = create_persisted_address(&address_repo, "other_referee").await; - referral_repo - .create( - &Referral::new(ReferralData { - referrer_address: other_referrer.quan_address.0.clone(), - referee_address: other_referee.quan_address.0.clone(), - }) - .unwrap(), - ) - .await - .unwrap(); - - let count = referral_repo.count_by_referrer(referrer.quan_address.0).await.unwrap(); - assert_eq!(count, 2); - } - #[tokio::test] async fn test_find_all_by_referrer_no_results() { let (_address_repo, referral_repo) = setup_test_repositories().await; diff --git a/src/repositories/x_association.rs b/src/repositories/x_association.rs deleted file mode 100644 index d99a88b..0000000 --- a/src/repositories/x_association.rs +++ /dev/null @@ -1,128 +0,0 @@ -use sqlx::PgPool; - -use crate::{ - models::{address::QuanAddress, x_association::XAssociation}, - repositories::DbResult, -}; - -#[derive(Clone, Debug)] -pub struct XAssociationRepository { - pool: PgPool, -} - -impl XAssociationRepository { - pub fn new(pool: &PgPool) -> Self { - Self { pool: pool.clone() } - } - - pub async fn create(&self, new_association: &XAssociation) -> DbResult { - let association = sqlx::query_as::<_, XAssociation>( - r#" - INSERT INTO x_associations (quan_address, username) - VALUES ($1, $2) - ON CONFLICT (quan_address) - DO UPDATE SET username = EXCLUDED.username - RETURNING quan_address, username, created_at - "#, - ) - .bind(&new_association.quan_address.0) - .bind(&new_association.username) - .fetch_one(&self.pool) - .await?; - - Ok(association) - } - - pub async fn find_by_address(&self, quan_address: &QuanAddress) -> DbResult> { - let association = sqlx::query_as::<_, XAssociation>("SELECT * FROM x_associations WHERE quan_address = $1") - .bind(&quan_address.0) - .fetch_optional(&self.pool) - .await?; - - Ok(association) - } - - pub async fn delete(&self, quan_address: &QuanAddress) -> DbResult<()> { - sqlx::query("DELETE FROM x_associations WHERE quan_address = $1") - .bind(&quan_address.0) - .execute(&self.pool) - .await?; - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - config::Config, - models::x_association::{XAssociation, XAssociationInput}, - repositories::address::AddressRepository, - utils::test_db::{create_persisted_address, reset_database}, - }; - use sqlx::PgPool; - - // Helper to set up test repositories. - async fn setup_test_repositories() -> (AddressRepository, XAssociationRepository) { - let config = Config::load_test_env().expect("Failed to load configuration for tests"); - let pool = PgPool::connect(config.get_database_url()) - .await - .expect("Failed to create pool."); - - reset_database(&pool).await; - - (AddressRepository::new(&pool), XAssociationRepository::new(&pool)) - } - - #[tokio::test] - async fn test_create_and_find_association() { - let (address_repo, x_repo) = setup_test_repositories().await; - - // Create Parent Address - let address = create_persisted_address(&address_repo, "user_01").await; - - // Create X Association - let input = XAssociationInput { - quan_address: address.quan_address.0.clone(), - username: "x_user_01".to_string(), - }; - let new_association = XAssociation::new(input).unwrap(); - - let created = x_repo.create(&new_association).await.unwrap(); - - // Check returned value - assert_eq!(created.username, "x_user_01"); - assert!(created.created_at.is_some()); - - // Verify by finding by Quan Address - let found = x_repo.find_by_address(&address.quan_address).await.unwrap().unwrap(); - - assert_eq!(found.quan_address.0, address.quan_address.0); - assert_eq!(found.username, "x_user_01"); - } - - #[tokio::test] - async fn test_delete_association() { - let (address_repo, x_repo) = setup_test_repositories().await; - - let address = create_persisted_address(&address_repo, "user_04").await; - - let input = XAssociationInput { - quan_address: address.quan_address.0.clone(), - username: "to_be_deleted".to_string(), - }; - let new_association = XAssociation::new(input).unwrap(); - x_repo.create(&new_association).await.unwrap(); - - // Verify it exists - assert!(x_repo.find_by_address(&address.quan_address).await.unwrap().is_some()); - - // Delete - x_repo.delete(&address.quan_address).await.unwrap(); - - // Verify it is gone - let found = x_repo.find_by_address(&address.quan_address).await.unwrap(); - assert!(found.is_none()); - } -} diff --git a/src/routes/raid_quest.rs b/src/routes/raid_quest.rs index 3048079..920faea 100644 --- a/src/routes/raid_quest.rs +++ b/src/routes/raid_quest.rs @@ -1,15 +1,14 @@ use axum::{ handler::Handler, middleware, - routing::{delete, get, post, put}, + routing::{delete, get, put}, Router, }; use crate::{ handlers::raid_quest::{ - handle_create_raid, handle_create_raid_submission, handle_delete_raid, handle_delete_raid_submission, - handle_finish_raid, handle_get_active_raid_raider_submissions, handle_get_raid_leaderboard, - handle_get_raid_quests, handle_get_specific_raider_raid_leaderboard, handle_revert_to_active_raid, + handle_create_raid, handle_delete_raid, handle_finish_raid, handle_get_raid_quests, + handle_revert_to_active_raid, }, http_server::AppState, middlewares::jwt_auth, diff --git a/src/services/graphql_client.rs b/src/services/graphql_client.rs index f7ee0eb..61d6962 100644 --- a/src/services/graphql_client.rs +++ b/src/services/graphql_client.rs @@ -60,46 +60,6 @@ pub struct TransferData { transfers: Vec, } -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct EventsConnection { - #[serde(rename = "totalCount")] - pub total_count: u64, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct TransactionsConnection { - #[serde(rename = "totalCount")] - pub total_count: u64, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ReversibleTransactionsConnection { - #[serde(rename = "totalCount")] - pub total_count: u64, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct MinerStat { - #[serde(rename = "totalMinedBlocks")] - pub total_mined_blocks: u64, - #[serde(rename = "totalRewards")] - pub total_rewards: String, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct StatsData { - pub transactions: TransactionsConnection, - #[serde(rename = "reversibleTransactions")] - pub reversible_transactions: ReversibleTransactionsConnection, - #[serde(rename = "minerStats")] - pub miner_stats: Vec, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct EventCountData { - pub events: EventsConnection, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Transfer { pub id: String, @@ -269,201 +229,13 @@ impl GraphqlClient { Ok((transfer_count, address_count as usize)) } - - pub async fn get_address_stats(&self, id: String) -> GraphqlResult { - const GET_STATS_QUERY: &str = r#" - query GetStatsById($id: String!) { - transactions: transfersConnection( - orderBy: timestamp_DESC - where: { - extrinsicHash_isNull: false - AND: { from: { id_eq: $id }, OR: { to: { id_eq: $id } } } - } - ) { - totalCount - } - reversibleTransactions: reversibleTransfersConnection( - orderBy: timestamp_DESC - where: { from: { id_eq: $id }, OR: { to: { id_eq: $id } } } - ) { - totalCount - } - minerStats(where: { id_eq: $id} ) { - totalMinedBlocks - totalRewards - } - } - "#; - - let mut variables = HashMap::new(); - variables.insert("id".to_string(), serde_json::json!(id)); - - let payload = GraphqlQuery { - query: GET_STATS_QUERY.to_string(), - variables: Some(variables), - }; - - info!("Fetching transfers from GraphQL endpoint: {}", &self.graphql_url); - - let stats_data: StatsData = self.execute_query(payload).await?; - let miner_stats = stats_data - .miner_stats - .first() - .unwrap_or(&MinerStat { - total_mined_blocks: 0, - total_rewards: "0".to_string(), - }) - .to_owned(); - - Ok(AddressStats { - total_reversible_transactions: stats_data.reversible_transactions.total_count, - total_transactions: stats_data.transactions.total_count, - total_mined_blocks: miner_stats.total_mined_blocks, - total_mining_rewards: miner_stats.total_rewards, - }) - } - - pub async fn get_addresses_stats(&self, ids: Vec) -> GraphqlResult { - if ids.is_empty() { - return Ok(AddressStats { - total_reversible_transactions: 0, - total_transactions: 0, - total_mined_blocks: 0, - total_mining_rewards: "0".to_string(), - }); - } - - const GET_STATS_QUERY: &str = r#" - query GetStatsByIds($ids: [String!]!) { - transactions: transfersConnection( - orderBy: timestamp_DESC - where: { - extrinsicHash_isNull: false - AND: { from: { id_in: $ids }, OR: { to: { id_in: $ids } } } - } - ) { - totalCount - } - reversibleTransactions: reversibleTransfersConnection( - orderBy: timestamp_DESC - where: { from: { id_in: $ids }, OR: { to: { id_in: $ids } } } - ) { - totalCount - } - minerStats(where: { id_in: $ids } ) { - totalMinedBlocks - totalRewards - } - } - "#; - - let mut variables = HashMap::new(); - variables.insert("ids".to_string(), serde_json::json!(ids)); - - let payload = GraphqlQuery { - query: GET_STATS_QUERY.to_string(), - variables: Some(variables), - }; - - info!("Fetching transfers from GraphQL endpoint: {}", &self.graphql_url); - - let stats_data: StatsData = self.execute_query(payload).await?; - - // Aggregate miner stats from all addresses - let total_mined_blocks = stats_data.miner_stats.iter().map(|stat| stat.total_mined_blocks).sum(); - - let total_rewards: u128 = stats_data - .miner_stats - .iter() - .filter_map(|stat| stat.total_rewards.parse::().ok()) - .sum(); - - Ok(AddressStats { - total_reversible_transactions: stats_data.reversible_transactions.total_count, - total_transactions: stats_data.transactions.total_count, - total_mined_blocks, - total_mining_rewards: total_rewards.to_string(), - }) - } - - pub async fn get_addresses_events_count(&self, ids: Vec) -> GraphqlResult { - if ids.is_empty() { - return Ok(0); - } - - const GET_EVENT_COUNT_QUERY: &str = r#" -query GetEventCountByIds($ids: [String!]!) { - eventsConnection( - orderBy: id_ASC, - where: { - AND: [ - { type_not_eq: BALANCE }, - { - OR: [ - { - OR: [ - { transfer: { from: { id_in: $ids } } }, - { transfer: { to: { id_in: $ids } } } - ] - }, - { - OR: [ - { reversibleTransfer: { from: { id_in: $ids } } }, - { reversibleTransfer: { to: { id_in: $ids } } } - ] - }, - { - minerReward: { - miner: { - id_in: $ids - } - } - } - ] - } - ] - } - ) { - totalCount - } -} -"#; - - let mut variables = HashMap::new(); - variables.insert("ids".to_string(), serde_json::json!(ids)); - - let payload = GraphqlQuery { - query: GET_EVENT_COUNT_QUERY.to_string(), - variables: Some(variables), - }; - - info!("Fetching event count from GraphQL endpoint: {}", &self.graphql_url); - - let event_count_data: EventCountData = self.execute_query(payload).await?; - - Ok(event_count_data.events.total_count) - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct AddressStats { - pub total_transactions: u64, - pub total_reversible_transactions: u64, - pub total_mining_rewards: String, - pub total_mined_blocks: u64, } #[cfg(test)] mod tests { - use crate::Config; - use super::*; use std::collections::HashSet; - use wiremock::{ - matchers::{body_json, method, path}, - Mock, MockServer, ResponseTemplate, - }; // Helper functions fn sample_transfers() -> Vec { @@ -491,179 +263,6 @@ mod tests { ] } - // Helper to create a test GraphqlClient with a mock server - async fn setup_mock_graphql_client(mock_server: &MockServer) -> GraphqlClient { - let config = Config::load_test_env().expect("Failed to load test configuration"); - let db = DbPersistence::new(config.get_database_url()).await.unwrap(); - - // Use the mock server URI instead of the real GraphQL endpoint - let mock_url = mock_server.uri(); - GraphqlClient::new(db, mock_url) - } - - #[tokio::test] - async fn test_get_address_stats_success() { - // Arrange - let mock_server = MockServer::start().await; - let address_string = "qzkxaHg7h4zgk5jPNkJ3a7r9xNgbJNGpJ6a5LPEThDnjkfrC6".to_string(); - - const GET_STATS_QUERY: &str = r#" - query GetStatsById($id: String!) { - transactions: transfersConnection( - orderBy: timestamp_DESC - where: { - extrinsicHash_isNull: false - AND: { from: { id_eq: $id }, OR: { to: { id_eq: $id } } } - } - ) { - totalCount - } - reversibleTransactions: reversibleTransfersConnection( - orderBy: timestamp_DESC - where: { from: { id_eq: $id }, OR: { to: { id_eq: $id } } } - ) { - totalCount - } - minerStats(where: { id_eq: $id} ) { - totalMinedBlocks - totalRewards - } - } - "#; - - let expected_request = serde_json::json!({ - "query": GET_STATS_QUERY, - "variables": { - "id": address_string - } - }); - - let mock_response = serde_json::json!({ - "data": { - "transactions": { - "totalCount": 42 - }, - "reversibleTransactions": { - "totalCount": 5 - }, - "minerStats": [ - { - "totalMinedBlocks": 10, - "totalRewards": "1000000000000000000" - } - ] - } - }); - - Mock::given(method("POST")) - .and(path("/")) - .and(body_json(&expected_request)) - .respond_with(ResponseTemplate::new(200).set_body_json(&mock_response)) - .expect(1) - .mount(&mock_server) - .await; - - let client = setup_mock_graphql_client(&mock_server).await; - - // Act - let result = client.get_address_stats(address_string).await; - - // Assert - assert!(result.is_ok()); - - let stats = result.unwrap(); - assert_eq!(stats.total_transactions, 42); - assert_eq!(stats.total_reversible_transactions, 5); - assert_eq!(stats.total_mined_blocks, 10); - assert_eq!(stats.total_mining_rewards, "1000000000000000000"); - } - - #[tokio::test] - async fn test_get_addresses_event_count() { - // Arrange - let mock_server = MockServer::start().await; - let addresses = vec![ - "qzkxaHg7h4zgk5jPNkJ3a7r9xNgbJNGpJ6a5LPEThDnjkfrC6".to_string(), - "qzkxaHg7h4zgk5jPNkJ3a7r9xNgbJNGpJ6a5LPEThDnjkfrC7".to_string(), - ]; - - const GET_EVENT_COUNT_QUERY: &str = r#" -query GetEventCountByIds($ids: [String!]!) { - eventsConnection( - orderBy: id_ASC, - where: { - AND: [ - { type_not_eq: BALANCE }, - { - OR: [ - { - OR: [ - { transfer: { from: { id_in: $ids } } }, - { transfer: { to: { id_in: $ids } } } - ] - }, - { - OR: [ - { reversibleTransfer: { from: { id_in: $ids } } }, - { reversibleTransfer: { to: { id_in: $ids } } } - ] - }, - { - minerReward: { - miner: { - id_in: $ids - } - } - } - ] - } - ] - } - ) { - totalCount - } -} -"#; - - let expected_request = serde_json::json!({ - "query": GET_EVENT_COUNT_QUERY, - "variables": { - "ids": addresses - } - }); - - let mock_response = serde_json::json!({ - "data": { - "events": { - "totalCount": 16561 - } - } - }); - - Mock::given(method("POST")) - .and(path("/")) - .and(body_json(&expected_request)) - .respond_with(ResponseTemplate::new(200).set_body_json(&mock_response)) - .expect(1) - .mount(&mock_server) - .await; - - let client = setup_mock_graphql_client(&mock_server).await; - - // Act - let result = client.get_addresses_events_count(addresses).await; - - // Assert - assert!(result.is_ok()); - - let data = result.unwrap(); - assert_eq!(data, 16561); - } - - // ============================================================================ - // Serialization/Deserialization Tests - // ============================================================================ - #[test] fn test_transfer_deserialization_single() { let json = r#" diff --git a/src/utils/eth_address_validator.rs b/src/utils/eth_address_validator.rs deleted file mode 100644 index cbeeba0..0000000 --- a/src/utils/eth_address_validator.rs +++ /dev/null @@ -1,132 +0,0 @@ -use tiny_keccak::{Hasher, Keccak}; - -/// Validates a string to check if it's a valid Ethereum address. -/// -/// This function performs the following checks: -/// 1. Checks for the "0x" prefix. -/// 2. Validates the length is exactly 42 characters. -/// 3. Ensures all characters are valid hexadecimal digits. -/// 4. Validates the EIP-55 mixed-case checksum if present. If the address -/// is all lowercase or all uppercase, it is also considered valid. -/// -/// # Arguments -/// -/// * `address` - A string slice that holds the Ethereum address to validate. -/// -/// # Returns -/// -/// * `true` if the address is a valid Ethereum address, `false` otherwise. -/// -/// # Examples -/// -/// ``` -// assert!(is_valid_eth_address("0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359")); // lowercase -// assert!(is_valid_eth_address("0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359")); // checksummed -// assert!(!is_valid_eth_address("0xfb6916095ca1df60bb79ce92ce3ea74c37c5d35")); // invalid length -/// ``` -pub fn is_valid_eth_address(address: &str) -> bool { - if address.len() != 42 { - return false; - } - - let prefix = &address[..2]; - if prefix != "0x" && prefix != "0X" { - return false; - } - - // Get the address part without the "0x" prefix - let addr_part = &address[2..]; - - if !addr_part.chars().all(|c| c.is_ascii_hexdigit()) { - return false; - } - - // If the address is all lowercase or all uppercase, it's valid (no checksum) - let is_all_lowercase = addr_part.chars().all(|c| c.is_lowercase() || c.is_ascii_digit()); - let is_all_uppercase = addr_part.chars().all(|c| c.is_uppercase() || c.is_ascii_digit()); - - if is_all_lowercase || is_all_uppercase { - return true; - } - - // If it's mixed-case, validate the EIP-55 checksum - validate_checksum(addr_part) -} - -/// Validates the EIP-55 checksum for a given address part (without "0x"). -fn validate_checksum(address_part: &str) -> bool { - let lower_addr = address_part.to_lowercase(); - - // Compute the Keccak-256 hash of the lowercase address - let mut hasher = Keccak::v256(); - hasher.update(lower_addr.as_bytes()); - let mut hash_output = [0u8; 32]; - hasher.finalize(&mut hash_output); - - // Iterate through the original address and compare casing based on the hash - for (i, c) in address_part.chars().enumerate() { - if c.is_ascii_digit() { - continue; // Digits are not checksummed - } - - // Get the i-th nibble (4 bits) of the hash. - // Each byte of the hash corresponds to two hex characters in the address. - let hash_nibble = if i % 2 == 0 { - hash_output[i / 2] >> 4 // Get the high nibble - } else { - hash_output[i / 2] & 0x0F // Get the low nibble - }; - - if hash_nibble >= 8 { - // If the nibble is 8 or greater, the character must be uppercase - if c.is_lowercase() { - return false; - } - } else { - // Otherwise, the character must be lowercase - if c.is_uppercase() { - return false; - } - } - } - - true -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_correctly_validate_eth_address() { - let addresses_to_test = vec![ - // --- Valid Addresses --- - // Vitalik Buterin's address (checksummed) - ("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", true), - // Same address, lowercase - ("0xd8da6bf26964af9d7eed9e03e53415d37aa96045", true), - // Same address, uppercase - ("0XD8DA6BF26964AF9D7EED9E03E53415D37AA96045", true), - // Another valid checksum address - ("0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359", true), - // --- Invalid Addresses --- - // Invalid checksum - ("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA9604f", false), - // Invalid length (too short) - ("0xd8da6bf26964af9d7eed9e03e53415d37aa9604", false), - // Invalid length (too long) - ("0xd8da6bf26964af9d7eed9e03e53415d37aa960455", false), - // Missing "0x" prefix - ("d8da6bf26964af9d7eed9e03e53415d37aa96045", false), - // Invalid hex characters - ("0xd8da6bf26964af9d7eed9e03e53415d37aa9604g", false), - // Empty string - ("", false), - ]; - - for (address, expected) in addresses_to_test { - let is_valid = is_valid_eth_address(address); - assert_eq!(is_valid, expected, "Validation failed for address: {}", address); - } - } -} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 971f349..eca7e60 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,4 +1,3 @@ -pub mod eth_address_validator; pub mod generate_referral_code; pub mod jwt; pub mod x_url; diff --git a/src/utils/test_app_state.rs b/src/utils/test_app_state.rs index 02305b0..48b2e76 100644 --- a/src/utils/test_app_state.rs +++ b/src/utils/test_app_state.rs @@ -7,17 +7,16 @@ use crate::{ exchange_rate_service::ExchangeRateService, risk_checker_service::RiskCheckerService, wallet_config_service::WalletConfigService, }, - Config, GraphqlClient, + Config, }; use jsonwebtoken::{encode, EncodingKey, Header}; use rusx::RusxGateway; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; pub async fn create_test_app_state() -> AppState { let config = Config::load_test_env().expect("Failed to load test configuration"); let db = DbPersistence::new(config.get_database_url()).await.unwrap(); let twitter_gateway = RusxGateway::new(config.x_oauth.clone(), None).unwrap(); - let graphql_client = GraphqlClient::new(db.clone(), config.candidates.graphql_url.clone()); let risk_checker_service = RiskCheckerService::new(&config.risk_checker); let exchange_rate_service = ExchangeRateService::new(&config.exchange_rate.api_key); let db = Arc::new(db); @@ -25,7 +24,6 @@ pub async fn create_test_app_state() -> AppState { AppState { db, metrics: Arc::new(Metrics::new()), - graphql_client: Arc::new(graphql_client), wallet_config_service: Arc::new( WalletConfigService::new(config.remote_configs.wallet_configs_file.clone()).unwrap(), ), @@ -33,8 +31,6 @@ pub async fn create_test_app_state() -> AppState { exchange_rate_service: Arc::new(exchange_rate_service), config: Arc::new(config), twitter_gateway: Arc::new(twitter_gateway), - oauth_sessions: Arc::new(Mutex::new(std::collections::HashMap::new())), - twitter_oauth_tokens: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), challenges: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), } } @@ -42,8 +38,8 @@ pub async fn create_test_app_state() -> AppState { pub fn generate_test_token(secret: &str, user_id: &str) -> String { let claims = TokenClaims { sub: user_id.to_string(), - iat: 1, // Just a valid past timestamp - exp: 9999999999, // Far future timestamp, + iat: 1, + exp: 9999999999, }; encode( diff --git a/src/utils/test_db.rs b/src/utils/test_db.rs index 5ad3044..bd9ee11 100644 --- a/src/utils/test_db.rs +++ b/src/utils/test_db.rs @@ -6,12 +6,8 @@ use crate::{ models::{ address::{Address, AddressInput}, admin::Admin, - eth_association::{EthAssociation, EthAssociationInput}, - x_association::{XAssociation, XAssociationInput}, - }, - repositories::{ - address::AddressRepository, eth_association::EthAssociationRepository, x_association::XAssociationRepository, }, + repositories::address::AddressRepository, }; pub async fn reset_database(pool: &PgPool) { @@ -38,36 +34,30 @@ pub async fn create_persisted_address(repo: &AddressRepository, id: &str) -> Add address } -pub async fn create_persisted_x_association( - repo: &XAssociationRepository, - address: &str, - username: &str, -) -> XAssociation { - let input = XAssociationInput { - quan_address: address.to_string(), - username: username.to_string(), - }; - let new_association = XAssociation::new(input).unwrap(); - - repo.create(&new_association).await.unwrap(); - - new_association +pub async fn create_persisted_opt_in(pool: &PgPool, quan_address: &str) { + sqlx::query("INSERT INTO opt_ins (quan_address) VALUES ($1)") + .bind(quan_address) + .execute(pool) + .await + .expect("Failed to create opt-in"); } -pub async fn create_persisted_eth_association( - repo: &EthAssociationRepository, - quan_address: &str, - eth_address: &str, -) -> EthAssociation { - let input = EthAssociationInput { - quan_address: quan_address.to_string(), - eth_address: eth_address.to_string(), - }; - let new_association = EthAssociation::new(input).unwrap(); - - repo.create(&new_association).await.unwrap(); +pub async fn create_persisted_x_association(pool: &PgPool, quan_address: &str, username: &str) { + sqlx::query("INSERT INTO x_associations (quan_address, username) VALUES ($1, $2)") + .bind(quan_address) + .bind(username) + .execute(pool) + .await + .expect("Failed to create x association"); +} - new_association +pub async fn create_persisted_eth_association(pool: &PgPool, quan_address: &str, eth_address: &str) { + sqlx::query("INSERT INTO eth_associations (quan_address, eth_address) VALUES ($1, $2)") + .bind(quan_address) + .bind(eth_address) + .execute(pool) + .await + .expect("Failed to create eth association"); } pub fn create_mock_admin() -> Admin { diff --git a/src/utils/x_url.rs b/src/utils/x_url.rs index a882455..518e24d 100644 --- a/src/utils/x_url.rs +++ b/src/utils/x_url.rs @@ -1,38 +1,3 @@ -pub fn parse_x_status_url(url: &str) -> Option<(String, String)> { - let url_path = url.split('?').next()?.trim(); - - // We check if it contains distinct X/Twitter domains - let valid_domains = ["x.com/", "twitter.com/"]; - if !valid_domains.iter().any(|d| url_path.contains(d)) { - // Returns None if input is "google.com/..." or "random text" - return None; - } - - // Find anchor "/status/" - let split_keyword = "/status/"; - let index = url_path.rfind(split_keyword)?; - - // Extract Username - let prefix = &url_path[..index]; - let username = prefix.split('/').next_back()?; - - if username.is_empty() { - return None; - } - - // Extract ID - let id_part = &url_path[index + split_keyword.len()..]; - let id = id_part.trim_end_matches('/'); - - // Validate ID is numeric (Bad URL protection) - // If the URL is "x.com/user/status/bad_id", this catches it. - if id.is_empty() || !id.chars().all(char::is_numeric) { - return None; - } - - Some((username.to_string(), id.to_string())) -} - pub fn build_x_status_url(username: &str, id: &str) -> String { format!("https://x.com/{}/status/{}", username, id) } From 3a800325c011528495dce25edb1fb23ffd8bbe16 Mon Sep 17 00:00:00 2001 From: Beast Date: Fri, 12 Jun 2026 22:17:37 +0800 Subject: [PATCH 03/11] feat: cleanup dead codes 3rd pass --- Cargo.lock | 218 +++--------------------------------- Cargo.toml | 4 - config/default.toml | 17 --- config/example.toml | 26 +---- config/test.toml | 17 --- src/args.rs | 20 ---- src/config.rs | 6 - src/main.rs | 6 - src/repositories/address.rs | 2 +- 9 files changed, 18 insertions(+), 298 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 011628c..fc2267c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2939,20 +2939,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" -[[package]] -name = "frame-decode" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e56c0e51972d7b26ff76966c4d0f2307030df9daa5ce0885149ece1ab7ca5ad" -dependencies = [ - "frame-metadata", - "parity-scale-codec", - "scale-decode", - "scale-info", - "scale-type-resolver", - "sp-crypto-hashing", -] - [[package]] name = "frame-decode" version = "0.9.0" @@ -6024,8 +6010,8 @@ dependencies = [ "sha2 0.10.9", "sp-core", "sp-runtime", - "subxt 0.44.3", - "subxt-metadata 0.44.3", + "subxt", + "subxt-metadata", "thiserror 2.0.18", "tokio", "toml 0.9.8", @@ -8287,43 +8273,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "subxt" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74791ddeaaa6de42e7cc8a715c83eb73303f513f90af701fd07eb2caad92ed84" -dependencies = [ - "async-trait", - "derive-where", - "either", - "frame-metadata", - "futures", - "hex", - "jsonrpsee", - "parity-scale-codec", - "primitive-types 0.13.1", - "scale-bits", - "scale-decode", - "scale-encode", - "scale-info", - "scale-value", - "serde", - "serde_json", - "sp-crypto-hashing", - "subxt-core 0.43.0", - "subxt-lightclient 0.43.0", - "subxt-macro 0.43.0", - "subxt-metadata 0.43.0", - "subxt-rpcs 0.43.0", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", - "url", - "wasm-bindgen-futures", - "web-time", -] - [[package]] name = "subxt" version = "0.44.3" @@ -8347,11 +8296,11 @@ dependencies = [ "serde", "serde_json", "sp-crypto-hashing", - "subxt-core 0.44.3", - "subxt-lightclient 0.44.3", - "subxt-macro 0.44.3", - "subxt-metadata 0.44.3", - "subxt-rpcs 0.44.3", + "subxt-core", + "subxt-lightclient", + "subxt-macro", + "subxt-metadata", + "subxt-rpcs", "thiserror 2.0.18", "tokio", "tokio-util", @@ -8361,23 +8310,6 @@ dependencies = [ "web-time", ] -[[package]] -name = "subxt-codegen" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1728caecd9700391e78cc30dc298221d6f5ca0ea28258a452aa76b0b7c229842" -dependencies = [ - "heck 0.5.0", - "parity-scale-codec", - "proc-macro2", - "quote", - "scale-info", - "scale-typegen", - "subxt-metadata 0.43.0", - "syn 2.0.109", - "thiserror 2.0.18", -] - [[package]] name = "subxt-codegen" version = "0.44.3" @@ -8390,41 +8322,11 @@ dependencies = [ "quote", "scale-info", "scale-typegen", - "subxt-metadata 0.44.3", + "subxt-metadata", "syn 2.0.109", "thiserror 2.0.18", ] -[[package]] -name = "subxt-core" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25338dd11ae34293b8d0c5807064f2e00194ba1bd84cccfa694030c8d185b941" -dependencies = [ - "base58", - "blake2", - "derive-where", - "frame-decode 0.8.3", - "frame-metadata", - "hashbrown 0.14.5", - "hex", - "impl-serde", - "keccak-hash 0.11.0", - "parity-scale-codec", - "primitive-types 0.13.1", - "scale-bits", - "scale-decode", - "scale-encode", - "scale-info", - "scale-value", - "serde", - "serde_json", - "sp-crypto-hashing", - "subxt-metadata 0.43.0", - "thiserror 2.0.18", - "tracing", -] - [[package]] name = "subxt-core" version = "0.44.3" @@ -8434,7 +8336,7 @@ dependencies = [ "base58", "blake2", "derive-where", - "frame-decode 0.9.0", + "frame-decode", "frame-metadata", "hashbrown 0.14.5", "hex", @@ -8450,28 +8352,11 @@ dependencies = [ "serde", "serde_json", "sp-crypto-hashing", - "subxt-metadata 0.44.3", + "subxt-metadata", "thiserror 2.0.18", "tracing", ] -[[package]] -name = "subxt-lightclient" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9097ef356e534ce0b6a50b95233512afc394347b971a4f929c4830adc52bbc6f" -dependencies = [ - "futures", - "futures-util", - "serde", - "serde_json", - "smoldot-light", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tracing", -] - [[package]] name = "subxt-lightclient" version = "0.44.3" @@ -8489,23 +8374,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "subxt-macro" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69516e8ff0e9340a0f21b8398da7f997571af4734ee81deada5150a2668c8443" -dependencies = [ - "darling 0.20.11", - "parity-scale-codec", - "proc-macro-error2", - "quote", - "scale-typegen", - "subxt-codegen 0.43.0", - "subxt-metadata 0.43.0", - "subxt-utils-fetchmetadata 0.43.0", - "syn 2.0.109", -] - [[package]] name = "subxt-macro" version = "0.44.3" @@ -8517,34 +8385,19 @@ dependencies = [ "proc-macro-error2", "quote", "scale-typegen", - "subxt-codegen 0.44.3", - "subxt-metadata 0.44.3", - "subxt-utils-fetchmetadata 0.44.3", + "subxt-codegen", + "subxt-metadata", + "subxt-utils-fetchmetadata", "syn 2.0.109", ] -[[package]] -name = "subxt-metadata" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c134068711c0c46906abc0e6e4911204420331530738e18ca903a5469364d9f" -dependencies = [ - "frame-decode 0.8.3", - "frame-metadata", - "hashbrown 0.14.5", - "parity-scale-codec", - "scale-info", - "sp-crypto-hashing", - "thiserror 2.0.18", -] - [[package]] name = "subxt-metadata" version = "0.44.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b2f2a52d97d7539febc0006d6988081150b1c1a3e4a357ca02ab5cdb34072bc" dependencies = [ - "frame-decode 0.9.0", + "frame-decode", "frame-metadata", "hashbrown 0.14.5", "parity-scale-codec", @@ -8553,30 +8406,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "subxt-rpcs" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25de7727144780d780a6a7d78bbfd28414b8adbab68b05e87329c367d7705be4" -dependencies = [ - "derive-where", - "frame-metadata", - "futures", - "hex", - "impl-serde", - "jsonrpsee", - "parity-scale-codec", - "primitive-types 0.13.1", - "serde", - "serde_json", - "subxt-core 0.43.0", - "subxt-lightclient 0.43.0", - "thiserror 2.0.18", - "tokio-util", - "tracing", - "url", -] - [[package]] name = "subxt-rpcs" version = "0.44.3" @@ -8593,25 +8422,14 @@ dependencies = [ "primitive-types 0.13.1", "serde", "serde_json", - "subxt-core 0.44.3", - "subxt-lightclient 0.44.3", + "subxt-core", + "subxt-lightclient", "thiserror 2.0.18", "tokio-util", "tracing", "url", ] -[[package]] -name = "subxt-utils-fetchmetadata" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c4fb8fd6b16ecd3537a29d70699f329a68c1e47f70ed1a46d64f76719146563" -dependencies = [ - "hex", - "parity-scale-codec", - "thiserror 2.0.18", -] - [[package]] name = "subxt-utils-fetchmetadata" version = "0.44.3" @@ -8736,13 +8554,11 @@ name = "task-master" version = "0.1.0" dependencies = [ "alloy", - "anyhow", "argon2", "axum", "chrono", "clap", "config", - "dirs", "hex", "jsonwebtoken", "lazy_static", @@ -8760,9 +8576,7 @@ dependencies = [ "sp-core", "sp-runtime", "sqlx", - "subxt 0.43.0", "thiserror 2.0.18", - "tiny-keccak", "tokio", "toml 0.9.8", "tower 0.4.13", diff --git a/Cargo.toml b/Cargo.toml index b07f7e2..265818a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,17 +77,13 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Error handling -anyhow = "1.0" thiserror = "2.0" # Additional utilities argon2 = "0.5" -dirs = "6.0" jsonwebtoken = "9.3.1" lazy_static = "1.5.0" prometheus = { version = "0.14.0", features = ["process"] } -subxt = "0.43.0" -tiny-keccak = { version = "2.0.2", features = ["keccak"] } notify = "8.2.0" [dev-dependencies] diff --git a/config/default.toml b/config/default.toml index e4b6920..89320e8 100644 --- a/config/default.toml +++ b/config/default.toml @@ -1,14 +1,11 @@ # TaskMaster Configuration [server] -base_api_url = "http://localhost:3000/api" host = "127.0.0.1" port = 3000 cors_allowed_origins = ["http://localhost:4321"] [blockchain] -website_url = "https://www.quantus.com" - # Quantus node WebSocket URL node_url = "wss://a1-dirac.quantus.cat" @@ -22,20 +19,6 @@ reversal_period_hours = 12 [candidates] # GraphQL endpoint to fetch candidate addresses graphql_url = "https://subsquid.quantus.com/graphql" -# How often to refresh candidates (in minutes) -refresh_interval_minutes = 30 - -[task_generation] -# How often to generate new tasks (in minutes) -generation_interval_minutes = 60 -# Number of taskees to select each round -taskees_per_round = 5 - -[reverser] -# How many minutes before end_time to reverse uncompleted tasks -early_reversal_minutes = 2 -# How often to check for tasks to reverse (in seconds) -check_interval_seconds = 30 [data] # Database configuration diff --git a/config/example.toml b/config/example.toml index e154504..7b40b48 100644 --- a/config/example.toml +++ b/config/example.toml @@ -3,14 +3,11 @@ [server] # HTTP server configuration -base_api_url = "http://127.0.0.1:3000/api" host = "127.0.0.1" port = 3000 cors_allowed_origins = ["http://localhost:4321"] [blockchain] -website_url = "http://localhost:3080" - # Quantus node WebSocket URL - change to your node's address node_url = "ws://127.0.0.1:9944" @@ -23,30 +20,9 @@ wallet_password = "change_me_to_secure_password_123" reversal_period_hours = 12 [candidates] -# GraphQL endpoint that provides candidate addresses -# Expected query: { candidates } -# Expected response: { "data": { "candidates": ["qz...", "qz..."] } } +# GraphQL endpoint used by --sync-transfers graphql_url = "http://localhost:4000/graphql" -# How often to refresh the candidates list (in minutes) -refresh_interval_minutes = 30 - -[task_generation] -# How often to generate new tasks (in minutes) -# Default: every hour -generation_interval_minutes = 60 - -# Number of taskees to select each round -taskees_per_round = 5 - -[reverser] -# How many minutes before end_time to reverse uncompleted tasks -# This provides a buffer for task completion -early_reversal_minutes = 2 - -# How often to check for tasks that need reversal (in seconds) -check_interval_seconds = 30 - [data] # Database configuration database_url = "sqlite:taskmaster.db" diff --git a/config/test.toml b/config/test.toml index 4bcdb7c..7248e69 100644 --- a/config/test.toml +++ b/config/test.toml @@ -1,14 +1,11 @@ # TaskMaster Configuration [server] -base_api_url = "http://127.0.0.1:3000/api" host = "127.0.0.1" port = 3000 cors_allowed_origins = ["http://localhost:4321"] [blockchain] -website_url = "http://127.0.0.1:3080" - # Quantus node WebSocket URL (use local dev chain by default for testing) node_url = "ws://127.0.0.1:9944" @@ -22,20 +19,6 @@ reversal_period_hours = 12 [candidates] # GraphQL endpoint to fetch candidate addresses (local/dev default) graphql_url = "http://127.0.0.1:4000/graphql" -# How often to refresh candidates (in minutes) -refresh_interval_minutes = 30 - -[task_generation] -# How often to generate new tasks (in minutes) -generation_interval_minutes = 60 -# Number of taskees to select each round -taskees_per_round = 5 - -[reverser] -# How many minutes before end_time to reverse uncompleted tasks -early_reversal_minutes = 2 -# How often to check for tasks to reverse (in seconds) -check_interval_seconds = 30 [data] # Database configuration diff --git a/src/args.rs b/src/args.rs index 8e91d68..5683fa0 100644 --- a/src/args.rs +++ b/src/args.rs @@ -20,27 +20,7 @@ pub struct Args { #[arg(long)] pub node_url: Option, - /// Run once and exit (for testing) - #[arg(long)] - pub run_once: bool, - /// Sync transfers from GraphQL and store addresses #[arg(long)] pub sync_transfers: bool, - - /// Test address selection from database - #[arg(long)] - pub test_selection: bool, - - /// Test sending a reversible transaction - #[arg(long)] - pub test_transaction: bool, - - /// Destination address for test transaction - #[arg(long, requires = "test_transaction")] - pub destination: Option, - - /// Amount for test transaction (in QUAN units) - #[arg(long, requires = "test_transaction")] - pub amount: Option, } diff --git a/src/config.rs b/src/config.rs index dfdbc66..e752c1c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -32,13 +32,11 @@ pub struct RemoteConfigsConfig { pub struct ServerConfig { pub host: String, pub port: u16, - pub base_api_url: String, pub cors_allowed_origins: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BlockchainConfig { - pub website_url: String, pub node_url: String, pub wallet_name: String, pub wallet_password: String, @@ -48,7 +46,6 @@ pub struct BlockchainConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CandidatesConfig { pub graphql_url: String, - pub refresh_interval_minutes: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -195,11 +192,9 @@ impl Default for Config { server: ServerConfig { host: "127.0.0.1".to_string(), port: 3000, - base_api_url: "http://127.0.0.1:3000/api".to_string(), cors_allowed_origins: vec!["http://localhost:3000".to_string()], }, blockchain: BlockchainConfig { - website_url: "https://www.quantus.com".to_string(), node_url: "ws://127.0.0.1:9944".to_string(), wallet_name: "task_master_wallet".to_string(), wallet_password: "secure_password_change_me".to_string(), @@ -207,7 +202,6 @@ impl Default for Config { }, candidates: CandidatesConfig { graphql_url: "http://localhost:4000/graphql".to_string(), - refresh_interval_minutes: 30, }, data: DataConfig { database_url: "postgres://postgres:postgres@127.0.0.1:5432/task_master".to_string(), diff --git a/src/main.rs b/src/main.rs index 7e77639..97b38b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -100,12 +100,6 @@ async fn main() -> AppResult<()> { info!("🎯 TaskMaster is now running!"); info!("HTTP API available at: http://{}", server_address); - info!( - "Candidates refresh interval: {} minutes", - config.candidates.refresh_interval_minutes - ); - info!("Reversal period: {} hours", config.blockchain.reversal_period_hours); - // Initialize tweet sync service let tweet_synchronizer = TweetSynchronizerService::new( db.clone(), diff --git a/src/repositories/address.rs b/src/repositories/address.rs index 6a21e11..e2cc4c3 100644 --- a/src/repositories/address.rs +++ b/src/repositories/address.rs @@ -177,7 +177,7 @@ impl AddressRepository { Ok(address) } - #[allow(dead_code)] + #[cfg(test)] pub async fn find_all(&self) -> DbResult> { let addresses = sqlx::query_as::<_, Address>("SELECT * FROM addresses") .fetch_all(&self.pool) From 2235d60d2a0fd43010e19587f0d124a71354b83e Mon Sep 17 00:00:00 2001 From: Beast Date: Sat, 13 Jun 2026 22:58:11 +0800 Subject: [PATCH 04/11] feat: cleanup dead codes 4th pass --- Cargo.toml | 2 +- config/default.toml | 11 ----------- config/example.toml | 14 -------------- config/test.toml | 11 ----------- src/args.rs | 14 +------------- src/config.rs | 15 --------------- src/db_persistence.rs | 1 + src/handlers/auth.rs | 1 - src/http_server.rs | 1 - src/lib.rs | 10 ++-------- src/main.rs | 15 +-------------- 11 files changed, 6 insertions(+), 89 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 265818a..7915bcb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] authors = ["TaskMaster Team"] default-run = "task-master" -description = "Task management server with reversible blockchain transactions" +description = "Task management server" edition = "2021" license = "Apache-2.0" name = "task-master" diff --git a/config/default.toml b/config/default.toml index 89320e8..a2127b4 100644 --- a/config/default.toml +++ b/config/default.toml @@ -5,17 +5,6 @@ host = "127.0.0.1" port = 3000 cors_allowed_origins = ["http://localhost:4321"] -[blockchain] -# Quantus node WebSocket URL -node_url = "wss://a1-dirac.quantus.cat" - -# Wallet configuration for transactions -wallet_name = "test_seed_wallet3" -wallet_password = "test123" - -# Reversal period for transactions (in hours) -reversal_period_hours = 12 - [candidates] # GraphQL endpoint to fetch candidate addresses graphql_url = "https://subsquid.quantus.com/graphql" diff --git a/config/example.toml b/config/example.toml index 7b40b48..9abbfaa 100644 --- a/config/example.toml +++ b/config/example.toml @@ -7,18 +7,6 @@ host = "127.0.0.1" port = 3000 cors_allowed_origins = ["http://localhost:4321"] -[blockchain] -# Quantus node WebSocket URL - change to your node's address -node_url = "ws://127.0.0.1:9944" - -# Wallet configuration for sending transactions -# IMPORTANT: Change these for production use! -wallet_name = "task_master_wallet" -wallet_password = "change_me_to_secure_password_123" - -# How long before transactions can be reversed (in hours) -reversal_period_hours = 12 - [candidates] # GraphQL endpoint used by --sync-transfers graphql_url = "http://localhost:4000/graphql" @@ -79,8 +67,6 @@ max_concurrent_requests = 1 api_key = "change-me" # Example environment variable overrides: -# TASKMASTER_BLOCKCHAIN__NODE_URL="ws://remote-node:9944" -# TASKMASTER_BLOCKCHAIN__WALLET_PASSWORD="super_secure_password" # TASKMASTER_CANDIDATES__GRAPHQL_URL="https://your-api.com/graphql" # TASKMASTER_DATA__DATABASE_URL="sqlite:/path/to/taskmaster.db" # TASKMASTER_LOGGING__LEVEL="debug" diff --git a/config/test.toml b/config/test.toml index 7248e69..24585ee 100644 --- a/config/test.toml +++ b/config/test.toml @@ -5,17 +5,6 @@ host = "127.0.0.1" port = 3000 cors_allowed_origins = ["http://localhost:4321"] -[blockchain] -# Quantus node WebSocket URL (use local dev chain by default for testing) -node_url = "ws://127.0.0.1:9944" - -# Wallet configuration for transactions -wallet_name = "test_seed_wallet3" -wallet_password = "test123" - -# Reversal period for transactions (in hours) -reversal_period_hours = 12 - [candidates] # GraphQL endpoint to fetch candidate addresses (local/dev default) graphql_url = "http://127.0.0.1:4000/graphql" diff --git a/src/args.rs b/src/args.rs index 5683fa0..a1799e5 100644 --- a/src/args.rs +++ b/src/args.rs @@ -2,24 +2,12 @@ use clap::Parser; #[derive(Parser, Debug)] #[command(name = "task-master")] -#[command(about = "Task management server with reversible blockchain transactions")] +#[command(about = "Task management server")] pub struct Args { /// Configuration file path #[arg(short, long, default_value = "config/default.toml")] pub config: String, - /// Wallet name override - #[arg(long)] - pub wallet_name: Option, - - /// Wallet password override - #[arg(long)] - pub wallet_password: Option, - - /// Node URL override - #[arg(long)] - pub node_url: Option, - /// Sync transfers from GraphQL and store addresses #[arg(long)] pub sync_transfers: bool, diff --git a/src/config.rs b/src/config.rs index e752c1c..1212904 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,7 +8,6 @@ use tokio::time; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { pub server: ServerConfig, - pub blockchain: BlockchainConfig, pub candidates: CandidatesConfig, pub data: DataConfig, pub logging: LoggingConfig, @@ -35,14 +34,6 @@ pub struct ServerConfig { pub cors_allowed_origins: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BlockchainConfig { - pub node_url: String, - pub wallet_name: String, - pub wallet_password: String, - pub reversal_period_hours: u64, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CandidatesConfig { pub graphql_url: String, @@ -194,12 +185,6 @@ impl Default for Config { port: 3000, cors_allowed_origins: vec!["http://localhost:3000".to_string()], }, - blockchain: BlockchainConfig { - node_url: "ws://127.0.0.1:9944".to_string(), - wallet_name: "task_master_wallet".to_string(), - wallet_password: "secure_password_change_me".to_string(), - reversal_period_hours: 12, - }, candidates: CandidatesConfig { graphql_url: "http://localhost:4000/graphql".to_string(), }, diff --git a/src/db_persistence.rs b/src/db_persistence.rs index 08757ef..afceeb2 100644 --- a/src/db_persistence.rs +++ b/src/db_persistence.rs @@ -36,6 +36,7 @@ pub struct DbPersistence { pub raid_leaderboards: RaidLeaderboardRepository, pub tweet_pull_usage: TweetPullUsageRepository, + /// Exposed for binaries (`create_admin`) and integration tests. #[allow(dead_code)] pub pool: PgPool, } diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index 3b98bc1..c83bea2 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -37,7 +37,6 @@ pub async fn request_challenge( let temp_session_id = Uuid::new_v4().to_string(); let challenge = Uuid::new_v4().to_string(); let entry = Challenge { - id: temp_session_id.clone(), challenge: challenge.clone(), created_at: Utc::now(), }; diff --git a/src/http_server.rs b/src/http_server.rs index 15f102d..c250795 100644 --- a/src/http_server.rs +++ b/src/http_server.rs @@ -35,7 +35,6 @@ pub struct AppState { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Challenge { - pub id: String, pub challenge: String, pub created_at: DateTime, } diff --git a/src/lib.rs b/src/lib.rs index aea43e6..f148a58 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,8 +2,7 @@ //! //! A reward management server that monitors social media interactions and //! integrates with the Quantus Network. This library provides the core -//! functionality for managing rewards, interacting with the blockchain, -//! and handling HTTP API requests. +//! functionality for managing rewards and handling HTTP API requests. pub mod args; pub mod config; @@ -21,14 +20,9 @@ pub mod utils; // Re-export commonly used types pub use config::Config; +pub use db_persistence::DbError; pub use errors::{AppError, AppResult}; pub use http_server::AppState; -pub use services::graphql_client::{GraphqlClient, Transfer}; - -// Re-export errors -pub use db_persistence::DbError; -pub use services::graphql_client::GraphqlError; -pub use services::signature_service::SigServiceError; /// Library version pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/src/main.rs b/src/main.rs index 97b38b9..f007fdf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,18 +37,7 @@ async fn main() -> AppResult<()> { let args = Args::parse(); // Load configuration from --config path (defaults to config/default.toml) - let mut config = Config::load(&args.config).map_err(AppError::Config)?; - - // Apply CLI overrides - if let Some(wallet_name) = args.wallet_name { - config.blockchain.wallet_name = wallet_name; - } - if let Some(wallet_password) = args.wallet_password { - config.blockchain.wallet_password = wallet_password; - } - if let Some(node_url) = args.node_url { - config.blockchain.node_url = node_url; - } + let config = Config::load(&args.config).map_err(AppError::Config)?; crypto::set_default_ss58_version(Ss58AddressFormat::custom(189)); // Initialize logging @@ -56,8 +45,6 @@ async fn main() -> AppResult<()> { info!("🚀 Starting TaskMaster v{}", env!("CARGO_PKG_VERSION")); info!("Configuration loaded from: {}", args.config); - info!("Node URL: {}", config.blockchain.node_url); - info!("Wallet: {}", config.blockchain.wallet_name); // Initialize database persistence let db_url = config.get_database_url(); From fe799dad2446ed850e694e85b3b08a8298152398 Mon Sep 17 00:00:00 2001 From: Beast Date: Sun, 14 Jun 2026 00:02:21 +0800 Subject: [PATCH 05/11] feat: clean dead codes 5th pass --- Cargo.lock | 2 -- Cargo.toml | 3 --- src/config.rs | 1 - src/handlers/mod.rs | 2 +- src/handlers/referral.rs | 2 -- src/repositories/raid_quest.rs | 2 -- src/services/exchange_rate_service.rs | 2 -- 7 files changed, 1 insertion(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fc2267c..4759ff3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8568,7 +8568,6 @@ dependencies = [ "qp-human-checkphrase", "qp-rusty-crystals-dilithium", "quantus-cli", - "rand 0.9.2", "reqwest 0.11.27", "rusx", "serde", @@ -8578,7 +8577,6 @@ dependencies = [ "sqlx", "thiserror 2.0.18", "tokio", - "toml 0.9.8", "tower 0.4.13", "tower-cookies", "tower-http 0.5.2", diff --git a/Cargo.toml b/Cargo.toml index 7915bcb..f960fd8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,13 +57,10 @@ sp-runtime = "45.0.0" # Configuration clap = { version = "4.5", features = ["derive"] } config = "0.14" -toml = "0.9" # Time handling chrono = { version = "0.4", features = ["serde"] } -# Random generation -rand = "0.9" uuid = { version = "1.6", features = ["v4", "serde"] } # HTTP client for GraphQL diff --git a/src/config.rs b/src/config.rs index 1212904..7ca99ee 100644 --- a/src/config.rs +++ b/src/config.rs @@ -115,7 +115,6 @@ impl Config { #[cfg(test)] pub fn load_test_env() -> Result { - println!("Loading TEST configuration..."); // For demonstration let test_config_path = "config/test.toml"; let settings = config::Config::builder() // Load the test-specific configuration file diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 261a24d..b39e727 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -119,6 +119,6 @@ pub fn validate_pagination_query(page: u32, page_size: u32) -> Result<(), AppErr Ok(()) } -fn calculate_total_pages(page_size: u32, total_items: u32) -> u32 { +pub(crate) fn calculate_total_pages(page_size: u32, total_items: u32) -> u32 { ((total_items as f64) / (page_size as f64)).ceil() as u32 } diff --git a/src/handlers/referral.rs b/src/handlers/referral.rs index a311667..1572993 100644 --- a/src/handlers/referral.rs +++ b/src/handlers/referral.rs @@ -127,8 +127,6 @@ mod tests { // Act: Call the handler function directly. let result = handle_add_referral(State(state.clone()), Extension(auth_user), Json(input.clone())).await; - print!("result: {:?}", result); - // Assert: Check the handler's response. assert!(result.is_ok()); let response = result.unwrap(); diff --git a/src/repositories/raid_quest.rs b/src/repositories/raid_quest.rs index a253a91..bb395b0 100644 --- a/src/repositories/raid_quest.rs +++ b/src/repositories/raid_quest.rs @@ -59,8 +59,6 @@ impl RaidQuestRepository { params: &ListQueryParams, filters: &RaidQuestFilter, ) -> Result, DbError> { - // Select all tweet columns + author name/username - // We use aliases that match the TweetWithAuthor struct expectations let mut query_builder = QueryBuilder::new( r#" SELECT diff --git a/src/services/exchange_rate_service.rs b/src/services/exchange_rate_service.rs index 0e454c2..fc581e4 100644 --- a/src/services/exchange_rate_service.rs +++ b/src/services/exchange_rate_service.rs @@ -136,8 +136,6 @@ struct ExchangeRateApiV6Response { result: String, conversion_rates: Option>, time_next_update_unix: Option, - #[allow(dead_code)] - time_last_update_unix: Option, #[serde(rename = "error-type", default)] error_type: Option, } From 642434e01fbf83095a8546fb97b47c026aa0392f Mon Sep 17 00:00:00 2001 From: Beast Date: Sun, 14 Jun 2026 00:41:28 +0800 Subject: [PATCH 06/11] feat: clean dead codes 6th pass --- Cargo.lock | 29 ----------------------------- Cargo.toml | 1 - src/db_persistence.rs | 2 +- src/http_server.rs | 2 -- src/lib.rs | 31 ------------------------------- 5 files changed, 1 insertion(+), 64 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4759ff3..4316d11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2069,17 +2069,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -8578,7 +8567,6 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tower 0.4.13", - "tower-cookies", "tower-http 0.5.2", "tracing", "tracing-subscriber", @@ -8944,23 +8932,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "tower-cookies" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fd0118512cf0b3768f7fcccf0bef1ae41d68f2b45edc1e77432b36c97c56c6d" -dependencies = [ - "async-trait", - "axum-core", - "cookie", - "futures-util", - "http 1.3.1", - "parking_lot", - "pin-project-lite", - "tower-layer", - "tower-service", -] - [[package]] name = "tower-http" version = "0.5.2" diff --git a/Cargo.toml b/Cargo.toml index f960fd8..3d64d5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,6 @@ tokio = { version = "1.46", features = ["full", "test-util"] } # HTTP server axum = { version = "0.7", features = ["tokio"] } tower = "0.4" -tower-cookies = "0.10" tower-http = { version = "0.5", features = ["cors", "trace"] } # Serialization diff --git a/src/db_persistence.rs b/src/db_persistence.rs index afceeb2..35c68fb 100644 --- a/src/db_persistence.rs +++ b/src/db_persistence.rs @@ -36,7 +36,7 @@ pub struct DbPersistence { pub raid_leaderboards: RaidLeaderboardRepository, pub tweet_pull_usage: TweetPullUsageRepository, - /// Exposed for binaries (`create_admin`) and integration tests. + /// Used by the `create_admin` binary and integration tests (not the main server binary). #[allow(dead_code)] pub pool: PgPool, } diff --git a/src/http_server.rs b/src/http_server.rs index c250795..86c6f99 100644 --- a/src/http_server.rs +++ b/src/http_server.rs @@ -4,7 +4,6 @@ use rusx::TwitterGateway; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, sync::Arc}; use tower::ServiceBuilder; -use tower_cookies::CookieManagerLayer; use tower_http::{ cors::{AllowHeaders, CorsLayer}, trace::TraceLayer, @@ -63,7 +62,6 @@ pub fn create_router(state: AppState) -> Router { .allow_credentials(true), ), ) - .layer(CookieManagerLayer::new()) .with_state(state) } diff --git a/src/lib.rs b/src/lib.rs index f148a58..615b68a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,34 +23,3 @@ pub use config::Config; pub use db_persistence::DbError; pub use errors::{AppError, AppResult}; pub use http_server::AppState; - -/// Library version -pub const VERSION: &str = env!("CARGO_PKG_VERSION"); - -/// Library name -pub const NAME: &str = env!("CARGO_PKG_NAME"); - -/// Get the library version -pub fn version() -> &'static str { - VERSION -} - -/// Get the library name -pub fn name() -> &'static str { - NAME -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_version() { - assert!(!version().is_empty()); - } - - #[test] - fn test_name() { - assert_eq!(name(), "task-master"); - } -} From 73e1b3c43c0deda7a63ad0877994bfb1235190ec Mon Sep 17 00:00:00 2001 From: Beast Date: Sun, 14 Jun 2026 02:49:16 +0800 Subject: [PATCH 07/11] feat: dead codes cleaning 7th pass --- config/default.toml | 4 - config/example.toml | 4 - config/test.toml | 4 - src/config.rs | 19 - src/db_persistence.rs | 8 - src/lib.rs | 4 +- src/main.rs | 16 +- src/models/mod.rs | 1 - src/models/raid_submission.rs | 92 ---- src/repositories/mod.rs | 2 - src/repositories/raid_leaderboard.rs | 50 -- src/repositories/raid_submission.rs | 452 ------------------ src/repositories/relevant_tweet.rs | 2 + src/services/mod.rs | 1 - src/services/raid_leaderboard_service.rs | 575 ----------------------- src/utils/test_db.rs | 7 - 16 files changed, 8 insertions(+), 1233 deletions(-) delete mode 100644 src/models/raid_submission.rs delete mode 100644 src/repositories/raid_leaderboard.rs delete mode 100644 src/repositories/raid_submission.rs delete mode 100644 src/services/raid_leaderboard_service.rs diff --git a/config/default.toml b/config/default.toml index a2127b4..44cf630 100644 --- a/config/default.toml +++ b/config/default.toml @@ -42,10 +42,6 @@ chat_id = "-1" message_thread_id = "1" token = "token" -[raid_leaderboard] -sync_interval_in_hours = 24 -tweets_req_interval_in_secs = 60 - [alert] webhook_url = "https://www.webhook_url.com" diff --git a/config/example.toml b/config/example.toml index 9abbfaa..b6e9fc4 100644 --- a/config/example.toml +++ b/config/example.toml @@ -44,10 +44,6 @@ chat_id = '-1' message_thread_id = '1' token = "token" -[raid_leaderboard] -sync_interval_in_hours = 24 -tweets_req_interval_in_secs = 60 - [alert] webhook_url = "https://www.webhook_url.com" diff --git a/config/test.toml b/config/test.toml index 24585ee..655bdb8 100644 --- a/config/test.toml +++ b/config/test.toml @@ -42,10 +42,6 @@ chat_id = '-1' message_thread_id = '1' token = "token" -[raid_leaderboard] -sync_interval_in_hours = 24 -tweets_req_interval_in_secs = 1 - [alert] webhook_url = "https://www.webhook_url.com" diff --git a/src/config.rs b/src/config.rs index 7ca99ee..e377436 100644 --- a/src/config.rs +++ b/src/config.rs @@ -15,7 +15,6 @@ pub struct Config { pub x_oauth: OauthConfig, pub tweet_sync: TweetSyncConfig, pub tg_bot: TelegramBotConfig, - pub raid_leaderboard: RaidLeaderboardConfig, pub alert: AlertConfig, pub remote_configs: RemoteConfigsConfig, pub risk_checker: RiskCheckerConfig, @@ -74,12 +73,6 @@ pub struct TelegramBotConfig { pub message_thread_id: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RaidLeaderboardConfig { - pub sync_interval_in_hours: u64, - pub tweets_req_interval_in_secs: u64, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AlertConfig { pub webhook_url: String, @@ -144,14 +137,6 @@ impl Config { time::Duration::from_secs(self.tweet_sync.interval_in_hours * 3600) } - pub fn get_raid_leaderboard_sync_interval(&self) -> time::Duration { - time::Duration::from_secs(self.raid_leaderboard.sync_interval_in_hours * 3600) - } - - pub fn get_raid_leaderboard_tweets_req_interval(&self) -> time::Duration { - time::Duration::from_secs(self.raid_leaderboard.tweets_req_interval_in_secs) - } - pub fn get_cors_allowed_origins(&self) -> Vec { self.server .cors_allowed_origins @@ -217,10 +202,6 @@ impl Default for Config { message_thread_id: Some("-0".to_string()), token: "token".to_string(), }, - raid_leaderboard: RaidLeaderboardConfig { - sync_interval_in_hours: 24, - tweets_req_interval_in_secs: 60, - }, alert: AlertConfig { webhook_url: "https://your-webhook-url.com".to_string(), }, diff --git a/src/db_persistence.rs b/src/db_persistence.rs index 35c68fb..a265cc1 100644 --- a/src/db_persistence.rs +++ b/src/db_persistence.rs @@ -1,9 +1,7 @@ use sqlx::{postgres::PgPoolOptions, PgPool}; use crate::repositories::admin::AdminRepository; -use crate::repositories::raid_leaderboard::RaidLeaderboardRepository; use crate::repositories::raid_quest::RaidQuestRepository; -use crate::repositories::raid_submission::RaidSubmissionRepository; use crate::repositories::relevant_tweet::RelevantTweetRepository; use crate::repositories::tweet_author::TweetAuthorRepository; use crate::repositories::tweet_pull_usage::TweetPullUsageRepository; @@ -32,8 +30,6 @@ pub struct DbPersistence { pub relevant_tweets: RelevantTweetRepository, pub tweet_authors: TweetAuthorRepository, pub raid_quests: RaidQuestRepository, - pub raid_submissions: RaidSubmissionRepository, - pub raid_leaderboards: RaidLeaderboardRepository, pub tweet_pull_usage: TweetPullUsageRepository, /// Used by the `create_admin` binary and integration tests (not the main server binary). @@ -53,8 +49,6 @@ impl DbPersistence { let relevant_tweets = RelevantTweetRepository::new(&pool); let tweet_authors = TweetAuthorRepository::new(&pool); let raid_quests = RaidQuestRepository::new(&pool); - let raid_submissions = RaidSubmissionRepository::new(&pool); - let raid_leaderboards = RaidLeaderboardRepository::new(&pool); let tweet_pull_usage = TweetPullUsageRepository::new(pool.clone()); Ok(Self { @@ -65,8 +59,6 @@ impl DbPersistence { relevant_tweets, tweet_authors, raid_quests, - raid_submissions, - raid_leaderboards, tweet_pull_usage, }) } diff --git a/src/lib.rs b/src/lib.rs index 615b68a..826f88d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,8 @@ //! # TaskMaster Library //! -//! A reward management server that monitors social media interactions and +//! A task management server that monitors social media interactions and //! integrates with the Quantus Network. This library provides the core -//! functionality for managing rewards and handling HTTP API requests. +//! functionality for handling HTTP API requests. pub mod args; pub mod config; diff --git a/src/main.rs b/src/main.rs index f007fdf..69758dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,8 +3,8 @@ use crate::{ db_persistence::DbPersistence, errors::{AppError, AppResult}, services::{ - alert_service::AlertService, graphql_client::GraphqlClient, raid_leaderboard_service::RaidLeaderboardService, - telegram_service::TelegramService, tweet_synchronizer_service::TweetSynchronizerService, + alert_service::AlertService, graphql_client::GraphqlClient, telegram_service::TelegramService, + tweet_synchronizer_service::TweetSynchronizerService, }, }; @@ -80,8 +80,8 @@ async fn main() -> AppResult<()> { let server_twitter_gateway = twitter_gateway.clone(); let server_task = tokio::spawn(async move { http_server::start_server(server_db, server_twitter_gateway, &server_addr_clone, server_config) - .await - .map_err(|e| AppError::Server(e.to_string())) + .await + .map_err(|e| AppError::Server(e.to_string())) }); info!("🎯 TaskMaster is now running!"); @@ -96,10 +96,6 @@ async fn main() -> AppResult<()> { Arc::new(config.clone()), ); - // Initialize raid leaderboard service - let raid_leaderboard_service = - RaidLeaderboardService::new(db.clone(), twitter_gateway, alert_service, Arc::new(config.clone())); - // Wait for any task to complete (they should run forever unless there's an error) tokio::select! { result = server_task => { @@ -110,10 +106,6 @@ async fn main() -> AppResult<()> { error!("Tweet synchronizer exited: {:?}", result); result??; } - result = raid_leaderboard_service.spawn_raid_leaderboard_synchronizer() => { - error!("Raid leaderboard synchronizer exited: {:?}", result); - result??; - } } Ok(()) diff --git a/src/models/mod.rs b/src/models/mod.rs index c2aa00f..4b5a4cf 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -12,7 +12,6 @@ pub mod address; pub mod admin; pub mod auth; pub mod raid_quest; -pub mod raid_submission; pub mod referrals; pub mod relevant_tweet; pub mod tweet_author; diff --git a/src/models/raid_submission.rs b/src/models/raid_submission.rs deleted file mode 100644 index b563d02..0000000 --- a/src/models/raid_submission.rs +++ /dev/null @@ -1,92 +0,0 @@ -use rusx::resources::tweet::{Tweet as TwitterTweet, TweetPublicMetrics}; -use serde::{Deserialize, Serialize}; - -#[cfg(test)] -use chrono::{DateTime, Utc}; -#[cfg(test)] -use sqlx::{postgres::PgRow, FromRow, Row}; - -#[cfg(test)] -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct RaidSubmission { - pub id: String, - pub raid_id: i32, - pub target_id: Option, - pub raider_id: String, - pub impression_count: i32, - pub reply_count: i32, - pub retweet_count: i32, - pub like_count: i32, - pub is_invalid: bool, - pub updated_at: DateTime, - pub created_at: DateTime, -} - -#[cfg(test)] -impl<'r> FromRow<'r, PgRow> for RaidSubmission { - fn from_row(row: &'r PgRow) -> Result { - let id = row.try_get("id")?; - let raid_id = row.try_get("raid_id")?; - let target_id = row.try_get("target_id")?; - let raider_id = row.try_get("raider_id")?; - let impression_count: Option = row.try_get("impression_count")?; - let reply_count: Option = row.try_get("reply_count")?; - let retweet_count: Option = row.try_get("retweet_count")?; - let like_count: Option = row.try_get("like_count")?; - let is_invalid = row.try_get("is_invalid")?; - let updated_at = row.try_get("updated_at")?; - let created_at = row.try_get("created_at")?; - - Ok(RaidSubmission { - id, - raid_id, - target_id, - raider_id, - impression_count: impression_count.unwrap_or(0), - reply_count: reply_count.unwrap_or(0), - retweet_count: retweet_count.unwrap_or(0), - like_count: like_count.unwrap_or(0), - is_invalid, - updated_at, - created_at, - }) - } -} - -#[cfg(test)] -#[derive(Debug, Deserialize, Serialize)] -pub struct CreateRaidSubmission { - pub id: String, - pub raid_id: i32, - pub raider_id: String, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct UpdateRaidSubmissionStats { - pub id: String, - pub impression_count: i32, - pub reply_count: i32, - pub retweet_count: i32, - pub like_count: i32, -} - -impl From<&TwitterTweet> for UpdateRaidSubmissionStats { - fn from(tweet: &TwitterTweet) -> Self { - let default_metrics = TweetPublicMetrics::default(); - let public_metrics = tweet.public_metrics.as_ref().unwrap_or(&default_metrics); - - UpdateRaidSubmissionStats { - id: tweet.id.clone(), - impression_count: public_metrics.impression_count as i32, - like_count: public_metrics.like_count as i32, - retweet_count: public_metrics.retweet_count as i32, - reply_count: public_metrics.reply_count as i32, - } - } -} - -#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] -pub struct ValidRaidSubmissionWithRaiderUsername { - pub raid_submission_id: String, - pub raider_username: String, -} diff --git a/src/repositories/mod.rs b/src/repositories/mod.rs index 2608128..d5e7ee9 100644 --- a/src/repositories/mod.rs +++ b/src/repositories/mod.rs @@ -6,9 +6,7 @@ pub type DbResult = Result; pub mod address; pub mod admin; -pub mod raid_leaderboard; pub mod raid_quest; -pub mod raid_submission; pub mod referral; pub mod relevant_tweet; pub mod tweet_author; diff --git a/src/repositories/raid_leaderboard.rs b/src/repositories/raid_leaderboard.rs deleted file mode 100644 index ac7a421..0000000 --- a/src/repositories/raid_leaderboard.rs +++ /dev/null @@ -1,50 +0,0 @@ -use crate::repositories::DbResult; -use sqlx::PgPool; - -#[derive(Clone, Debug)] -pub struct RaidLeaderboardRepository { - pool: PgPool, -} - -impl RaidLeaderboardRepository { - pub fn new(pool: &PgPool) -> Self { - Self { pool: pool.clone() } - } - - pub async fn refresh(&self) -> DbResult<()> { - sqlx::query("REFRESH MATERIALIZED VIEW CONCURRENTLY raid_leaderboards") - .execute(&self.pool) - .await?; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{config::Config, utils::test_db::reset_database}; - use sqlx::PgPool; - - async fn setup_test_repository() -> RaidLeaderboardRepository { - let config = Config::load_test_env().expect("Failed to load configuration for tests"); - let pool = PgPool::connect(config.get_database_url()) - .await - .expect("Failed to create pool."); - - reset_database(&pool).await; - - RaidLeaderboardRepository::new(&pool) - } - - #[tokio::test] - async fn test_refresh_materialized_view() { - let repo = setup_test_repository().await; - - sqlx::query("INSERT INTO raid_quests (name, start_date) VALUES ('Refresh Raid', NOW())") - .execute(&repo.pool) - .await - .unwrap(); - - repo.refresh().await.expect("Failed to refresh view"); - } -} diff --git a/src/repositories/raid_submission.rs b/src/repositories/raid_submission.rs deleted file mode 100644 index deb425c..0000000 --- a/src/repositories/raid_submission.rs +++ /dev/null @@ -1,452 +0,0 @@ -use sqlx::{PgPool, QueryBuilder}; - -use crate::{ - models::raid_submission::{UpdateRaidSubmissionStats, ValidRaidSubmissionWithRaiderUsername}, - repositories::DbResult, -}; - -#[cfg(test)] -use crate::{ - db_persistence::DbError, - models::raid_submission::{CreateRaidSubmission, RaidSubmission}, -}; -#[cfg(test)] -use sqlx::Postgres; - -#[derive(Clone, Debug)] -pub struct RaidSubmissionRepository { - pool: PgPool, -} - -impl RaidSubmissionRepository { - pub fn new(pool: &PgPool) -> Self { - Self { pool: pool.clone() } - } - - #[cfg(test)] - fn create_select_base_query<'a>() -> QueryBuilder<'a, Postgres> { - QueryBuilder::new("SELECT * FROM raid_submissions") - } - - #[cfg(test)] - pub async fn create(&self, submission: &CreateRaidSubmission) -> DbResult { - let created_id = sqlx::query_scalar::<_, String>( - " - INSERT INTO raid_submissions ( - id, raid_id, raider_id - ) - VALUES ($1, $2, $3) - RETURNING id - ", - ) - .bind(&submission.id) - .bind(submission.raid_id) - .bind(&submission.raider_id) - .fetch_optional(&self.pool) - .await?; - - if let Some(id) = created_id { - Ok(id) - } else { - Err(DbError::RecordNotFound("Failed to retrieve generated ID".to_string())) - } - } - - #[cfg(test)] - pub async fn delete(&self, submission_id: &str) -> DbResult<()> { - sqlx::query("DELETE FROM raid_submissions WHERE id = $1") - .bind(submission_id) - .execute(&self.pool) - .await?; - - Ok(()) - } - - #[cfg(test)] - pub async fn find_by_id(&self, id: &str) -> DbResult> { - let mut qb = Self::create_select_base_query(); - qb.push(" WHERE id = "); - qb.push_bind(id); - - let submission = qb.build_query_as().fetch_optional(&self.pool).await?; - - Ok(submission) - } - - pub async fn find_valid_only_by_raid(&self, raid_id: i32) -> DbResult> { - let mut qb = QueryBuilder::new("SELECT rs.id as raid_submission_id, x.username as raider_username FROM raid_submissions rs INNER JOIN x_associations x ON rs.raider_id = x.quan_address"); - qb.push(" WHERE rs.raid_id = "); - qb.push_bind(raid_id); - qb.push(" AND NOT rs.is_invalid"); - qb.push(" ORDER BY rs.created_at DESC"); - - let submissions = qb.build_query_as().fetch_all(&self.pool).await?; - - Ok(submissions) - } - - pub async fn update_stats_many(&self, updates: &[UpdateRaidSubmissionStats]) -> DbResult { - if updates.is_empty() { - return Ok(0); - } - - let mut ids = Vec::with_capacity(updates.len()); - let mut impressions = Vec::with_capacity(updates.len()); - let mut replies = Vec::with_capacity(updates.len()); - let mut retweets = Vec::with_capacity(updates.len()); - let mut likes = Vec::with_capacity(updates.len()); - - for u in updates { - ids.push(u.id.clone()); - impressions.push(u.impression_count); - replies.push(u.reply_count); - retweets.push(u.retweet_count); - likes.push(u.like_count); - } - - let query = " - UPDATE raid_submissions AS rs - SET - impression_count = data.impression_count, - reply_count = data.reply_count, - retweet_count = data.retweet_count, - like_count = data.like_count, - updated_at = NOW() - FROM ( - SELECT * FROM UNNEST( - $1::varchar[], - $2::int[], - $3::int[], - $4::int[], - $5::int[] - ) AS t(id, impression_count, reply_count, retweet_count, like_count) - ) AS data - WHERE rs.id = data.id - "; - - let result = sqlx::query(query) - .bind(&ids) - .bind(&impressions) - .bind(&replies) - .bind(&retweets) - .bind(&likes) - .execute(&self.pool) - .await?; - - Ok(result.rows_affected()) - } - - pub async fn update_as_invalid_many(&self, ids: &[&str]) -> DbResult { - if ids.is_empty() { - return Ok(0); - } - - let query = " - UPDATE raid_submissions - SET - is_invalid = true, - updated_at = NOW() - WHERE id = ANY($1::varchar[]); - "; - - let result = sqlx::query(query).bind(ids).execute(&self.pool).await?; - - Ok(result.rows_affected()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - use crate::utils::test_db::reset_database; - use chrono::Utc; - use sqlx::PgPool; - use uuid::Uuid; - - // ------------------------------------------------------------------------- - // Setup & Helpers - // ------------------------------------------------------------------------- - - async fn setup_test_repository() -> RaidSubmissionRepository { - let config = Config::load_test_env().expect("Failed to load configuration for tests"); - let pool = PgPool::connect(config.get_database_url()) - .await - .expect("Failed to create pool."); - - // Clean database before each test to ensure empty state - reset_database(&pool).await; - - RaidSubmissionRepository::new(&pool) - } - - struct SeedData { - raid_id: i32, - raider_id: String, - } - - // Helper to satisfy the strict Foreign Key chain: - // 1. Address (Raider) - // 2. Raid Quest - // 3. Tweet Author (Required by Relevant Tweet) - // 4. Relevant Tweet (Target) - async fn seed_dependencies(pool: &PgPool) -> SeedData { - // 1. Seed Address (Raider) - let raider_id = "qz_test_raider_01".to_string(); - sqlx::query("INSERT INTO addresses (quan_address, referral_code) VALUES ($1, 'REF01')") - .bind(&raider_id) - .execute(pool) - .await - .expect("Failed to seed address"); - - // 2. Seed Raid Quest - // Note: RaidQuests has 'enforce_one_active_raid' constraint, but since we reset DB, this is safe. - let raid_id: i32 = - sqlx::query_scalar("INSERT INTO raid_quests (name, start_date) VALUES ('Test Raid', NOW()) RETURNING id") - .fetch_one(pool) - .await - .expect("Failed to seed raid quest"); - - // 3. Seed Tweet Author (REQUIRED because relevant_tweets references tweet_authors) - let author_id = "author_123".to_string(); - sqlx::query("INSERT INTO tweet_authors (id, name, username) VALUES ($1, 'Test Author', 'test_author')") - .bind(&author_id) - .execute(pool) - .await - .expect("Failed to seed tweet author"); - - // 4. Seed Relevant Tweet (Target) - // Note: created_at is NOT NULL in migration 005 - let target_id = "tweet_target_1".to_string(); - sqlx::query( - "INSERT INTO relevant_tweets (id, author_id, text, created_at) VALUES ($1, $2, 'Target Tweet', $3)", - ) - .bind(&target_id) - .bind(&author_id) - .bind(Utc::now()) - .execute(pool) - .await - .expect("Failed to seed relevant tweet"); - - SeedData { raid_id, raider_id } - } - - fn create_mock_submission_input(seed: &SeedData) -> CreateRaidSubmission { - CreateRaidSubmission { - id: Uuid::new_v4().to_string(), - raid_id: seed.raid_id, - raider_id: seed.raider_id.clone(), - } - } - - // ------------------------------------------------------------------------- - // Tests - // ------------------------------------------------------------------------- - - #[tokio::test] - async fn test_create_and_find_by_id() { - let repo = setup_test_repository().await; - let seed = seed_dependencies(&repo.pool).await; - - let input = create_mock_submission_input(&seed); - - // 1. Create - let created_id = repo.create(&input).await.expect("Failed to create submission"); - assert_eq!(created_id, input.id); - - // 2. Find - let found = repo - .find_by_id(&created_id) - .await - .expect("Failed to query") - .expect("Submission not found"); - - assert_eq!(found.id, input.id); - assert_eq!(found.raid_id, seed.raid_id); - assert_eq!(found.raider_id, seed.raider_id); - assert_eq!(found.impression_count, 0); - assert_eq!(found.like_count, 0); - } - - #[tokio::test] - async fn test_create_and_delete_by_id() { - let repo = setup_test_repository().await; - let seed = seed_dependencies(&repo.pool).await; - - let input = create_mock_submission_input(&seed); - - // 1. Create - let created_id = repo.create(&input).await.expect("Failed to create submission"); - assert_eq!(created_id, input.id); - - // 2. Delete - repo.delete(&created_id).await.unwrap(); - - // 3. Find - let found = repo.find_by_id(&created_id).await.expect("Failed to query"); - - assert!(found.is_none()); - } - - #[tokio::test] - async fn test_find_valid_only_by_raid_sorting() { - let repo = setup_test_repository().await; - let seed = seed_dependencies(&repo.pool).await; - - // Seed x_association for the raider so the query can retrieve the username - let x_username = "test_raider_username"; - sqlx::query("INSERT INTO x_associations (quan_address, username) VALUES ($1, $2)") - .bind(&seed.raider_id) - .bind(x_username) - .execute(&repo.pool) - .await - .expect("Failed to seed x_association"); - - // Create 3 submissions with slight delays to ensure distinct created_at timestamps - let sub1 = create_mock_submission_input(&seed); - repo.create(&sub1).await.unwrap(); - tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; - - let sub2 = create_mock_submission_input(&seed); - repo.create(&sub2).await.unwrap(); - tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; - - let sub3 = create_mock_submission_input(&seed); - repo.create(&sub3).await.unwrap(); - - // Query by Raid ID - let results = repo.find_valid_only_by_raid(seed.raid_id).await.unwrap(); - - assert_eq!(results.len(), 3); - - // Verify Sorting: Query uses "ORDER BY rs.created_at DESC" - // So sub3 (newest) should be first - assert_eq!( - results[0].raid_submission_id, sub3.id, - "Newest submission should be first" - ); - assert_eq!(results[1].raid_submission_id, sub2.id); - assert_eq!( - results[2].raid_submission_id, sub1.id, - "Oldest submission should be last" - ); - - // Verify that usernames are correctly retrieved - assert_eq!(results[0].raider_username, x_username); - assert_eq!(results[1].raider_username, x_username); - assert_eq!(results[2].raider_username, x_username); - } - - #[tokio::test] - async fn test_update_stats_success() { - let repo = setup_test_repository().await; - let seed = seed_dependencies(&repo.pool).await; - let input = create_mock_submission_input(&seed); - - repo.create(&input).await.unwrap(); - - // 1. Prepare the Update Payload - let updates = vec![UpdateRaidSubmissionStats { - id: input.id.clone(), - impression_count: 100, - reply_count: 5, - retweet_count: 10, - like_count: 50, - }]; - - // 2. Execute Bulk Update - let rows_affected = repo.update_stats_many(&updates).await.expect("Failed to update stats"); - - assert_eq!(rows_affected, 1, "Should have updated exactly 1 record"); - - // 3. Verify Update in DB - let updated = repo.find_by_id(&input.id).await.unwrap().unwrap(); - - assert_eq!(updated.impression_count, 100); - assert_eq!(updated.reply_count, 5); - assert_eq!(updated.retweet_count, 10); - assert_eq!(updated.like_count, 50); - - // 4. Verify `updated_at` trigger worked - assert!( - updated.updated_at > updated.created_at, - "updated_at timestamp was not refreshed" - ); - } - - #[tokio::test] - async fn test_update_invalid_many_success() { - let repo = setup_test_repository().await; - let seed = seed_dependencies(&repo.pool).await; - let input = create_mock_submission_input(&seed); - - repo.create(&input).await.unwrap(); - - // 1. Prepare the Update Payload - let ids: &[&str] = &[&input.id]; - - // 2. Execute Bulk Update - let rows_affected = repo - .update_as_invalid_many(ids) - .await - .expect("Failed to update status to invalid"); - - assert_eq!(rows_affected, 1, "Should have updated exactly 1 record"); - - // 3. Verify Update in DB - let updated = repo.find_by_id(&input.id).await.unwrap().unwrap(); - - assert!(updated.is_invalid); - - // 4. Verify `updated_at` trigger worked - assert!( - updated.updated_at > updated.created_at, - "updated_at timestamp was not refreshed" - ); - } - - #[tokio::test] - async fn test_update_stats_not_found() { - let repo = setup_test_repository().await; - - // Try to update a random ID that doesn't exist - let updates = vec![UpdateRaidSubmissionStats { - id: "non-existent-id".to_string(), - impression_count: 10, - reply_count: 0, - retweet_count: 0, - like_count: 0, - }]; - - // In bulk operations, a missing ID usually results in 0 rows affected, - // rather than an Error, because it's a set-based operation. - let result = repo.update_stats_many(&updates).await; - - assert!(result.is_ok()); - assert_eq!(result.unwrap(), 0, "Should return 0 affected rows for non-existent ID"); - } - - #[tokio::test] - async fn test_create_fails_without_dependencies() { - let repo = setup_test_repository().await; - // We do NOT call seed_dependencies here - - let input = CreateRaidSubmission { - id: Uuid::new_v4().to_string(), - raid_id: 9999, // Non-existent Raid - raider_id: "fake_user".to_string(), - }; - - let result = repo.create(&input).await; - - // This should fail with a Foreign Key violation - assert!(result.is_err()); - - // Verify it is a Database error (SQL State 23503 is foreign_key_violation) - if let DbError::Database(sqlx::Error::Database(e)) = result.unwrap_err() { - assert_eq!(e.code().unwrap(), "23503"); - } else { - // It might be wrapped differently depending on your DbError impl, but valid failure is enough - } - } -} diff --git a/src/repositories/relevant_tweet.rs b/src/repositories/relevant_tweet.rs index b1676bf..a8a4249 100644 --- a/src/repositories/relevant_tweet.rs +++ b/src/repositories/relevant_tweet.rs @@ -1,3 +1,4 @@ +#[cfg(test)] use std::collections::HashSet; use sqlx::{PgPool, Postgres, QueryBuilder, Row}; @@ -221,6 +222,7 @@ impl RelevantTweetRepository { Ok(tweet) } + #[cfg(test)] pub async fn get_existing_ids(&self, ids: &[String]) -> DbResult> { if ids.is_empty() { return Ok(HashSet::new()); diff --git a/src/services/mod.rs b/src/services/mod.rs index 15d2080..558ccc5 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,7 +1,6 @@ pub mod alert_service; pub mod exchange_rate_service; pub mod graphql_client; -pub mod raid_leaderboard_service; pub mod risk_checker_service; pub mod signature_service; pub mod telegram_service; diff --git a/src/services/raid_leaderboard_service.rs b/src/services/raid_leaderboard_service.rs deleted file mode 100644 index 9dadbe8..0000000 --- a/src/services/raid_leaderboard_service.rs +++ /dev/null @@ -1,575 +0,0 @@ -use std::{ - collections::{HashMap, HashSet}, - sync::Arc, -}; - -use rusx::{ - resources::{tweet::TweetParams, TweetExpansion, TweetField}, - TwitterGateway, -}; - -use crate::{ - db_persistence::DbPersistence, - metrics::{track_tweets_pulled, track_twitter_api_call}, - models::raid_submission::{UpdateRaidSubmissionStats, ValidRaidSubmissionWithRaiderUsername}, - services::alert_service::AlertService, - AppError, AppResult, Config, -}; - -#[derive(Clone)] -pub struct RaidLeaderboardService { - db: Arc, - twitter_gateway: Arc, - alert_service: Arc, - config: Arc, -} - -impl RaidLeaderboardService { - fn build_batched_tweet_queries(submissions: &[ValidRaidSubmissionWithRaiderUsername]) -> Vec> { - // Twitter's limit for the get ids result - const TWEET_GET_MAX_IDS: usize = 100; - - submissions - .chunks(TWEET_GET_MAX_IDS) - .map(|chunk| chunk.iter().map(|s| s.raid_submission_id.clone()).collect()) - .collect() - } - - pub fn new( - db: Arc, - twitter_gateway: Arc, - alert_service: Arc, - config: Arc, - ) -> Self { - Self { - db, - twitter_gateway, - alert_service, - config, - } - } - - pub fn spawn_raid_leaderboard_synchronizer(&self) -> tokio::task::JoinHandle> { - let service = self.clone(); - - tokio::spawn(async move { - let mut interval = tokio::time::interval(service.config.get_raid_leaderboard_sync_interval()); - - loop { - interval.tick().await; - tracing::info!("🔄 Background Worker: Starting Raid Leaderboard Sync..."); - - match service.sync_raid_leaderboard().await { - Ok(_) => { - tracing::info!("✅ Sync Complete. Refreshing raid leaderboard material view"); - } - Err(e) => tracing::error!("❌ Sync Failed: {:?}", e), - } - } - }) - } - - pub async fn sync_raid_leaderboard(&self) -> Result<(), AppError> { - let active_raid_quest = self.db.raid_quests.find_active().await?; - - let Some(active_raid_quest) = active_raid_quest else { - tracing::info!("No active raid quest at the moment, exiting..."); - return Ok(()); - }; - - let raid_submissions = self - .db - .raid_submissions - .find_valid_only_by_raid(active_raid_quest.id) - .await?; - if raid_submissions.is_empty() { - tracing::info!("No raid submissions found yet for current active raid quest."); - return Ok(()); - }; - - let queries = RaidLeaderboardService::build_batched_tweet_queries(&raid_submissions); - let submission_to_x_username_map: HashMap = raid_submissions - .into_iter() - .map(|s| (s.raid_submission_id, s.raider_username)) - .collect(); - - let mut params = TweetParams::new(); - params.tweet_fields = Some(vec![ - TweetField::PublicMetrics, - TweetField::CreatedAt, - TweetField::AuthorId, - TweetField::InReplyToUserId, - TweetField::ReferencedTweets, - ]); - params.expansions = Some(vec![TweetExpansion::AuthorId]); - - // X Api Request Limit: 15 requests / 15 mins. - // We set interval to 1 min (~1 req/min) to be safe. - let mut rate_limiter = tokio::time::interval(self.config.get_raid_leaderboard_tweets_req_interval()); - - for query in queries { - rate_limiter.tick().await; - - // Track Twitter API call with metrics - let response = track_twitter_api_call("tweets_get_many", async { - self.twitter_gateway - .tweets() - .get_many(query, Some(params.clone())) - .await - }) - .await?; - - let Some(tweets) = &response.data else { - tracing::info!("No tweets found!."); - continue; - }; - let Some(includes) = &response.includes else { - tracing::info!("No includes found!."); - continue; - }; - let Some(users) = &includes.users else { - tracing::info!("No users found!."); - continue; - }; - - let user_id_to_username_map: HashMap = - users.iter().map(|u| (u.id.clone(), u.username.clone())).collect(); - - // Track Twitter API usage (for alerting) - let tweets_pulled = tweets.len(); - self.alert_service.track_and_alert_usage(tweets_pulled as i32).await?; - - // Track metrics for tweets pulled - track_tweets_pulled("tweets_get_many", tweets_pulled); - - // EXTRACT: Collect all referenced IDs from the fetched tweets - // We use a HashSet immediately to remove duplicates before sending to DB - let referenced_ids: Vec = tweets - .iter() - .filter_map(|t| t.referenced_tweets.as_ref()) // Get Option> - .flatten() // Flatten Vec - .map(|r| r.id.clone()) // Extract ID - .collect::>() // Deduplicate - .into_iter() - .collect(); - - let valid_raid_ids: HashSet = self.db.relevant_tweets.get_existing_ids(&referenced_ids).await?; - - let mut valid_tweets = Vec::new(); - let mut invalid_tweets = Vec::new(); - - // `for tweet in tweets` consumes the original vector, so we "move" - // the data instead of cloning it. - for tweet in tweets { - let is_valid_reply = tweet.referenced_tweets.as_ref().is_some_and(|refs| { - // Check if ANY of the referenced IDs exist in our valid set - refs.iter().any(|r| valid_raid_ids.contains(&r.id)) - }); - let is_eligible_owner = match ( - tweet.author_id.as_ref().and_then(|id| user_id_to_username_map.get(id)), - submission_to_x_username_map.get(&tweet.id), - ) { - (Some(author), Some(expected)) => author.eq_ignore_ascii_case(expected), - _ => false, - }; - - if is_valid_reply && is_eligible_owner { - valid_tweets.push(tweet); - } else { - invalid_tweets.push(tweet); - } - } - - if !valid_tweets.is_empty() { - let updates: Vec = - valid_tweets.into_iter().map(UpdateRaidSubmissionStats::from).collect(); - - self.db.raid_submissions.update_stats_many(&updates).await?; - } - - if !invalid_tweets.is_empty() { - let flags: Vec<&str> = invalid_tweets.iter().map(|t| t.id.as_str()).collect(); - - self.db.raid_submissions.update_as_invalid_many(&flags).await?; - - tracing::info!("Flagged {} invalid tweets", invalid_tweets.len()); - } - } - - // We immediately refresh leaderboard after finish syncing stats - self.db.raid_leaderboards.refresh().await?; - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - use crate::models::raid_quest::CreateRaidQuest; - use crate::utils::test_db::reset_database; - use mockall::predicate::*; - use mockall::*; - use rusx::{ - resources::{ - tweet::{ReferenceType, ReferencedTweet, Tweet, TweetApi, TweetPublicMetrics}, - user::User, - Includes, TwitterApiResponse, - }, - MockTweetApi, MockTwitterGateway, - }; - use sqlx::PgPool; - use std::sync::Arc; - - // ------------------------------------------------------------------------- - // Setup & Helpers - // ------------------------------------------------------------------------- - - async fn setup_deps() -> (Arc, Arc, Arc) { - let config = Config::load_test_env().expect("Failed to load test config"); - let pool = PgPool::connect(config.get_database_url()).await.unwrap(); - reset_database(&pool).await; - let db = Arc::new(DbPersistence::new(config.get_database_url()).await.unwrap()); - let app_config = Arc::new(config.clone()); - let alert_service = Arc::new(AlertService::new(config, db.tweet_pull_usage.clone())); - (db, alert_service, app_config) - } - - fn create_mock_tweet(id: &str, target_id: String, author_id: String, impressions: u32, likes: u32) -> Tweet { - Tweet { - id: id.to_string(), - text: "Raid content".to_string(), - author_id: Some(author_id), - created_at: Some(chrono::Utc::now().to_rfc3339()), - in_reply_to_user_id: None, - referenced_tweets: Some(vec![ReferencedTweet { - reference_type: ReferenceType::RepliedTo, - id: target_id, - }]), - public_metrics: Some(TweetPublicMetrics { - impression_count: impressions, - like_count: likes, - reply_count: 5, - retweet_count: 2, - ..Default::default() - }), - } - } - - // Helper to seed the DB requirements for a submission - async fn seed_submission( - db: &Arc, - raider_id: &str, - raid_id: i32, - target_id: &str, - submission_id: &str, - x_username: &str, - x_user_id: &str, - ) { - // 1. Seed Raider (Address) - // Handle constraint if address already exists from previous calls in same test - let _ = sqlx::query( - "INSERT INTO addresses (quan_address, referral_code) VALUES ($1, 'REF') ON CONFLICT DO NOTHING", - ) - .bind(raider_id) - .execute(&db.pool) - .await; - - // 2. Seed X Association (Required for raider_id to X user ID mapping) - let _ = sqlx::query( - "INSERT INTO x_associations (quan_address, username) VALUES ($1, $2) ON CONFLICT (quan_address) DO UPDATE SET username = EXCLUDED.username", - ) - .bind(raider_id) - .bind(x_username) - .execute(&db.pool) - .await; - - // 3. Seed Tweet Author (Foreign Key for RelevantTweet) - let _ = sqlx::query( - "INSERT INTO tweet_authors (id, name, username) VALUES ($1, 'Auth', $2) ON CONFLICT DO NOTHING", - ) - .bind(x_user_id) - .bind(x_username) - .execute(&db.pool) - .await; - - // 4. Seed Relevant Tweet (Target) - let _ = sqlx::query( - "INSERT INTO relevant_tweets (id, author_id, text, created_at) VALUES ($1, $2, 'Target', NOW())", - ) - .bind(target_id) - .bind(x_user_id) - .execute(&db.pool) - .await; - - // 5. Create Submission - let _ = sqlx::query( - "INSERT INTO raid_submissions (id, raid_id, raider_id, impression_count, like_count) - VALUES ($1, $2, $3, 0, 0)", - ) - .bind(submission_id) - .bind(raid_id) - .bind(raider_id) - .execute(&db.pool) - .await - .unwrap(); - } - - // ------------------------------------------------------------------------- - // 3. Tests - // ------------------------------------------------------------------------- - - #[tokio::test] - async fn test_sync_no_active_raid_does_nothing() { - let (db, alert_service, config) = setup_deps().await; - - // Setup Gateway: Expect NO calls because there is no active raid - let mut mock_gateway = MockTwitterGateway::new(); - mock_gateway.expect_tweets().times(0); - - let service = RaidLeaderboardService::new(db, Arc::new(mock_gateway), alert_service, config); - - let result = service.sync_raid_leaderboard().await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_sync_active_raid_but_no_submissions_does_nothing() { - let (db, alert_service, config) = setup_deps().await; - - // 1. Create Active Raid - db.raid_quests - .create(&CreateRaidQuest { - name: "Active Raid".to_string(), - }) - .await - .unwrap(); - - // Setup Gateway: Expect NO calls because there are no submissions to check - let mut mock_gateway = MockTwitterGateway::new(); - mock_gateway.expect_tweets().times(0); - - let service = RaidLeaderboardService::new(db, Arc::new(mock_gateway), alert_service, config); - - let result = service.sync_raid_leaderboard().await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_sync_updates_stats_successfully() { - let (db, alert_service, config) = setup_deps().await; - - // 1. Create Active Raid - let raid_id = db - .raid_quests - .create(&CreateRaidQuest { - name: "Active Raid".to_string(), - }) - .await - .unwrap(); - - // 2. Seed Submission (Initial Stats: 0 impressions, 0 likes) - let raider_id = "0xRaider"; - let sub_id = "12345_submission"; - let target_id = "target_12345_submission"; - let x_username = "test_raider"; - let x_user_id = "1234567890"; // X user ID - seed_submission(&db, raider_id, raid_id, target_id, sub_id, x_username, x_user_id).await; - - // 3. Setup Mocks - let mut mock_gateway = MockTwitterGateway::new(); - let mut mock_tweet_api = MockTweetApi::new(); - - // Expect get_many to be called with the submission ID - let x_user_id_clone = x_user_id.to_string(); - let target_id_clone = target_id.to_string(); - let sub_id_clone = sub_id.to_string(); - mock_tweet_api - .expect_get_many() - .with(predicate::eq(vec![sub_id.to_string()]), predicate::always()) - .times(1) - .returning(move |_, _| { - Ok(TwitterApiResponse { - // Return UPDATED stats (100 impressions, 50 likes) - data: Some(vec![create_mock_tweet( - &sub_id_clone, - target_id_clone.clone(), - x_user_id_clone.clone(), // Use X user ID, not raider_id - 100, - 50, - )]), - includes: Some(Includes { - users: Some(vec![User { - id: x_user_id_clone.clone(), - username: x_username.to_string(), - name: "Test User".to_string(), - description: None, - public_metrics: None, - }]), - tweets: None, - }), - meta: None, - }) - }); - - mock_gateway - .expect_tweets() - .return_const(Arc::new(mock_tweet_api) as Arc); - - let service = RaidLeaderboardService::new(db.clone(), Arc::new(mock_gateway), alert_service, config); - - // 4. Run Sync - service.sync_raid_leaderboard().await.unwrap(); - - // 5. Verify DB Updated - let updated_sub = db.raid_submissions.find_by_id(sub_id).await.unwrap().unwrap(); - - assert!(updated_sub.updated_at > updated_sub.created_at); - assert!(!updated_sub.is_invalid); - assert_eq!(updated_sub.impression_count, 100); - assert_eq!(updated_sub.like_count, 50); - } - - #[tokio::test] - async fn test_sync_flag_invalid() { - let (db, alert_service, config) = setup_deps().await; - - // 1. Create Active Raid - let raid_id = db - .raid_quests - .create(&CreateRaidQuest { - name: "Active Raid".to_string(), - }) - .await - .unwrap(); - - // 2. Seed Submission (Initial Stats: 0 impressions, 0 likes) - let raider_id = "0xRaider"; - let sub_id = "12345_submission"; - let target_id = "target_12345_submission"; - let x_username = "test_raider"; - let x_user_id = "1234567890"; // X user ID - seed_submission(&db, raider_id, raid_id, target_id, sub_id, x_username, x_user_id).await; - - // 3. Setup Mocks - let mut mock_gateway = MockTwitterGateway::new(); - let mut mock_tweet_api = MockTweetApi::new(); - - // Expect get_many to be called with the submission ID - let x_user_id_clone = x_user_id.to_string(); - let sub_id_clone = sub_id.to_string(); - mock_tweet_api - .expect_get_many() - .with(predicate::eq(vec![sub_id.to_string()]), predicate::always()) - .times(1) - .returning(move |_, _| { - Ok(TwitterApiResponse { - // Return UPDATED stats (100 impressions, 50 likes) - data: Some(vec![create_mock_tweet( - &sub_id_clone, - "invalid_id".to_string(), - x_user_id_clone.clone(), // Use X user ID, not raider_id - 100, - 50, - )]), - includes: Some(Includes { - users: Some(vec![User { - id: x_user_id_clone.clone(), - username: "invalid_username".to_string(), - name: "Test User".to_string(), - description: None, - public_metrics: None, - }]), - tweets: None, - }), - meta: None, - }) - }); - - mock_gateway - .expect_tweets() - .return_const(Arc::new(mock_tweet_api) as Arc); - - let service = RaidLeaderboardService::new(db.clone(), Arc::new(mock_gateway), alert_service, config); - - // 4. Run Sync - service.sync_raid_leaderboard().await.unwrap(); - - // 5. Verify DB Updated - let updated_sub = db.raid_submissions.find_by_id(sub_id).await.unwrap().unwrap(); - - assert!(updated_sub.updated_at > updated_sub.created_at); - assert!(updated_sub.is_invalid); - assert_eq!(updated_sub.impression_count, 0); - assert_eq!(updated_sub.like_count, 0); - } - - #[tokio::test] - async fn test_sync_batching_logic() { - // This test verifies that if we have > 100 submissions, - // the service makes multiple calls to Twitter. - let (db, alert_service, config) = setup_deps().await; - - let raid_id = db - .raid_quests - .create(&CreateRaidQuest { - name: "Big Raid".to_string(), - }) - .await - .unwrap(); - - // 1. Seed 150 Submissions - // We just need unique IDs. - let mut all_ids = Vec::new(); - let raider_id = "0xRaider"; - let x_username = "test_raider"; - let x_user_id = "1234567890"; // X user ID - for i in 0..150 { - let id = format!("sub_{}", i); - seed_submission( - &db, - raider_id, - raid_id, - &format!("target_{}", id), - &id, - x_username, - x_user_id, - ) - .await; - all_ids.push(id); - } - - // 2. Setup Mocks - let mut mock_gateway = MockTwitterGateway::new(); - let mut mock_tweet_api = MockTweetApi::new(); - - // We expect `get_many` to be called 2 times. - // 1st time: 100 IDs - // 2nd time: 50 IDs - let x_user_id_clone = x_user_id.to_string(); - mock_tweet_api.expect_get_many().times(2).returning(move |ids, _| { - // Return valid responses for whatever IDs were requested - let tweets = ids - .iter() - .map(|id| create_mock_tweet(id, format!("target_{}", id), x_user_id_clone.clone(), 10, 1)) - .collect(); - Ok(TwitterApiResponse { - data: Some(tweets), - includes: None, - meta: None, - }) - }); - - mock_gateway - .expect_tweets() - .times(2) - .return_const(Arc::new(mock_tweet_api) as Arc); - - let service = RaidLeaderboardService::new(db, Arc::new(mock_gateway), alert_service, config); - - // 3. Run Sync - - service.sync_raid_leaderboard().await.unwrap(); - } -} diff --git a/src/utils/test_db.rs b/src/utils/test_db.rs index bd9ee11..6520d14 100644 --- a/src/utils/test_db.rs +++ b/src/utils/test_db.rs @@ -15,13 +15,6 @@ pub async fn reset_database(pool: &PgPool) { .execute(pool) .await .expect("Failed to truncate tables for tests"); - - // Refresh the materialized view to clear the stale snapshot - // Since the source tables are now empty, this will result in an empty view. - sqlx::query("REFRESH MATERIALIZED VIEW raid_leaderboards") - .execute(pool) - .await - .expect("Failed to refresh materialized view for tests"); } pub async fn create_persisted_address(repo: &AddressRepository, id: &str) -> Address { From 62e3c581f5cd993e7101695df5dc7486d43e1810 Mon Sep 17 00:00:00 2001 From: Beast Date: Sun, 14 Jun 2026 03:06:10 +0800 Subject: [PATCH 08/11] cleaning --- scripts/test_opt_in.sh | 7 ------ src/repositories/relevant_tweet.rs | 36 ------------------------------ 2 files changed, 43 deletions(-) delete mode 100755 scripts/test_opt_in.sh diff --git a/scripts/test_opt_in.sh b/scripts/test_opt_in.sh deleted file mode 100755 index 8246fb6..0000000 --- a/scripts/test_opt_in.sh +++ /dev/null @@ -1,7 +0,0 @@ -./sc #!/bin/bash - -# Run opt_in repository tests with single-threaded execution -# This ensures tests don't interfere with each other's database state - -cargo test --lib opt_in -- --test-threads=1 - diff --git a/src/repositories/relevant_tweet.rs b/src/repositories/relevant_tweet.rs index a8a4249..ab060fd 100644 --- a/src/repositories/relevant_tweet.rs +++ b/src/repositories/relevant_tweet.rs @@ -1,6 +1,3 @@ -#[cfg(test)] -use std::collections::HashSet; - use sqlx::{PgPool, Postgres, QueryBuilder, Row}; use crate::{ @@ -221,20 +218,6 @@ impl RelevantTweetRepository { Ok(tweet) } - - #[cfg(test)] - pub async fn get_existing_ids(&self, ids: &[String]) -> DbResult> { - if ids.is_empty() { - return Ok(HashSet::new()); - } - - let rows: Vec = sqlx::query_scalar("SELECT id FROM relevant_tweets WHERE id = ANY($1)") - .bind(ids) - .fetch_all(&self.pool) - .await?; - - Ok(rows.into_iter().collect()) - } } #[cfg(test)] @@ -330,25 +313,6 @@ mod tests { ); } - #[tokio::test] - async fn test_get_existing_ids() { - let (repo, author_repo) = setup_test_repository().await; - seed_author(&author_repo, "a1", "user1").await; - - let t1 = create_payload("t1", "a1", "one"); - let t2 = create_payload("t2", "a1", "two"); - repo.upsert_many(&vec![t1, t2]).await.unwrap(); - - // Check for t1, t2 and a non-existent t3 - let check_ids = vec!["t1".to_string(), "t2".to_string(), "t3".to_string()]; - let existing = repo.get_existing_ids(&check_ids).await.unwrap(); - - assert_eq!(existing.len(), 2); - assert!(existing.contains("t1")); - assert!(existing.contains("t2")); - assert!(!existing.contains("t3")); - } - #[tokio::test] async fn test_get_newest_tweet_id_returns_none_when_no_recent_tweets() { let (repo, author_repo) = setup_test_repository().await; From c248e0aee9f98f5b3a2ccde5bf010ee0d838ab60 Mon Sep 17 00:00:00 2001 From: Beast Date: Sun, 14 Jun 2026 03:21:30 +0800 Subject: [PATCH 09/11] final clean up pass --- src/config.rs | 62 -------------------------------------------------- src/metrics.rs | 6 ----- 2 files changed, 68 deletions(-) diff --git a/src/config.rs b/src/config.rs index e377436..54c1367 100644 --- a/src/config.rs +++ b/src/config.rs @@ -160,65 +160,3 @@ impl Config { self.remote_configs.wallet_configs_file = base_dir.join(wallet_configs_path).to_string_lossy().to_string(); } } - -impl Default for Config { - fn default() -> Self { - Self { - server: ServerConfig { - host: "127.0.0.1".to_string(), - port: 3000, - cors_allowed_origins: vec!["http://localhost:3000".to_string()], - }, - candidates: CandidatesConfig { - graphql_url: "http://localhost:4000/graphql".to_string(), - }, - data: DataConfig { - database_url: "postgres://postgres:postgres@127.0.0.1:5432/task_master".to_string(), - }, - logging: LoggingConfig { - level: "info".to_string(), - }, - jwt: JwtConfig { - admin_secret: "Also-change-in-production".to_string(), - secret: "Change-in-production".to_string(), - exp_in_hours: 24, - }, - x_oauth: OauthConfig { - callback_url: "example".to_string(), - client_id: "example".to_string(), - client_secret: "example".to_string(), - }, - tweet_sync: TweetSyncConfig { - interval_in_hours: 24, - keywords: "hello".to_string(), - api_key: "key".to_string(), - monthly_limit: 15000, - alert_threshold: 13000, - reset_day: 1, - }, - tg_bot: TelegramBotConfig { - base_url: "https://api.telegram.org".to_string(), - chat_id: "-0".to_string(), - message_thread_id: Some("-0".to_string()), - token: "token".to_string(), - }, - alert: AlertConfig { - webhook_url: "https://your-webhook-url.com".to_string(), - }, - remote_configs: RemoteConfigsConfig { - wallet_configs_file: "wallet_configs/default_configs.json".to_string(), - }, - risk_checker: RiskCheckerConfig { - etherscan_api_key: "change-me".to_string(), - etherscan_base_url: "https://api.etherscan.io/api".to_string(), - infura_api_key: "change-me".to_string(), - infura_base_url: "https://mainnet.infura.io/v3".to_string(), - etherscan_calls_per_sec: 3, - max_concurrent_requests: 1, - }, - exchange_rate: ExchangeRateConfig { - api_key: "change-me".to_string(), - }, - } - } -} diff --git a/src/metrics.rs b/src/metrics.rs index b6c5e3d..c6fb01d 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -86,12 +86,6 @@ pub struct Metrics { pub registry: Arc, } -impl Default for Metrics { - fn default() -> Self { - Self::new() - } -} - impl Metrics { pub fn new() -> Self { let registry = Registry::new(); From b702dfd8f3bd331a27e59e4e763147087505fd00 Mon Sep 17 00:00:00 2001 From: Beast Date: Mon, 15 Jun 2026 12:11:42 +0800 Subject: [PATCH 10/11] chore: formatting --- src/handlers/address.rs | 4 +++- src/handlers/auth.rs | 12 ++---------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/handlers/address.rs b/src/handlers/address.rs index cbc8da8..0a22fe0 100644 --- a/src/handlers/address.rs +++ b/src/handlers/address.rs @@ -52,7 +52,9 @@ mod tests { models::admin::Admin, utils::{ test_app_state::create_test_app_state, - test_db::{create_persisted_address, create_persisted_eth_association, create_persisted_opt_in, reset_database}, + test_db::{ + create_persisted_address, create_persisted_eth_association, create_persisted_opt_in, reset_database, + }, }, }; use axum::{ diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index c83bea2..34d1416 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -1,10 +1,5 @@ use argon2::{Argon2, PasswordHash, PasswordVerifier}; -use axum::{ - extract::State, - http::StatusCode, - response::Json, - Extension, -}; +use axum::{extract::State, http::StatusCode, response::Json, Extension}; use chrono::Utc; use jsonwebtoken::{encode, EncodingKey, Header}; use uuid::Uuid; @@ -196,10 +191,7 @@ pub async fn auth_admin( #[cfg(test)] mod tests { - use crate::{ - routes::auth::auth_routes, - utils::test_app_state::create_test_app_state, - }; + use crate::{routes::auth::auth_routes, utils::test_app_state::create_test_app_state}; use axum::{body::Body, http}; use qp_rusty_crystals_dilithium::SensitiveBytes32; use sp_core::crypto::{self, Ss58AddressFormat, Ss58Codec}; From 339c18ed2231729673de732c97f8442483b29177 Mon Sep 17 00:00:00 2001 From: Beast Date: Mon, 15 Jun 2026 16:05:10 +0800 Subject: [PATCH 11/11] fix clippy --- src/metrics.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/metrics.rs b/src/metrics.rs index c6fb01d..6a933b5 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -81,6 +81,12 @@ lazy_static! { .unwrap(); } +impl Default for Metrics { + fn default() -> Self { + Self::new() + } +} + #[derive(Debug, Clone)] pub struct Metrics { pub registry: Arc,