Skip to content
Open
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 false;

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
197 changes: 191 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.make_ignored_from_whitelist(&id).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.make_watched_in_whitelist(&id).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 Down Expand Up @@ -263,7 +340,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 +358,113 @@ 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);

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);
}

#[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);
}
}
22 changes: 11 additions & 11 deletions src/models/tweet_author.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use chrono::{DateTime, Utc};
use rusx::resources::user::{User as TwitterUser, UserPublicMetrics};
use rusx::resources::user::User as TwitterUser;
use serde::{Deserialize, Serialize};
use sqlx::{postgres::PgRow, FromRow, Row};

Expand All @@ -8,6 +8,7 @@ pub struct TweetAuthor {
pub id: String,
pub name: String,
pub username: String,
pub is_ignored: bool,
pub followers_count: i32,
pub following_count: i32,
pub tweet_count: i32,
Expand All @@ -22,6 +23,7 @@ impl<'r> FromRow<'r, PgRow> for TweetAuthor {
id: row.try_get("id")?,
name: row.try_get("name")?,
username: row.try_get("username")?,
is_ignored: row.try_get("is_ignored")?,
followers_count: row.try_get("followers_count")?,
following_count: row.try_get("following_count")?,
tweet_count: row.try_get("tweet_count")?,
Expand Down Expand Up @@ -81,14 +83,7 @@ pub struct NewAuthorPayload {

impl NewAuthorPayload {
pub fn new(author: TwitterUser) -> Self {
let public_metrics = author
.public_metrics
.ok_or_else(|| UserPublicMetrics {
media_count: Some(0),
like_count: Some(0),
..Default::default()
})
.unwrap();
let public_metrics = author.public_metrics.unwrap_or_default();

let new_author = NewAuthorPayload {
id: author.id,
Expand All @@ -98,10 +93,15 @@ impl NewAuthorPayload {
following_count: public_metrics.following_count as i32,
tweet_count: public_metrics.tweet_count as i32,
listed_count: public_metrics.listed_count as i32,
media_count: public_metrics.media_count.unwrap() as i32,
like_count: public_metrics.like_count.unwrap() as i32,
media_count: public_metrics.media_count.unwrap_or(0) as i32,
like_count: public_metrics.like_count.unwrap_or(0) as i32,
};

new_author
}
}

#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct CreateTweetAuthorInput {
pub username: String,
}
Loading