diff --git a/crates/zclaw-hands/src/hand.rs b/crates/zclaw-hands/src/hand.rs index 2399413..5a4b255 100644 --- a/crates/zclaw-hands/src/hand.rs +++ b/crates/zclaw-hands/src/hand.rs @@ -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, diff --git a/crates/zclaw-hands/src/hands/twitter.rs b/crates/zclaw-hands/src/hands/twitter.rs index a4cbf04..4a2dc19 100644 --- a/crates/zclaw-hands/src/hands/twitter.rs +++ b/crates/zclaw-hands/src/hands/twitter.rs @@ -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::(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); + } +}