Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ path = "src/bin/create_raid.rs"
qp-human-checkphrase = "0.1.2"
qp-rusty-crystals-dilithium = "2.0.0"
quantus-cli = "0.3.0"
rusx = {git = "https://github.com/Quantus-Network/rusx", tag = "v0.5.0"}
rusx = {git = "https://github.com/Quantus-Network/rusx", tag = "v0.6.0"}

# Async runtime
tokio = {version = "1.46", features = ["full", "test-util"]}
Expand Down Expand Up @@ -90,4 +90,4 @@ tiny-keccak = {version = "2.0.2", features = ["keccak"]}
mockall = "0.13"
wiremock = "0.5"
# Enable the testing feature ONLY for tests
rusx = {git = "https://github.com/Quantus-Network/rusx", tag = "v0.5.0", features = ["testing"]}
rusx = {git = "https://github.com/Quantus-Network/rusx", tag = "v0.6.0", features = ["testing"]}
1 change: 0 additions & 1 deletion config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ client_secret = "lfXc45dZLqYTzP62Ms32EhXinGQzxcIP9TvjJml2B-h0T1nIJK"
api_key = "some-key"
interval_in_hours = 24
keywords = "quantum"
whitelist = ["username"]

[tg_bot]
base_url = "https://api.telegram.org"
Expand Down
1 change: 0 additions & 1 deletion config/example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ client_secret = "example-secret"
api_key = "some-key"
interval_in_hours = 24
keywords = "example"
whitelist = ["example-username"]

[tg_bot]
base_url = "https://api.telegram.org"
Expand Down
1 change: 0 additions & 1 deletion config/test.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ client_secret = "test-secret"
api_key = "some-key"
interval_in_hours = 24
keywords = "test"
whitelist = ["test-username"]

[tg_bot]
base_url = "https://api.telegram.org"
Expand Down
5 changes: 5 additions & 0 deletions migrations/008_add_is_ignore_col_to_tweet_authors_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
ALTER TABLE tweet_authors
ADD COLUMN is_ignored BOOLEAN NOT NULL DEFAULT true;

CREATE INDEX IF NOT EXISTS idx_tweet_authors_is_ignored ON tweet_authors(is_ignored)
WHERE is_ignored = false;
2 changes: 0 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ pub struct JwtConfig {

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TweetSyncConfig {
pub whitelist: Vec<String>,
pub interval_in_hours: u64,
pub keywords: String,
pub api_key: String,
Expand Down Expand Up @@ -207,7 +206,6 @@ impl Default for Config {
client_secret: "example".to_string(),
},
tweet_sync: TweetSyncConfig {
whitelist: vec![],
interval_in_hours: 24,
keywords: "hello".to_string(),
api_key: "key".to_string(),
Expand Down
1 change: 1 addition & 0 deletions src/handlers/raid_quest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ mod tests {
listed_count: 0,
like_count: 0,
media_count: 0,
is_ignored: Some(true),
};
state.db.tweet_authors.upsert_many(&vec![author]).await.unwrap();

Expand Down
2 changes: 2 additions & 0 deletions src/handlers/relevant_tweet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ mod tests {
listed_count: 0,
like_count: 0,
media_count: 0,
is_ignored: Some(true),
},
NewAuthorPayload {
id: "auth_B".to_string(),
Expand All @@ -100,6 +101,7 @@ mod tests {
listed_count: 0,
like_count: 0,
media_count: 0,
is_ignored: Some(true),
},
];
state
Expand Down
204 changes: 198 additions & 6 deletions src/handlers/tweet_author.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
use axum::{
extract::{self, Query, State},
extract::{self, Path, Query, State},
http::StatusCode,
response::NoContent,
Extension, Json,
};
use rusx::resources::{user::UserParams, UserField};

use crate::{
db_persistence::DbError,
handlers::{calculate_total_pages, ListQueryParams, PaginatedResponse, PaginationMetadata, SuccessResponse},
handlers::{
calculate_total_pages, HandlerError, ListQueryParams, PaginatedResponse, PaginationMetadata, SuccessResponse,
},
http_server::AppState,
models::{
admin::Admin,
tweet_author::{AuthorFilter, AuthorSortColumn, TweetAuthor},
tweet_author::{AuthorFilter, AuthorSortColumn, CreateTweetAuthorInput, NewAuthorPayload, TweetAuthor},
},
AppError,
};
Expand Down Expand Up @@ -40,6 +45,60 @@ pub async fn handle_get_tweet_authors(
Ok(Json(response))
}

/// POST /tweet-authors
pub async fn handle_create_tweet_author(
State(state): State<AppState>,
Extension(_): Extension<Admin>,
Json(payload): Json<CreateTweetAuthorInput>,
) -> Result<(StatusCode, Json<SuccessResponse<String>>), AppError> {
let mut params = UserParams::new();
params.user_fields = Some(vec![
UserField::PublicMetrics,
UserField::Id,
UserField::Name,
UserField::Username,
]);

let author_response = state
.twitter_gateway
.users()
.get_by_username(&payload.username, Some(params.clone()))
.await?;
let Some(author) = author_response.data else {
return Err(AppError::Handler(HandlerError::InvalidBody(format!(
"Tweet Author {} not found",
payload.username
))));
};

let new_author = NewAuthorPayload::new(author);
let create_response = state.db.tweet_authors.upsert(&new_author).await?;

Ok((StatusCode::CREATED, SuccessResponse::new(create_response)))
}

/// PUT /tweet-authors/:id/ignore
pub async fn handle_ignore_tweet_author(
State(state): State<AppState>,
Extension(_): Extension<Admin>,
Path(id): Path<String>,
) -> Result<NoContent, AppError> {
state.db.tweet_authors.set_ignore_status(&id, true).await?;

Ok(NoContent)
}

/// PUT /tweet-authors/:id/watch
pub async fn handle_watch_tweet_author(
State(state): State<AppState>,
Extension(_): Extension<Admin>,
Path(id): Path<String>,
) -> Result<NoContent, AppError> {
state.db.tweet_authors.set_ignore_status(&id, false).await?;

Ok(NoContent)
}

/// GET /tweet-authors/:id
/// Gets a single author by their X ID
pub async fn handle_get_tweet_author_by_id(
Expand All @@ -60,12 +119,30 @@ pub async fn handle_get_tweet_author_by_id(

#[cfg(test)]
mod tests {
use axum::{body::Body, extract::Request, http::StatusCode, routing::get, Extension, Router};
use std::sync::Arc;

use axum::{
body::Body,
extract::Request,
http::StatusCode,
routing::{get, post, put},
Extension, Router,
};
use rusx::{
resources::{
user::{User, UserApi, UserPublicMetrics},
TwitterApiResponse,
},
MockTwitterGateway, MockUserApi,
};
use serde_json::Value;
use tower::ServiceExt;

use crate::{
handlers::tweet_author::{handle_get_tweet_author_by_id, handle_get_tweet_authors},
handlers::tweet_author::{
handle_create_tweet_author, handle_get_tweet_author_by_id, handle_get_tweet_authors,
handle_ignore_tweet_author, handle_watch_tweet_author,
},
models::tweet_author::NewAuthorPayload,
utils::{
test_app_state::create_test_app_state,
Expand All @@ -86,6 +163,7 @@ mod tests {
listed_count: 1,
like_count: 200,
media_count: 5,
is_ignored: Some(true),
},
NewAuthorPayload {
id: "auth_2".to_string(),
Expand All @@ -97,6 +175,7 @@ mod tests {
listed_count: 5,
like_count: 1000,
media_count: 10,
is_ignored: Some(true),
},
NewAuthorPayload {
id: "auth_3".to_string(),
Expand All @@ -108,6 +187,7 @@ mod tests {
listed_count: 0,
like_count: 20,
media_count: 0,
is_ignored: Some(true),
},
];

Expand Down Expand Up @@ -263,7 +343,6 @@ mod tests {
async fn test_get_tweet_author_by_id_not_found() {
let state = create_test_app_state().await;
reset_database(&state.db.pool).await;
// No authors seeded

let router = Router::new()
.route("/tweet-authors/:id", get(handle_get_tweet_author_by_id))
Expand All @@ -282,4 +361,117 @@ mod tests {

assert_eq!(response.status(), 404);
}

#[tokio::test]
async fn test_create_tweet_author_success() {
let mut state = create_test_app_state().await;
reset_database(&state.db.pool).await;

// --- Setup Twitter Mock ---
let mut mock_gateway = MockTwitterGateway::new();
let mut mock_user = MockUserApi::new();

mock_user.expect_get_by_username().returning(|_, _| {
Ok(TwitterApiResponse {
data: Some(User {
id: "hello".to_string(),
name: "hello".to_string(),
username: "test_user".to_string(),
public_metrics: Some(UserPublicMetrics {
followers_count: 100,
following_count: 50,
tweet_count: 10,
listed_count: 5,
like_count: Some(0),
media_count: Some(0),
}),
}),
includes: None,
meta: None,
})
});

let user_api_arc: Arc<dyn UserApi> = Arc::new(mock_user);

mock_gateway.expect_users().times(1).return_const(user_api_arc);

state.twitter_gateway = Arc::new(mock_gateway);

let router = Router::new()
.route("/tweet-authors", post(handle_create_tweet_author))
.layer(Extension(create_mock_admin()))
.with_state(state.clone());

let payload = serde_json::json!({
"username": "test_user"
});

let response = router
.oneshot(
Request::builder()
.method("POST")
.uri("/tweet-authors")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_vec(&payload).unwrap()))
.unwrap(),
)
.await
.unwrap();

assert!(response.status() == StatusCode::CREATED);

let author = state.db.tweet_authors.find_by_id("hello").await.unwrap().unwrap();

assert_eq!(author.is_ignored, true);
}

#[tokio::test]
async fn test_ignore_and_watch_tweet_author() {
let state = create_test_app_state().await;
reset_database(&state.db.pool).await;
seed_authors(&state).await;

let router = Router::new()
.route("/tweet-authors/:id/ignore", put(handle_ignore_tweet_author))
.route("/tweet-authors/:id/watch", put(handle_watch_tweet_author))
.layer(Extension(create_mock_admin()))
.with_state(state.clone());

// 1. Test Ignore
let ignore_res = router
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/tweet-authors/auth_1/ignore")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();

assert_eq!(ignore_res.status(), StatusCode::NO_CONTENT);

// Verify in DB
let author = state.db.tweet_authors.find_by_id("auth_1").await.unwrap().unwrap();
assert!(author.is_ignored);

// 2. Test Watch (Un-ignore)
let watch_res = router
.oneshot(
Request::builder()
.method("PUT")
.uri("/tweet-authors/auth_1/watch")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();

assert_eq!(watch_res.status(), StatusCode::NO_CONTENT);

// Verify in DB
let author_updated = state.db.tweet_authors.find_by_id("auth_1").await.unwrap().unwrap();
assert!(!author_updated.is_ignored);
}
}
Loading