diff --git a/scripts/seed_test_relevant_tweets.sh b/scripts/seed_test_relevant_tweets.sh new file mode 100755 index 0000000..6b834c6 --- /dev/null +++ b/scripts/seed_test_relevant_tweets.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -e + +# CONFIG ------------------------------------------ +CONTAINER_NAME="task_master_test_db" # Change if your container is named differently +DB_USER="postgres" +DB_NAME="task_master" +SQL_FILE="seed_relevant_tweets.sql" +# -------------------------------------------------- + +echo "🔧 Generating seed SQL..." + +cat << 'EOF' > $SQL_FILE +INSERT INTO relevant_tweets ( + id, author_id, text, created_at, fetched_at +) +VALUES +('2037267283798593848', '420308365', 'Test Tweet', '2026-03-26 20:35:45+00', '2026-03-29 12:38:29.171782+00'); +EOF + +echo "📦 Copying SQL file into container ($CONTAINER_NAME)..." +podman cp "$SQL_FILE" "$CONTAINER_NAME":/"$SQL_FILE" + +echo "🚀 Running seed script inside Postgres..." +podman exec -it "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -f "/$SQL_FILE" + +echo "🔍 Verifying result..." +podman exec -it "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -c "SELECT * FROM relevant_tweets WHERE id = '2037267283798593848';" + +rm -rf "$SQL_FILE" + +echo "✅ Seeding complete!" diff --git a/scripts/seed_test_tweet_authors.sh b/scripts/seed_test_tweet_authors.sh index b0cdf07..82879a3 100755 --- a/scripts/seed_test_tweet_authors.sh +++ b/scripts/seed_test_tweet_authors.sh @@ -17,6 +17,24 @@ INSERT INTO tweet_authors ( ) VALUES ('1862779229277954048', 'Yuvi Lightman', 'YuviLightman', 0, 0, 0, 0, 0, 0, NOW(), false) +ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + username = EXCLUDED.username, + followers_count = EXCLUDED.followers_count, + following_count = EXCLUDED.following_count, + tweet_count = EXCLUDED.tweet_count, + listed_count = EXCLUDED.listed_count, + like_count = EXCLUDED.like_count, + media_count = EXCLUDED.media_count, + fetched_at = NOW(), + is_ignored = EXCLUDED.is_ignored; + +INSERT INTO tweet_authors ( + id, name, username, followers_count, following_count, + tweet_count, listed_count, like_count, media_count, fetched_at, is_ignored +) +VALUES +('420308365', 'nic carter', 'nic_carter', 0, 0, 0, 0, 0, 0, '2026-03-29 12:38:29.166611+00', false) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, username = EXCLUDED.username, @@ -37,7 +55,7 @@ echo "🚀 Running seed script inside Postgres..." podman exec -it "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -f "/$SQL_FILE" echo "🔍 Verifying result..." -podman exec -it "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -c "SELECT * FROM tweet_authors WHERE id = '1862779229277954048';" +podman exec -it "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -c "SELECT * FROM tweet_authors;" rm -rf "$SQL_FILE" diff --git a/src/repositories/tweet_author.rs b/src/repositories/tweet_author.rs index f90204b..19ac549 100644 --- a/src/repositories/tweet_author.rs +++ b/src/repositories/tweet_author.rs @@ -108,11 +108,11 @@ impl TweetAuthorRepository { } pub async fn get_whitelist(&self) -> Result, DbError> { - let ids = sqlx::query_scalar::<_, String>("SELECT id FROM tweet_authors WHERE is_ignored = false") + let usernames = sqlx::query_scalar::<_, String>("SELECT username FROM tweet_authors WHERE is_ignored = false") .fetch_all(&self.pool) .await?; - Ok(ids) + Ok(usernames) } pub async fn set_ignore_status(&self, id: &str, status: bool) -> Result<(), DbError> { diff --git a/src/services/tweet_synchronizer_service.rs b/src/services/tweet_synchronizer_service.rs index e18837b..34d096c 100644 --- a/src/services/tweet_synchronizer_service.rs +++ b/src/services/tweet_synchronizer_service.rs @@ -230,8 +230,14 @@ impl TweetSynchronizerService { // Track metrics for tweets pulled track_tweets_pulled("search_recent", tweets_pulled); - self.process_sending_raid_targets(&tweet_authors, &relevant_tweets) - .await?; + // We might get duplicate because X will return the tweet including the since_id not just the new ones. + let new_tweets: Vec = relevant_tweets + .iter() + .filter(|t| Some(&t.id) != last_id.as_ref()) + .cloned() + .collect(); + + self.process_sending_raid_targets(&tweet_authors, &new_tweets).await?; } Ok(()) @@ -240,6 +246,7 @@ impl TweetSynchronizerService { #[cfg(test)] mod tests { + // NOTE: These tests share the test DB; run with `cargo test ... -- --test-threads=1` (see scripts/run_non_chain_tests.sh). use super::*; use crate::config::{Config, TelegramBotConfig}; use crate::models::raid_quest::CreateRaidQuest; @@ -497,6 +504,87 @@ mod tests { tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; } + /// API may repeat the `since_id` tweet; it must not be forwarded to Telegram for raids. + #[tokio::test] + async fn test_sync_excludes_last_id_from_telegram_when_raid_active() { + let (db, mock_tg, telegram_service, alert_service, config) = setup_deps().await; + + let dummy_author = crate::models::tweet_author::NewAuthorPayload { + id: "old_u".to_string(), + name: "Old".to_string(), + username: "old".to_string(), + followers_count: 0, + following_count: 0, + tweet_count: 0, + listed_count: 0, + like_count: 0, + media_count: 0, + is_ignored: Some(false), + }; + + db.tweet_authors.upsert(&dummy_author).await.unwrap(); + + let last_tweet_id = "since_id_dup"; + db.relevant_tweets + .upsert_many(&vec![crate::models::relevant_tweet::NewTweetPayload { + id: last_tweet_id.to_string(), + author_id: dummy_author.id.clone(), + text: "Already seen".to_string(), + impression_count: 0, + reply_count: 0, + retweet_count: 0, + like_count: 0, + created_at: chrono::Utc::now(), + }]) + .await + .unwrap(); + + db.raid_quests + .create(&CreateRaidQuest { + name: "Test Raid".to_string(), + }) + .await + .unwrap(); + + Mock::given(method("POST")) + .and(path("/bot123456/sendMessage")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&mock_tg) + .await; + + let mut mock_gateway = MockTwitterGateway::new(); + let mut mock_search = MockSearchApi::new(); + + let author = dummy_author.clone(); + mock_search.expect_recent().returning(move |_| { + Ok(TwitterApiResponse { + data: Some(vec![create_mock_tweet(last_tweet_id, &author.id)]), + includes: Some(Includes { + users: Some(vec![create_mock_user(&author.id, &author.username)]), + tweets: None, + }), + meta: None, + }) + }); + + let search_api_arc: Arc = Arc::new(mock_search); + + mock_gateway.expect_search().times(1).return_const(search_api_arc); + + let service = TweetSynchronizerService::new( + db.clone(), + Arc::new(mock_gateway), + telegram_service, + alert_service, + config, + ); + + service.sync_relevant_tweets().await.unwrap(); + + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + #[tokio::test] async fn test_pagination_logic_uses_last_id() { let (db, _mock_tg, telegram_service, alert_service, config) = setup_deps().await;