use std::sync::Arc;
use crate::config::{Config, McpPolicyConfig};
use crate::error::XApiError;
use crate::llm::{GenerationParams, LlmProvider, LlmResponse};
use crate::storage;
use crate::storage::tweets::DiscoveredTweet;
use crate::x_api::types::*;
use crate::x_api::XApiClient;
use crate::LlmError;
use super::*;
struct MockXApiClient {
tweets: Vec<Tweet>,
users: Vec<User>,
}
impl MockXApiClient {
fn with_results(tweets: Vec<Tweet>, users: Vec<User>) -> Self {
Self { tweets, users }
}
fn empty() -> Self {
Self {
tweets: vec![],
users: vec![],
}
}
}
#[async_trait::async_trait]
impl XApiClient for MockXApiClient {
async fn search_tweets(
&self,
_query: &str,
_max_results: u32,
_since_id: Option<&str>,
_pagination_token: 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,
_user_id: &str,
_since_id: Option<&str>,
_pagination_token: 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_1".to_string(),
text: text.to_string(),
})
}
async fn reply_to_tweet(
&self,
text: &str,
_in_reply_to_id: &str,
) -> Result<PostedTweet, XApiError> {
Ok(PostedTweet {
id: "reply_1".to_string(),
text: text.to_string(),
})
}
async fn get_tweet(&self, tweet_id: &str) -> Result<Tweet, XApiError> {
Ok(Tweet {
id: tweet_id.to_string(),
text: "Test tweet".to_string(),
author_id: "a1".to_string(),
created_at: "2026-02-24T00:00:00Z".to_string(),
public_metrics: PublicMetrics::default(),
conversation_id: None,
})
}
async fn get_me(&self) -> Result<User, XApiError> {
Ok(User {
id: "u1".to_string(),
username: "testuser".to_string(),
name: "Test User".to_string(),
profile_image_url: None,
description: None,
location: None,
url: None,
public_metrics: UserMetrics::default(),
})
}
async fn get_user_tweets(
&self,
_user_id: &str,
_max_results: u32,
_pagination_token: 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, username: &str) -> Result<User, XApiError> {
Ok(User {
id: "u2".to_string(),
username: username.to_string(),
name: "Test".to_string(),
profile_image_url: None,
description: None,
location: None,
url: None,
public_metrics: UserMetrics::default(),
})
}
async fn quote_tweet(
&self,
text: &str,
_quoted_tweet_id: &str,
) -> Result<PostedTweet, XApiError> {
Ok(PostedTweet {
id: "qt_1".to_string(),
text: text.to_string(),
})
}
async fn like_tweet(&self, _user_id: &str, _tweet_id: &str) -> Result<bool, XApiError> {
Ok(true)
}
async fn follow_user(&self, _user_id: &str, _target_user_id: &str) -> Result<bool, XApiError> {
Ok(true)
}
async fn unfollow_user(
&self,
_user_id: &str,
_target_user_id: &str,
) -> Result<bool, XApiError> {
Ok(false)
}
}
struct MockLlmProvider {
reply_text: String,
}
impl MockLlmProvider {
fn new(text: &str) -> Self {
Self {
reply_text: text.to_string(),
}
}
}
#[async_trait::async_trait]
impl LlmProvider for MockLlmProvider {
fn name(&self) -> &str {
"mock"
}
async fn complete(
&self,
_system: &str,
_user_message: &str,
_params: &GenerationParams,
) -> Result<LlmResponse, LlmError> {
Ok(LlmResponse {
text: self.reply_text.clone(),
usage: crate::llm::TokenUsage {
input_tokens: 10,
output_tokens: 5,
},
model: "mock-model".to_string(),
})
}
async fn health_check(&self) -> Result<(), LlmError> {
Ok(())
}
}
fn test_config() -> Config {
let mut config = Config::default();
config.mcp_policy = McpPolicyConfig {
enforce_for_mutations: false,
blocked_tools: Vec::new(),
require_approval_for: Vec::new(),
dry_run_mutations: false,
max_mutations_per_hour: 20,
..McpPolicyConfig::default()
};
config.business.product_keywords = vec!["rust".to_string(), "async".to_string()];
config.business.industry_topics = vec!["software engineering".to_string()];
config.scoring.threshold = 0; 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: 10,
retweet_count: 2,
reply_count: 1,
quote_count: 0,
impression_count: 500,
bookmark_count: 0,
},
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,
},
}
}
async fn seed_discovered_tweet(db: &storage::DbPool, id: &str, text: &str, author: &str) {
let tweet = DiscoveredTweet {
id: id.to_string(),
author_id: "a1".to_string(),
author_username: author.to_string(),
content: text.to_string(),
like_count: 10,
retweet_count: 2,
reply_count: 1,
impression_count: Some(500),
relevance_score: Some(75.0),
matched_keyword: Some("rust".to_string()),
discovered_at: "2026-02-24T12:00:00Z".to_string(),
replied_to: 0,
};
storage::tweets::insert_discovered_tweet(db, &tweet)
.await
.expect("seed tweet");
}
mod discover_tests {
use super::*;
#[tokio::test]
async fn happy_path_search_score_rank() {
let db = storage::init_test_db().await.unwrap();
let tweets = vec![
sample_tweet("t1", "Learning rust async programming today", "a1"),
sample_tweet("t2", "Just had coffee", "a2"),
];
let users = vec![
sample_user("a1", "rustdev", 5000),
sample_user("a2", "coffeelover", 200),
];
let client = MockXApiClient::with_results(tweets, users);
let config = test_config();
let output = discover::execute(
&db,
&client,
&config,
DiscoverInput {
query: Some("rust".to_string()),
min_score: None,
limit: Some(10),
since_id: None,
},
)
.await
.unwrap();
assert!(!output.candidates.is_empty());
assert_eq!(output.query_used, "rust");
}
#[tokio::test]
async fn empty_results() {
let db = storage::init_test_db().await.unwrap();
let client = MockXApiClient::empty();
let config = test_config();
let output = discover::execute(
&db,
&client,
&config,
DiscoverInput {
query: Some("rust".to_string()),
min_score: None,
limit: None,
since_id: None,
},
)
.await
.unwrap();
assert!(output.candidates.is_empty());
}
#[tokio::test]
async fn default_query_from_keywords() {
let db = storage::init_test_db().await.unwrap();
let tweets = vec![sample_tweet(
"t1",
"rust async is amazing for async tasks",
"a1",
)];
let users = vec![sample_user("a1", "dev", 1000)];
let client = MockXApiClient::with_results(tweets, users);
let config = test_config();
let output = discover::execute(
&db,
&client,
&config,
DiscoverInput {
query: None, min_score: None,
limit: None,
since_id: None,
},
)
.await
.unwrap();
assert_eq!(output.query_used, "rust OR async");
}
#[tokio::test]
async fn no_query_no_keywords_errors() {
let db = storage::init_test_db().await.unwrap();
let client = MockXApiClient::empty();
let mut config = test_config();
config.business.product_keywords = vec![];
let err = discover::execute(
&db,
&client,
&config,
DiscoverInput {
query: None,
min_score: None,
limit: None,
since_id: None,
},
)
.await
.unwrap_err();
assert!(matches!(err, WorkflowError::InvalidInput(_)));
}
}
mod draft_tests {
use super::*;
#[tokio::test]
async fn happy_path_generate_drafts() {
let db = storage::init_test_db().await.unwrap();
seed_discovered_tweet(
&db,
"t1",
"Rust is great for systems programming",
"rustdev",
)
.await;
let llm: Arc<dyn LlmProvider> = Arc::new(MockLlmProvider::new("Great point about Rust!"));
let config = test_config();
let results = draft::execute(
&db,
&llm,
&config,
DraftInput {
candidate_ids: vec!["t1".to_string()],
archetype: None,
mention_product: false,
account_id: None,
},
)
.await
.unwrap();
assert_eq!(results.len(), 1);
match &results[0] {
DraftResult::Success { draft_text, .. } => {
assert_eq!(draft_text, "Great point about Rust!");
}
DraftResult::Error { error_message, .. } => {
panic!("Expected success, got error: {error_message}");
}
}
}
#[tokio::test]
async fn candidate_not_found() {
let db = storage::init_test_db().await.unwrap();
let llm: Arc<dyn LlmProvider> = Arc::new(MockLlmProvider::new("Reply"));
let config = test_config();
let results = draft::execute(
&db,
&llm,
&config,
DraftInput {
candidate_ids: vec!["nonexistent".to_string()],
archetype: None,
mention_product: false,
account_id: None,
},
)
.await
.unwrap();
assert_eq!(results.len(), 1);
assert!(
matches!(&results[0], DraftResult::Error { error_code, .. } if error_code == "not_found")
);
}
#[tokio::test]
async fn empty_input_errors() {
let db = storage::init_test_db().await.unwrap();
let llm: Arc<dyn LlmProvider> = Arc::new(MockLlmProvider::new("Reply"));
let config = test_config();
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(_)));
}
}
mod queue_tests {
use super::*;
#[tokio::test]
async fn queues_in_approval_mode() {
let db = storage::init_test_db().await.unwrap();
seed_discovered_tweet(&db, "t1", "Rust topic", "dev").await;
let llm: Arc<dyn LlmProvider> = Arc::new(MockLlmProvider::new("Great insight!"));
let client = MockXApiClient::empty();
let mut config = test_config();
config.approval_mode = true;
let results = queue::execute(
&db,
Some(&client as &dyn XApiClient),
Some(&llm),
&config,
QueueInput {
items: vec![QueueItem {
candidate_id: "t1".to_string(),
pre_drafted_text: Some("This is my reply!".to_string()),
}],
mention_product: false,
},
)
.await
.unwrap();
assert_eq!(results.len(), 1);
assert!(matches!(&results[0], ProposeResult::Queued { .. }));
}
#[tokio::test]
async fn executes_in_autopilot_mode() {
let db = storage::init_test_db().await.unwrap();
seed_discovered_tweet(&db, "t1", "Rust topic", "dev").await;
let client = MockXApiClient::empty(); let mut config = test_config();
config.approval_mode = false;
let results = queue::execute(
&db,
Some(&client as &dyn XApiClient),
None,
&config,
QueueInput {
items: vec![QueueItem {
candidate_id: "t1".to_string(),
pre_drafted_text: Some("Direct reply!".to_string()),
}],
mention_product: false,
},
)
.await
.unwrap();
assert_eq!(results.len(), 1);
match &results[0] {
ProposeResult::Executed { reply_tweet_id, .. } => {
assert_eq!(reply_tweet_id, "reply_1");
}
other => panic!("Expected Executed, got {other:?}"),
}
}
#[tokio::test]
async fn tweet_not_found_blocked() {
let db = storage::init_test_db().await.unwrap();
let config = test_config();
let results = queue::execute(
&db,
None,
None,
&config,
QueueInput {
items: vec![QueueItem {
candidate_id: "nonexistent".to_string(),
pre_drafted_text: Some("reply".to_string()),
}],
mention_product: false,
},
)
.await
.unwrap();
assert_eq!(results.len(), 1);
assert!(matches!(&results[0], ProposeResult::Blocked { .. }));
}
#[tokio::test]
async fn empty_items_errors() {
let db = storage::init_test_db().await.unwrap();
let config = test_config();
let err = queue::execute(
&db,
None,
None,
&config,
QueueInput {
items: vec![],
mention_product: false,
},
)
.await
.unwrap_err();
assert!(matches!(err, WorkflowError::InvalidInput(_)));
}
}
mod thread_plan_tests {
use super::*;
fn valid_thread_text() -> &'static str {
"Most people think async is hard\n---\n\
But the reality is simpler than you think\n---\n\
Step one: understand the event loop\n---\n\
Step two: learn about futures and polling\n---\n\
Step three: build something real and iterate"
}
#[tokio::test]
async fn happy_path_generates_thread() {
let llm: Arc<dyn LlmProvider> = Arc::new(MockLlmProvider::new(valid_thread_text()));
let config = test_config();
let output = thread_plan::execute(
&llm,
&config,
ThreadPlanInput {
topic: "software engineering".to_string(),
objective: Some("establish expertise".to_string()),
target_audience: Some("developers".to_string()),
structure: None,
},
)
.await
.unwrap();
assert_eq!(output.tweet_count, 5);
assert_eq!(output.estimated_performance, "high");
assert_eq!(output.hook_type, "contrarian"); }
#[tokio::test]
async fn novel_topic_medium_performance() {
let llm: Arc<dyn LlmProvider> = Arc::new(MockLlmProvider::new(valid_thread_text()));
let config = test_config();
let output = thread_plan::execute(
&llm,
&config,
ThreadPlanInput {
topic: "cooking recipes".to_string(),
objective: None,
target_audience: None,
structure: None,
},
)
.await
.unwrap();
assert_eq!(output.estimated_performance, "medium");
assert_eq!(output.topic_relevance, "novel_topic");
}
}
mod orchestrate_tests {
use super::*;
#[tokio::test]
async fn full_cycle_discover_draft_queue() {
let db = storage::init_test_db().await.unwrap();
let tweets = vec![sample_tweet(
"t1",
"Learning rust async programming today",
"a1",
)];
let users = vec![sample_user("a1", "rustdev", 5000)];
let client = MockXApiClient::with_results(tweets, users);
let llm: Arc<dyn LlmProvider> = Arc::new(MockLlmProvider::new("Great point about Rust!"));
let mut config = test_config();
config.approval_mode = true;
let report = orchestrate::run_discovery_cycle(
&db,
&client,
&llm,
&config,
CycleInput {
query: Some("rust".to_string()),
min_score: None,
limit: Some(10),
since_id: None,
mention_product: false,
},
)
.await
.unwrap();
assert!(report.summary.candidates_found > 0);
assert!(report.summary.drafts_generated > 0 || report.summary.drafts_failed > 0);
}
#[tokio::test]
async fn empty_search_returns_empty_report() {
let db = storage::init_test_db().await.unwrap();
let client = MockXApiClient::empty();
let llm: Arc<dyn LlmProvider> = Arc::new(MockLlmProvider::new("Reply"));
let config = test_config();
let report = orchestrate::run_discovery_cycle(
&db,
&client,
&llm,
&config,
CycleInput {
query: Some("rust".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());
}
}
mod publish_tests {
use super::*;
#[tokio::test]
async fn publish_reply_through_toolkit() {
let client = MockXApiClient::empty();
let output = publish::reply(&client, "Great point!", "t1").await.unwrap();
assert_eq!(output.tweet_id, "reply_1");
assert_eq!(output.text, "Great point!");
}
#[tokio::test]
async fn publish_tweet_through_toolkit() {
let client = MockXApiClient::empty();
let output = publish::tweet(&client, "Hello world!").await.unwrap();
assert_eq!(output.tweet_id, "posted_1");
assert_eq!(output.text, "Hello world!");
}
}
mod approval_scheduling_tests {
use super::*;
#[tokio::test]
async fn autopilot_approval_on_queues_with_pending_status() {
let db = storage::init_test_db().await.unwrap();
seed_discovered_tweet(&db, "t1", "Rust topic", "dev").await;
let llm: Arc<dyn LlmProvider> = Arc::new(MockLlmProvider::new("Insightful!"));
let client = MockXApiClient::empty();
let mut config = test_config();
config.approval_mode = true;
let results = queue::execute(
&db,
Some(&client as &dyn XApiClient),
Some(&llm),
&config,
QueueInput {
items: vec![QueueItem {
candidate_id: "t1".to_string(),
pre_drafted_text: Some("Scheduled reply".to_string()),
}],
mention_product: false,
},
)
.await
.unwrap();
assert_eq!(results.len(), 1);
assert!(matches!(&results[0], ProposeResult::Queued { .. }));
let pending = storage::approval_queue::get_pending(&db).await.unwrap();
assert!(!pending.is_empty());
assert_eq!(pending[0].status, "pending");
}
#[tokio::test]
async fn autopilot_approval_off_executes_immediately() {
let db = storage::init_test_db().await.unwrap();
seed_discovered_tweet(&db, "t1", "Rust topic", "dev").await;
let client = MockXApiClient::empty();
let mut config = test_config();
config.approval_mode = false;
let results = queue::execute(
&db,
Some(&client as &dyn XApiClient),
None,
&config,
QueueInput {
items: vec![QueueItem {
candidate_id: "t1".to_string(),
pre_drafted_text: Some("Direct post".to_string()),
}],
mention_product: false,
},
)
.await
.unwrap();
assert_eq!(results.len(), 1);
match &results[0] {
ProposeResult::Executed { reply_tweet_id, .. } => {
assert_eq!(reply_tweet_id, "reply_1");
}
other => panic!("Expected Executed, got {other:?}"),
}
let pending = storage::approval_queue::get_pending(&db).await.unwrap();
assert!(pending.is_empty());
}
#[tokio::test]
async fn scheduled_for_preserved_through_enqueue() {
let db = storage::init_test_db().await.unwrap();
let id = storage::approval_queue::enqueue_with_context_for(
&db,
storage::accounts::DEFAULT_ACCOUNT_ID,
"tweet",
"",
"",
"Scheduled tweet content",
"Topic",
"",
0.0,
"[]",
None,
None,
Some("2026-03-15T14:00:00Z"),
)
.await
.unwrap();
let item = storage::approval_queue::get_by_id(&db, id)
.await
.unwrap()
.unwrap();
assert_eq!(item.scheduled_for.as_deref(), Some("2026-03-15T14:00:00Z"));
assert_eq!(item.status, "pending");
}
#[tokio::test]
async fn approval_mode_preserves_schedule_across_status_changes() {
let db = storage::init_test_db().await.unwrap();
let id = storage::approval_queue::enqueue_with_context_for(
&db,
storage::accounts::DEFAULT_ACCOUNT_ID,
"tweet",
"",
"",
"Content with schedule",
"",
"",
0.0,
"[]",
None,
None,
Some("2026-04-01T10:00:00Z"),
)
.await
.unwrap();
let review = storage::approval_queue::ReviewAction {
actor: Some("tester".to_string()),
notes: None,
};
storage::approval_queue::update_status_with_review(&db, id, "approved", &review)
.await
.unwrap();
let item = storage::approval_queue::get_by_id(&db, id)
.await
.unwrap()
.unwrap();
assert_eq!(item.status, "approved");
assert_eq!(item.scheduled_for.as_deref(), Some("2026-04-01T10:00:00Z"));
}
}
mod error_tests {
use super::*;
#[test]
fn workflow_error_from_toolkit() {
let toolkit_err = crate::toolkit::ToolkitError::InvalidInput {
message: "bad input".to_string(),
};
let workflow_err: WorkflowError = toolkit_err.into();
assert!(matches!(workflow_err, WorkflowError::Toolkit(_)));
}
#[test]
fn workflow_error_from_llm() {
let llm_err = LlmError::NotConfigured;
let workflow_err: WorkflowError = llm_err.into();
assert!(matches!(workflow_err, WorkflowError::Llm(_)));
}
#[test]
fn workflow_error_display() {
let err = WorkflowError::InvalidInput("test error".to_string());
assert_eq!(err.to_string(), "invalid input: test error");
}
}