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:
iven
2026-04-02 01:01:37 +08:00
parent c8dc654fd4
commit dce9035584
2 changed files with 415 additions and 1 deletions

View File

@@ -111,7 +111,7 @@ impl HandResult {
} }
/// Hand execution status /// Hand execution status
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum HandStatus { pub enum HandStatus {
Idle, Idle,

View File

@@ -823,3 +823,417 @@ impl Hand for TwitterHand {
crate::HandStatus::Idle 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);
}
}