#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::config::Config;
use crate::error::XApiError;
use crate::llm::{GenerationParams, LlmProvider, LlmResponse};
use crate::storage;
use crate::workflow::discover::{self, DiscoverInput};
use crate::workflow::draft::{self, DraftInput};
use crate::workflow::orchestrate::{self, CycleInput};
use crate::workflow::publish;
use crate::workflow::thread_plan::{self, ThreadPlanInput};
use crate::workflow::{ProposeResult, WorkflowError};
use crate::x_api::types::*;
use crate::x_api::XApiClient;
use crate::LlmError;
struct E2eXApiClient {
tweets: Vec<Tweet>,
users: Vec<User>,
}
impl E2eXApiClient {
fn with_data(tweets: Vec<Tweet>, users: Vec<User>) -> Self {
Self { tweets, users }
}
}
#[async_trait::async_trait]
impl XApiClient for E2eXApiClient {
async fn search_tweets(
&self,
_: &str,
_: u32,
_: Option<&str>,
_: Option<&str>,
) -> Result<SearchResponse, XApiError> {
Ok(SearchResponse {
data: self.tweets.clone(),
includes: if self.users.is_empty() {
None
} else {
Some(Includes {
users: self.users.clone(),
})
},
meta: SearchMeta {
newest_id: self.tweets.first().map(|t| t.id.clone()),
oldest_id: self.tweets.last().map(|t| t.id.clone()),
result_count: self.tweets.len() as u32,
next_token: None,
},
})
}
async fn get_mentions(
&self,
_: &str,
_: Option<&str>,
_: Option<&str>,
) -> Result<MentionResponse, XApiError> {
Ok(SearchResponse {
data: vec![],
includes: None,
meta: SearchMeta {
newest_id: None,
oldest_id: None,
result_count: 0,
next_token: None,
},
})
}
async fn post_tweet(&self, text: &str) -> Result<PostedTweet, XApiError> {
Ok(PostedTweet {
id: "posted_e2e".to_string(),
text: text.to_string(),
})
}
async fn reply_to_tweet(&self, text: &str, _: &str) -> Result<PostedTweet, XApiError> {
Ok(PostedTweet {
id: "reply_e2e".to_string(),
text: text.to_string(),
})
}
async fn get_tweet(&self, id: &str) -> Result<Tweet, XApiError> {
self.tweets
.iter()
.find(|t| t.id == id)
.cloned()
.ok_or_else(|| XApiError::ApiError {
status: 404,
message: "Not found".to_string(),
})
}
async fn get_me(&self) -> Result<User, XApiError> {
Ok(User {
id: "me".to_string(),
username: "testbot".to_string(),
name: "Test Bot".to_string(),
profile_image_url: None,
description: None,
location: None,
url: None,
public_metrics: UserMetrics::default(),
})
}
async fn get_user_tweets(
&self,
_: &str,
_: u32,
_: Option<&str>,
) -> Result<SearchResponse, XApiError> {
Ok(SearchResponse {
data: vec![],
includes: None,
meta: SearchMeta {
newest_id: None,
oldest_id: None,
result_count: 0,
next_token: None,
},
})
}
async fn get_user_by_username(&self, u: &str) -> Result<User, XApiError> {
self.users
.iter()
.find(|user| user.username == u)
.cloned()
.ok_or_else(|| XApiError::ApiError {
status: 404,
message: "Not found".to_string(),
})
}
}
struct E2eLlm {
reply_text: String,
}
impl E2eLlm {
fn new(text: &str) -> Self {
Self {
reply_text: text.to_string(),
}
}
}
#[async_trait::async_trait]
impl LlmProvider for E2eLlm {
fn name(&self) -> &str {
"e2e-mock"
}
async fn complete(
&self,
_system: &str,
_user_message: &str,
_params: &GenerationParams,
) -> Result<LlmResponse, LlmError> {
Ok(LlmResponse {
text: self.reply_text.clone(),
model: "mock".to_string(),
usage: crate::llm::TokenUsage {
input_tokens: 10,
output_tokens: 20,
},
})
}
async fn health_check(&self) -> Result<(), LlmError> {
Ok(())
}
}
fn test_config() -> Config {
let mut config = Config::default();
config.business.product_name = "TestProduct".to_string();
config.business.product_keywords = vec!["rust".to_string(), "programming".to_string()];
config.scoring.threshold = 10;
config.approval_mode = true;
config
}
fn sample_tweet(id: &str, text: &str, author_id: &str) -> Tweet {
Tweet {
id: id.to_string(),
text: text.to_string(),
author_id: author_id.to_string(),
created_at: "2026-02-24T12:00:00Z".to_string(),
public_metrics: PublicMetrics {
like_count: 15,
retweet_count: 5,
reply_count: 3,
impression_count: 2000,
..Default::default()
},
conversation_id: None,
}
}
fn sample_user(id: &str, username: &str, followers: u64) -> User {
User {
id: id.to_string(),
username: username.to_string(),
name: username.to_string(),
profile_image_url: None,
description: None,
location: None,
url: None,
public_metrics: UserMetrics {
followers_count: followers,
following_count: 100,
tweet_count: 500,
},
}
}
#[tokio::test]
async fn e2e_discover_uses_toolkit_search() {
let db = storage::init_test_db().await.unwrap();
let tweets = vec![
sample_tweet("t1", "Learning Rust async programming today", "a1"),
sample_tweet("t2", "Best practices for Rust error handling", "a2"),
];
let users = vec![
sample_user("a1", "rustdev", 3000),
sample_user("a2", "sysdev", 5000),
];
let client = E2eXApiClient::with_data(tweets, users);
let config = test_config();
let output = discover::execute(
&db,
&client,
&config,
DiscoverInput {
query: Some("rust programming".to_string()),
min_score: Some(0.0), limit: Some(10),
since_id: None,
},
)
.await
.unwrap();
assert_eq!(output.candidates.len(), 2);
assert_eq!(output.query_used, "rust programming");
for c in &output.candidates {
assert!(c.score_total > 0.0, "score should be positive");
assert!(!c.author_username.is_empty());
}
}
#[tokio::test]
async fn e2e_publish_reply_uses_toolkit() {
let client = E2eXApiClient::with_data(vec![], vec![]);
let output = publish::reply(&client, "Great insight!", "t1")
.await
.unwrap();
assert_eq!(output.tweet_id, "reply_e2e");
}
#[tokio::test]
async fn e2e_publish_tweet_uses_toolkit() {
let client = E2eXApiClient::with_data(vec![], vec![]);
let output = publish::tweet(&client, "Check out this tool!")
.await
.unwrap();
assert_eq!(output.tweet_id, "posted_e2e");
}
#[tokio::test]
async fn e2e_full_pipeline_with_approval() {
let db = storage::init_test_db().await.unwrap();
let tweets = vec![sample_tweet(
"t1",
"How do you handle errors in Rust programming?",
"a1",
)];
let users = vec![sample_user("a1", "curious_dev", 2500)];
let client = E2eXApiClient::with_data(tweets, users);
let llm: Arc<dyn LlmProvider> = Arc::new(E2eLlm::new(
"Use the ? operator for clean error propagation!",
));
let mut config = test_config();
config.approval_mode = true;
let report = orchestrate::run_discovery_cycle(
&db,
&client,
&llm,
&config,
CycleInput {
query: Some("rust error handling".to_string()),
min_score: Some(0.0),
limit: Some(10),
since_id: None,
mention_product: false,
},
)
.await
.unwrap();
assert!(report.summary.candidates_found > 0);
let has_queued = report
.queued
.iter()
.any(|r| matches!(r, ProposeResult::Queued { .. }));
let has_executed = report
.queued
.iter()
.any(|r| matches!(r, ProposeResult::Executed { .. }));
assert!(
has_queued || has_executed || report.summary.drafts_generated > 0,
"pipeline should produce output for valid candidates"
);
}
#[tokio::test]
async fn e2e_empty_search_graceful() {
let db = storage::init_test_db().await.unwrap();
let client = E2eXApiClient::with_data(vec![], vec![]);
let llm: Arc<dyn LlmProvider> = Arc::new(E2eLlm::new("reply"));
let config = test_config();
let report = orchestrate::run_discovery_cycle(
&db,
&client,
&llm,
&config,
CycleInput {
query: Some("nonexistent topic".to_string()),
min_score: None,
limit: None,
since_id: None,
mention_product: false,
},
)
.await
.unwrap();
assert_eq!(report.summary.candidates_found, 0);
assert!(report.drafts.is_empty());
assert!(report.queued.is_empty());
}
#[tokio::test]
async fn e2e_thread_plan_generates_structure() {
let llm: Arc<dyn LlmProvider> = Arc::new(E2eLlm::new(
"Here's how Rust handles memory\n---\n\
The ownership system is key\n---\n\
Borrowing lets you share references safely\n---\n\
The borrow checker catches issues at compile time\n---\n\
Result: zero-cost abstractions with safety guarantees",
));
let config = test_config();
let output = thread_plan::execute(
&llm,
&config,
ThreadPlanInput {
topic: "Rust memory safety".to_string(),
objective: Some("educate".to_string()),
target_audience: Some("developers".to_string()),
structure: None,
},
)
.await
.unwrap();
assert!(
output.tweet_count >= 2,
"thread should have multiple tweets"
);
assert!(!output.hook_type.is_empty(), "hook type should be detected");
}
#[tokio::test]
async fn e2e_workflow_error_from_toolkit_search_failure() {
struct FailingClient;
#[async_trait::async_trait]
impl XApiClient for FailingClient {
async fn search_tweets(
&self,
_: &str,
_: u32,
_: Option<&str>,
_: Option<&str>,
) -> Result<SearchResponse, XApiError> {
Err(XApiError::RateLimited {
retry_after: Some(60),
})
}
async fn get_mentions(
&self,
_: &str,
_: Option<&str>,
_: Option<&str>,
) -> Result<MentionResponse, XApiError> {
Err(XApiError::AuthExpired)
}
async fn post_tweet(&self, _: &str) -> Result<PostedTweet, XApiError> {
Err(XApiError::AuthExpired)
}
async fn reply_to_tweet(&self, _: &str, _: &str) -> Result<PostedTweet, XApiError> {
Err(XApiError::AuthExpired)
}
async fn get_tweet(&self, _: &str) -> Result<Tweet, XApiError> {
Err(XApiError::AuthExpired)
}
async fn get_me(&self) -> Result<User, XApiError> {
Err(XApiError::AuthExpired)
}
async fn get_user_tweets(
&self,
_: &str,
_: u32,
_: Option<&str>,
) -> Result<SearchResponse, XApiError> {
Err(XApiError::AuthExpired)
}
async fn get_user_by_username(&self, _: &str) -> Result<User, XApiError> {
Err(XApiError::AuthExpired)
}
}
let db = storage::init_test_db().await.unwrap();
let config = test_config();
let err = discover::execute(
&db,
&FailingClient,
&config,
DiscoverInput {
query: Some("test".to_string()),
min_score: None,
limit: None,
since_id: None,
},
)
.await
.unwrap_err();
assert!(
matches!(err, WorkflowError::Toolkit(_)),
"expected Toolkit error variant, got: {err:?}"
);
}
#[tokio::test]
async fn e2e_draft_empty_candidates_returns_validation_error() {
let db = storage::init_test_db().await.unwrap();
let config = test_config();
let llm: Arc<dyn LlmProvider> = Arc::new(E2eLlm::new("reply"));
let err = draft::execute(
&db,
&llm,
&config,
DraftInput {
candidate_ids: vec![],
archetype: None,
mention_product: false,
account_id: None,
},
)
.await
.unwrap_err();
assert!(
matches!(err, WorkflowError::InvalidInput(_)),
"expected InvalidInput, got: {err:?}"
);
}
}