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
2 changes: 2 additions & 0 deletions migrations/007_raid_submissions_target_id_nullable.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE raid_submissions
ALTER COLUMN target_id DROP NOT NULL;
20 changes: 3 additions & 17 deletions src/handlers/raid_quest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,16 +190,6 @@ pub async fn handle_create_raid_submission(
Extension(user): Extension<Address>,
extract::Json(payload): Json<RaidSubmissionInput>,
) -> Result<(StatusCode, Json<SuccessResponse<String>>), 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"
Expand All @@ -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?;
Expand Down Expand Up @@ -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(),
};

Expand All @@ -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]
Expand All @@ -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(),
};

Expand Down Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions src/models/raid_submission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub raider_id: String,
pub impression_count: i32,
pub reply_count: i32,
Expand Down Expand Up @@ -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,
}

Expand Down
14 changes: 3 additions & 11 deletions src/repositories/raid_submission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(),
}
}
Expand Down Expand Up @@ -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(),
};

Expand Down
106 changes: 92 additions & 14 deletions src/services/raid_leaderboard_service.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use std::{collections::HashSet, sync::Arc};
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};

use rusx::{
resources::{tweet::TweetParams, TweetField},
Expand Down Expand Up @@ -76,6 +79,7 @@ impl RaidLeaderboardService {
};

let queries = RaidLeaderboardService::build_batched_tweet_queries(&raid_submissions);
let raider_map: HashMap<String, String> = raid_submissions.into_iter().map(|s| (s.id, s.raider_id)).collect();

let mut params = TweetParams::new();
params.tweet_fields = Some(vec![
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand All @@ -209,9 +214,14 @@ mod tests {
}

// Helper to seed the DB requirements for a submission
async fn seed_submission(db: &Arc<DbPersistence>, raid_id: i32, target_id: &str, submission_id: &str) {
async fn seed_submission(
db: &Arc<DbPersistence>,
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",
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand All @@ -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,
})
Expand All @@ -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<dyn TweetApi>);

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,
Expand All @@ -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);
}

Expand All @@ -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),
Expand Down