test(hands): add 28 unit tests for Twitter Hand
Cover config defaults, 13 action types deserialization, serialization roundtrip, credential management, and data type parsing. Also add PartialEq derive to HandStatus for test assertions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -111,7 +111,7 @@ impl HandResult {
|
||||
}
|
||||
|
||||
/// Hand execution status
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HandStatus {
|
||||
Idle,
|
||||
|
||||
@@ -823,3 +823,417 @@ impl Hand for TwitterHand {
|
||||
crate::HandStatus::Idle
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Hand;
|
||||
use zclaw_types::id::AgentId;
|
||||
|
||||
fn make_context() -> HandContext {
|
||||
HandContext {
|
||||
agent_id: AgentId::new(),
|
||||
working_dir: None,
|
||||
env: std::collections::HashMap::new(),
|
||||
timeout_secs: 30,
|
||||
callback_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
// === Config & Defaults ===
|
||||
|
||||
#[test]
|
||||
fn test_hand_config() {
|
||||
let hand = TwitterHand::new();
|
||||
assert_eq!(hand.config().id, "twitter");
|
||||
assert_eq!(hand.config().name, "Twitter 自动化");
|
||||
assert!(hand.config().needs_approval);
|
||||
assert!(hand.config().enabled);
|
||||
assert!(hand.config().tags.contains(&"twitter".to_string()));
|
||||
assert!(hand.config().input_schema.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_impl() {
|
||||
let hand = TwitterHand::default();
|
||||
assert_eq!(hand.config().id, "twitter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_needs_approval() {
|
||||
let hand = TwitterHand::new();
|
||||
assert!(hand.needs_approval());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status() {
|
||||
let hand = TwitterHand::new();
|
||||
assert_eq!(hand.status(), crate::HandStatus::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_dependencies() {
|
||||
let hand = TwitterHand::new();
|
||||
let deps = hand.check_dependencies().unwrap();
|
||||
assert!(!deps.is_empty());
|
||||
}
|
||||
|
||||
// === Action Deserialization ===
|
||||
|
||||
#[test]
|
||||
fn test_tweet_action_deserialize() {
|
||||
let json = json!({
|
||||
"action": "tweet",
|
||||
"config": {
|
||||
"text": "Hello world!"
|
||||
}
|
||||
});
|
||||
let action: TwitterAction = serde_json::from_value(json).unwrap();
|
||||
match action {
|
||||
TwitterAction::Tweet { config } => {
|
||||
assert_eq!(config.text, "Hello world!");
|
||||
assert!(config.media_urls.is_empty());
|
||||
assert!(config.reply_to.is_none());
|
||||
assert!(config.quote_tweet.is_none());
|
||||
assert!(config.poll.is_none());
|
||||
}
|
||||
_ => panic!("Expected Tweet action"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tweet_action_with_reply() {
|
||||
let json = json!({
|
||||
"action": "tweet",
|
||||
"config": {
|
||||
"text": "@user reply",
|
||||
"replyTo": "123456"
|
||||
}
|
||||
});
|
||||
let action: TwitterAction = serde_json::from_value(json).unwrap();
|
||||
match action {
|
||||
TwitterAction::Tweet { config } => {
|
||||
assert_eq!(config.reply_to.as_deref(), Some("123456"));
|
||||
}
|
||||
_ => panic!("Expected Tweet action"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tweet_action_with_poll() {
|
||||
let json = json!({
|
||||
"action": "tweet",
|
||||
"config": {
|
||||
"text": "Vote!",
|
||||
"poll": {
|
||||
"options": ["A", "B", "C"],
|
||||
"durationMinutes": 60
|
||||
}
|
||||
}
|
||||
});
|
||||
let action: TwitterAction = serde_json::from_value(json).unwrap();
|
||||
match action {
|
||||
TwitterAction::Tweet { config } => {
|
||||
let poll = config.poll.unwrap();
|
||||
assert_eq!(poll.options, vec!["A", "B", "C"]);
|
||||
assert_eq!(poll.duration_minutes, 60);
|
||||
}
|
||||
_ => panic!("Expected Tweet action"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_tweet_action() {
|
||||
let json = json!({"action": "delete_tweet", "tweet_id": "789"});
|
||||
let action: TwitterAction = serde_json::from_value(json).unwrap();
|
||||
match action {
|
||||
TwitterAction::DeleteTweet { tweet_id } => assert_eq!(tweet_id, "789"),
|
||||
_ => panic!("Expected DeleteTweet"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_like_unlike_actions() {
|
||||
let like: TwitterAction = serde_json::from_value(json!({"action": "like", "tweet_id": "111"})).unwrap();
|
||||
match like {
|
||||
TwitterAction::Like { tweet_id } => assert_eq!(tweet_id, "111"),
|
||||
_ => panic!("Expected Like"),
|
||||
}
|
||||
|
||||
let unlike: TwitterAction = serde_json::from_value(json!({"action": "unlike", "tweet_id": "111"})).unwrap();
|
||||
match unlike {
|
||||
TwitterAction::Unlike { tweet_id } => assert_eq!(tweet_id, "111"),
|
||||
_ => panic!("Expected Unlike"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retweet_unretweet_actions() {
|
||||
let rt: TwitterAction = serde_json::from_value(json!({"action": "retweet", "tweet_id": "222"})).unwrap();
|
||||
match rt {
|
||||
TwitterAction::Retweet { tweet_id } => assert_eq!(tweet_id, "222"),
|
||||
_ => panic!("Expected Retweet"),
|
||||
}
|
||||
|
||||
let unrt: TwitterAction = serde_json::from_value(json!({"action": "unretweet", "tweet_id": "222"})).unwrap();
|
||||
match unrt {
|
||||
TwitterAction::Unretweet { tweet_id } => assert_eq!(tweet_id, "222"),
|
||||
_ => panic!("Expected Unretweet"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_action_defaults() {
|
||||
let json = json!({"action": "search", "config": {"query": "rust lang"}});
|
||||
let action: TwitterAction = serde_json::from_value(json).unwrap();
|
||||
match action {
|
||||
TwitterAction::Search { config } => {
|
||||
assert_eq!(config.query, "rust lang");
|
||||
assert_eq!(config.max_results, 10); // default
|
||||
assert!(config.next_token.is_none());
|
||||
}
|
||||
_ => panic!("Expected Search"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_action_custom_max() {
|
||||
let json = json!({"action": "search", "config": {"query": "test", "maxResults": 50}});
|
||||
let action: TwitterAction = serde_json::from_value(json).unwrap();
|
||||
match action {
|
||||
TwitterAction::Search { config } => assert_eq!(config.max_results, 50),
|
||||
_ => panic!("Expected Search"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timeline_action_defaults() {
|
||||
let json = json!({"action": "timeline", "config": {}});
|
||||
let action: TwitterAction = serde_json::from_value(json).unwrap();
|
||||
match action {
|
||||
TwitterAction::Timeline { config } => {
|
||||
assert!(config.user_id.is_none());
|
||||
assert_eq!(config.max_results, 10); // default
|
||||
assert!(!config.exclude_replies);
|
||||
assert!(config.include_retweets);
|
||||
}
|
||||
_ => panic!("Expected Timeline"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_tweet_action() {
|
||||
let json = json!({"action": "get_tweet", "tweet_id": "999"});
|
||||
let action: TwitterAction = serde_json::from_value(json).unwrap();
|
||||
match action {
|
||||
TwitterAction::GetTweet { tweet_id } => assert_eq!(tweet_id, "999"),
|
||||
_ => panic!("Expected GetTweet"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_user_action() {
|
||||
let json = json!({"action": "get_user", "username": "elonmusk"});
|
||||
let action: TwitterAction = serde_json::from_value(json).unwrap();
|
||||
match action {
|
||||
TwitterAction::GetUser { username } => assert_eq!(username, "elonmusk"),
|
||||
_ => panic!("Expected GetUser"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_followers_action() {
|
||||
let json = json!({"action": "followers", "user_id": "u1", "max_results": 50});
|
||||
let action: TwitterAction = serde_json::from_value(json).unwrap();
|
||||
match action {
|
||||
TwitterAction::Followers { user_id, max_results } => {
|
||||
assert_eq!(user_id, "u1");
|
||||
assert_eq!(max_results, Some(50));
|
||||
}
|
||||
_ => panic!("Expected Followers"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_following_action_no_max() {
|
||||
let json = json!({"action": "following", "user_id": "u2"});
|
||||
let action: TwitterAction = serde_json::from_value(json).unwrap();
|
||||
match action {
|
||||
TwitterAction::Following { user_id, max_results } => {
|
||||
assert_eq!(user_id, "u2");
|
||||
assert!(max_results.is_none());
|
||||
}
|
||||
_ => panic!("Expected Following"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_credentials_action() {
|
||||
let json = json!({"action": "check_credentials"});
|
||||
let action: TwitterAction = serde_json::from_value(json).unwrap();
|
||||
match action {
|
||||
TwitterAction::CheckCredentials => {}
|
||||
_ => panic!("Expected CheckCredentials"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_action() {
|
||||
let json = json!({"action": "invalid_action"});
|
||||
let result = serde_json::from_value::<TwitterAction>(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// === Serialization Roundtrip ===
|
||||
|
||||
#[test]
|
||||
fn test_tweet_action_roundtrip() {
|
||||
let json = json!({
|
||||
"action": "tweet",
|
||||
"config": {
|
||||
"text": "Test tweet",
|
||||
"mediaUrls": ["https://example.com/img.jpg"],
|
||||
"replyTo": "123",
|
||||
"quoteTweet": "456"
|
||||
}
|
||||
});
|
||||
let action: TwitterAction = serde_json::from_value(json).unwrap();
|
||||
let serialized = serde_json::to_value(&action).unwrap();
|
||||
// Verify core fields survive roundtrip (camelCase via serde rename)
|
||||
assert_eq!(serialized["action"], "tweet");
|
||||
assert_eq!(serialized["config"]["text"], "Test tweet");
|
||||
assert_eq!(serialized["config"]["mediaUrls"][0], "https://example.com/img.jpg");
|
||||
assert_eq!(serialized["config"]["replyTo"], "123");
|
||||
assert_eq!(serialized["config"]["quoteTweet"], "456");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_action_roundtrip() {
|
||||
let json = json!({
|
||||
"action": "search",
|
||||
"config": {
|
||||
"query": "hello world",
|
||||
"maxResults": 25
|
||||
}
|
||||
});
|
||||
let action: TwitterAction = serde_json::from_value(json).unwrap();
|
||||
let serialized = serde_json::to_value(&action).unwrap();
|
||||
assert_eq!(serialized["action"], "search");
|
||||
assert_eq!(serialized["config"]["query"], "hello world");
|
||||
assert_eq!(serialized["config"]["maxResults"], 25);
|
||||
}
|
||||
|
||||
// === Credentials ===
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_set_and_get_credentials() {
|
||||
let hand = TwitterHand::new();
|
||||
|
||||
// Initially no credentials
|
||||
assert!(hand.get_credentials().await.is_none());
|
||||
|
||||
// Set credentials
|
||||
hand.set_credentials(TwitterCredentials {
|
||||
api_key: "key".into(),
|
||||
api_secret: "secret".into(),
|
||||
access_token: "token".into(),
|
||||
access_token_secret: "token_secret".into(),
|
||||
bearer_token: Some("bearer".into()),
|
||||
}).await;
|
||||
|
||||
let creds = hand.get_credentials().await.unwrap();
|
||||
assert_eq!(creds.api_key, "key");
|
||||
assert_eq!(creds.bearer_token.as_deref(), Some("bearer"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_check_credentials_without_config() {
|
||||
let hand = TwitterHand::new();
|
||||
let ctx = make_context();
|
||||
let result = hand.execute(&ctx, json!({"action": "check_credentials"})).await.unwrap();
|
||||
|
||||
// No "success" field in output → HandResult.success defaults to false
|
||||
assert!(!result.success);
|
||||
assert_eq!(result.output["configured"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_check_credentials_with_config() {
|
||||
let hand = TwitterHand::new();
|
||||
hand.set_credentials(TwitterCredentials {
|
||||
api_key: "key".into(),
|
||||
api_secret: "secret".into(),
|
||||
access_token: "token".into(),
|
||||
access_token_secret: "token_secret".into(),
|
||||
bearer_token: Some("bearer".into()),
|
||||
}).await;
|
||||
|
||||
let ctx = make_context();
|
||||
let result = hand.execute(&ctx, json!({"action": "check_credentials"})).await.unwrap();
|
||||
|
||||
// execute_check_credentials returns {"configured": true, ...} without "success" field
|
||||
// HandResult.success = result["success"].as_bool().unwrap_or(false) = false
|
||||
// But the actual data is in output
|
||||
assert_eq!(result.output["configured"], true);
|
||||
assert_eq!(result.output["has_bearer_token"], true);
|
||||
}
|
||||
|
||||
// === Tweet Data Types ===
|
||||
|
||||
#[test]
|
||||
fn test_tweet_deserialize() {
|
||||
let json = json!({
|
||||
"id": "t123",
|
||||
"text": "Hello!",
|
||||
"authorId": "a456",
|
||||
"authorName": "Test User",
|
||||
"authorUsername": "testuser",
|
||||
"createdAt": "2026-01-01T00:00:00Z",
|
||||
"publicMetrics": {
|
||||
"retweetCount": 5,
|
||||
"replyCount": 2,
|
||||
"likeCount": 10,
|
||||
"quoteCount": 1,
|
||||
"impressionCount": 1000
|
||||
}
|
||||
});
|
||||
let tweet: Tweet = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(tweet.id, "t123");
|
||||
assert_eq!(tweet.public_metrics.like_count, 10);
|
||||
assert!(tweet.media.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_twitter_user_deserialize() {
|
||||
let json = json!({
|
||||
"id": "u1",
|
||||
"name": "Alice",
|
||||
"username": "alice",
|
||||
"verified": true,
|
||||
"publicMetrics": {
|
||||
"followersCount": 100,
|
||||
"followingCount": 50,
|
||||
"tweetCount": 1000,
|
||||
"listedCount": 5
|
||||
}
|
||||
});
|
||||
let user: TwitterUser = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(user.username, "alice");
|
||||
assert!(user.verified);
|
||||
assert_eq!(user.public_metrics.followers_count, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_media_info_deserialize() {
|
||||
let json = json!({
|
||||
"mediaKey": "mk1",
|
||||
"mediaType": "photo",
|
||||
"url": "https://pbs.example.com/photo.jpg",
|
||||
"width": 1200,
|
||||
"height": 800
|
||||
});
|
||||
let media: MediaInfo = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(media.media_type, "photo");
|
||||
assert_eq!(media.width, 1200);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user