diff --git a/migrations/007_raid_submissions_target_id_nullable.sql b/migrations/007_raid_submissions_target_id_nullable.sql new file mode 100644 index 0000000..e1e1629 --- /dev/null +++ b/migrations/007_raid_submissions_target_id_nullable.sql @@ -0,0 +1,2 @@ +ALTER TABLE raid_submissions +ALTER COLUMN target_id DROP NOT NULL; \ No newline at end of file diff --git a/src/handlers/raid_quest.rs b/src/handlers/raid_quest.rs index 1af0237..ac1dd65 100644 --- a/src/handlers/raid_quest.rs +++ b/src/handlers/raid_quest.rs @@ -190,21 +190,6 @@ pub async fn handle_create_raid_submission( Extension(user): Extension
, extract::Json(payload): Json, ) -> Result<(StatusCode, Json>), AppError> { - let Some((_target_username, target_id)) = parse_x_status_url(&payload.target_tweet_link) else { - return Err(AppError::Handler(HandlerError::InvalidBody(format!( - "Couldn't parse target tweet link" - )))); - }; - let Some(_) = state.db.relevant_tweets.find_by_id(&target_id).await? else { - return Err(AppError::Database(DbError::RecordNotFound(format!( - "Not a valid target tweet" - )))); - }; - let Some((reply_username, reply_id)) = parse_x_status_url(&payload.tweet_reply_link) else { - return Err(AppError::Handler(HandlerError::InvalidBody(format!( - "Couldn't parse tweet reply link" - )))); - }; let Some(current_active_raid) = state.db.raid_quests.find_active().await? else { return Err(AppError::Database(DbError::RecordNotFound(format!( "No active raid is found" @@ -215,6 +200,11 @@ pub async fn handle_create_raid_submission( "User doesn't have X association" )))); }; + let Some((reply_username, reply_id)) = parse_x_status_url(&payload.tweet_reply_link) else { + return Err(AppError::Handler(HandlerError::InvalidBody(format!( + "Couldn't parse tweet reply link" + )))); + }; if user_x.username != reply_username { return Err(AppError::Handler(HandlerError::Auth(AuthHandlerError::Unauthorized( format!("Only tweet reply author is eligible to submit"), @@ -225,7 +215,6 @@ pub async fn handle_create_raid_submission( id: reply_id, raid_id: current_active_raid.id, raider_id: user.quan_address.0, - target_id: target_id, }; let created_id = state.db.raid_submissions.create(&new_raid_submission).await?; @@ -570,10 +559,8 @@ mod tests { .with_state(state.clone()); // 5. Payload - // Target Link -> ID 1868000000000000000 // Reply Link -> ID 999999999, Username "me" let payload = RaidSubmissionInput { - target_tweet_link: format!("https://x.com/someone/status/{}", target_tweet_id), tweet_reply_link: "https://x.com/me/status/999999999".to_string(), }; @@ -596,7 +583,8 @@ mod tests { assert!(sub.is_some()); let sub = sub.unwrap(); assert_eq!(sub.raid_id, raid_id); - assert_eq!(sub.target_id, target_tweet_id); + assert_eq!(&sub.id, "999999999"); + assert!(sub.target_id.is_none()); } #[tokio::test] @@ -621,7 +609,6 @@ mod tests { .with_state(state); let payload = RaidSubmissionInput { - target_tweet_link: "https://x.com/a/status/100".into(), tweet_reply_link: "https://x.com/b/status/200".into(), }; @@ -660,8 +647,7 @@ mod tests { .with_state(state); let payload = RaidSubmissionInput { - target_tweet_link: "not_a_valid_url".into(), - tweet_reply_link: "https://x.com/b/status/200".into(), + tweet_reply_link: "https://x.com/b/dwdwdwt/dwdwd".into(), }; let response = router @@ -677,7 +663,7 @@ mod tests { .unwrap(); // 400 Bad Request / Handler Error - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] diff --git a/src/models/raid_submission.rs b/src/models/raid_submission.rs index ffa1b61..2e49489 100644 --- a/src/models/raid_submission.rs +++ b/src/models/raid_submission.rs @@ -9,7 +9,7 @@ use crate::models::raid_quest::RaidQuest; pub struct RaidSubmission { pub id: String, pub raid_id: i32, - pub target_id: String, + pub target_id: Option, pub raider_id: String, pub impression_count: i32, pub reply_count: i32, @@ -54,13 +54,11 @@ impl<'r> FromRow<'r, PgRow> for RaidSubmission { pub struct CreateRaidSubmission { pub id: String, pub raid_id: i32, - pub target_id: String, pub raider_id: String, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct RaidSubmissionInput { - pub target_tweet_link: String, pub tweet_reply_link: String, } diff --git a/src/repositories/raid_submission.rs b/src/repositories/raid_submission.rs index 2d65a42..e4ba71c 100644 --- a/src/repositories/raid_submission.rs +++ b/src/repositories/raid_submission.rs @@ -24,15 +24,14 @@ impl RaidSubmissionRepository { let created_id = sqlx::query_scalar::<_, String>( " INSERT INTO raid_submissions ( - id, raid_id, target_id, raider_id + id, raid_id, raider_id ) - VALUES ($1, $2, $3, $4) + VALUES ($1, $2, $3) RETURNING id ", ) .bind(&submission.id) .bind(submission.raid_id) - .bind(&submission.target_id) .bind(&submission.raider_id) .fetch_optional(&self.pool) .await?; @@ -186,7 +185,6 @@ mod tests { struct SeedData { raid_id: i32, raider_id: String, - target_id: String, } // Helper to satisfy the strict Foreign Key chain: @@ -232,18 +230,13 @@ mod tests { .await .expect("Failed to seed relevant tweet"); - SeedData { - raid_id, - raider_id, - target_id, - } + 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, - target_id: seed.target_id.clone(), raider_id: seed.raider_id.clone(), } } @@ -423,7 +416,6 @@ mod tests { let input = CreateRaidSubmission { id: Uuid::new_v4().to_string(), raid_id: 9999, // Non-existent Raid - target_id: "fake_tweet".to_string(), raider_id: "fake_user".to_string(), }; diff --git a/src/services/raid_leaderboard_service.rs b/src/services/raid_leaderboard_service.rs index 44509bc..5311a63 100644 --- a/src/services/raid_leaderboard_service.rs +++ b/src/services/raid_leaderboard_service.rs @@ -1,4 +1,7 @@ -use std::{collections::HashSet, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; use rusx::{ resources::{tweet::TweetParams, TweetField}, @@ -76,6 +79,7 @@ impl RaidLeaderboardService { }; let queries = RaidLeaderboardService::build_batched_tweet_queries(&raid_submissions); + let raider_map: HashMap = raid_submissions.into_iter().map(|s| (s.id, s.raider_id)).collect(); let mut params = TweetParams::new(); params.tweet_fields = Some(vec![ @@ -122,12 +126,13 @@ impl RaidLeaderboardService { // `for tweet in tweets` consumes the original vector, so we "move" // the data instead of cloning it. for tweet in tweets { - let is_valid = tweet.referenced_tweets.as_ref().map_or(false, |refs| { + let is_valid_reply = tweet.referenced_tweets.as_ref().map_or(false, |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 = raider_map.get(&tweet.id) == tweet.author_id.as_ref(); - if is_valid { + if is_valid_reply && is_eligible_owner { valid_tweets.push(tweet); } else { invalid_tweets.push(tweet); @@ -187,11 +192,11 @@ mod tests { (db, Arc::new(config)) } - fn create_mock_tweet(id: &str, target_id: String, impressions: u32, likes: u32) -> Tweet { + 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_1".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 { @@ -209,9 +214,14 @@ mod tests { } // Helper to seed the DB requirements for a submission - async fn seed_submission(db: &Arc, raid_id: i32, target_id: &str, submission_id: &str) { + async fn seed_submission( + db: &Arc, + raider_id: &str, + raid_id: i32, + target_id: &str, + submission_id: &str, + ) { // 1. Seed Raider (Address) - let raider_id = "0xRaider"; // 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", @@ -237,12 +247,11 @@ mod tests { // 4. Create Submission let _ = sqlx::query( - "INSERT INTO raid_submissions (id, raid_id, target_id, raider_id, impression_count, like_count) - VALUES ($1, $2, $3, $4, 0, 0)", + "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(target_id) .bind(raider_id) .execute(&db.pool) .await @@ -303,9 +312,10 @@ mod tests { .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"; - seed_submission(&db, raid_id, target_id, sub_id).await; + seed_submission(&db, raider_id, raid_id, target_id, sub_id).await; // 3. Setup Mocks let mut mock_gateway = MockTwitterGateway::new(); @@ -319,7 +329,13 @@ mod tests { .returning(|_, _| { Ok(TwitterApiResponse { // Return UPDATED stats (100 impressions, 50 likes) - data: Some(vec![create_mock_tweet(sub_id, target_id.to_string(), 100, 50)]), + data: Some(vec![create_mock_tweet( + sub_id, + target_id.to_string(), + raider_id.to_string(), + 100, + 50, + )]), includes: None, meta: None, }) @@ -343,6 +359,67 @@ mod tests { assert_eq!(updated_sub.like_count, 50); } + #[tokio::test] + async fn test_sync_flag_invalid() { + let (db, 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"; + seed_submission(&db, raider_id, raid_id, target_id, sub_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 + mock_tweet_api + .expect_get_many() + .with(predicate::eq(vec![sub_id.to_string()]), predicate::always()) + .times(1) + .returning(|_, _| { + Ok(TwitterApiResponse { + // Return UPDATED stats (100 impressions, 50 likes) + data: Some(vec![create_mock_tweet( + sub_id, + "invalid_id".to_string(), + raider_id.to_string(), + 100, + 50, + )]), + includes: 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), 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_eq!(updated_sub.is_invalid, true); + 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, @@ -360,9 +437,10 @@ mod tests { // 1. Seed 150 Submissions // We just need unique IDs. let mut all_ids = Vec::new(); + let raider_id = "0xRaider"; for i in 0..150 { let id = format!("sub_{}", i); - seed_submission(&db, raid_id, &format!("target_{}", id), &id).await; + seed_submission(&db, raider_id, raid_id, &format!("target_{}", id), &id).await; all_ids.push(id); } @@ -377,7 +455,7 @@ mod tests { // Return valid responses for whatever IDs were requested let tweets = ids .iter() - .map(|id| create_mock_tweet(id, format!("target_{}", id), 10, 1)) + .map(|id| create_mock_tweet(id, format!("target_{}", id), raider_id.to_string(), 10, 1)) .collect(); Ok(TwitterApiResponse { data: Some(tweets),