diff --git a/lexica/src/app_bsky/feed.rs b/lexica/src/app_bsky/feed.rs index 2fa76077..203668f7 100644 --- a/lexica/src/app_bsky/feed.rs +++ b/lexica/src/app_bsky/feed.rs @@ -44,7 +44,7 @@ pub struct PostView { pub indexed_at: DateTime, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FeedViewPost { pub post: PostView, @@ -56,7 +56,7 @@ pub struct FeedViewPost { pub feed_context: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReplyRef { pub root: ReplyRefPost, @@ -65,7 +65,7 @@ pub struct ReplyRef { pub grandparent_author: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "$type")] pub enum ReplyRefPost { #[serde(rename = "app.bsky.feed.defs#postView")] @@ -84,7 +84,7 @@ pub enum ReplyRefPost { }, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "$type")] pub enum FeedViewPostReason { #[serde(rename = "app.bsky.feed.defs#reasonRepost")] @@ -93,7 +93,7 @@ pub enum FeedViewPostReason { Pin, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FeedReasonRepost { pub by: ProfileViewBasic, @@ -298,3 +298,23 @@ pub struct Interaction { pub struct SendInteractionsRequest { pub interactions: Vec, } + +// ============================================================================ +// Request/Response Types for Feed Endpoints +// ============================================================================ + +/// Response from getPosts +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetPostsResponse { + pub posts: Vec, +} + +/// Response from getAuthorFeed +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetAuthorFeedResponse { + pub feed: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} diff --git a/lexica/tests/actor_types_test.rs b/lexica/tests/actor_types_test.rs new file mode 100644 index 00000000..c5d084c4 --- /dev/null +++ b/lexica/tests/actor_types_test.rs @@ -0,0 +1,322 @@ +//! Tests for app.bsky.actor types against actual API responses +//! +//! These tests verify that our types can correctly deserialize +//! real responses from the Bluesky API. + +use lexica::app_bsky::actor::*; +use serde_json; + +#[test] +fn test_profile_view_detailed_deserialization() { + // This is a real response from the Bluesky API (with some fields simplified) + let json = r#"{ + "did": "did:plc:jrtgsidnmxaen4offglr5lsh", + "handle": "quilling.dev", + "displayName": "teq", + "description": "torment nexus dismantler", + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:jrtgsidnmxaen4offglr5lsh/bafkreigwqzlvyzspam75vsrenyew265cud6qw4kbeuvpwndl2qr3myb6ye@jpeg", + "banner": "https://cdn.bsky.app/img/banner/plain/did:plc:jrtgsidnmxaen4offglr5lsh/bafkreib2eya5gp5uqycbxefbugkzfgz2qwgs26fi7htpexc2krdsv2dwqe@jpeg", + "followersCount": 1527, + "followsCount": 417, + "postsCount": 6142, + "associated": { + "lists": 0, + "feedgens": 1, + "starterPacks": 0, + "labeler": false, + "chat": { + "allowIncoming": "all" + }, + "activitySubscription": { + "allowSubscriptions": "followers" + } + }, + "labels": [], + "createdAt": "2023-12-08T15:37:09.656Z", + "indexedAt": "2025-11-29T03:29:57.825Z", + "pinnedPost": { + "cid": "bafyreie4lqcc65pp7f62eix5byyjwb5y5z2pookouff2d4zdwfb7ub4tu4", + "uri": "at://did:plc:jrtgsidnmxaen4offglr5lsh/app.bsky.feed.post/3lx6gs6lq5k2y" + } + }"#; + + let profile: ProfileViewDetailed = serde_json::from_str(json).expect("Failed to deserialize ProfileViewDetailed"); + + assert_eq!(profile.did, "did:plc:jrtgsidnmxaen4offglr5lsh"); + assert_eq!(profile.handle, "quilling.dev"); + assert_eq!(profile.display_name.as_deref(), Some("teq")); + assert_eq!(profile.followers_count, 1527); + assert_eq!(profile.follows_count, 417); + assert_eq!(profile.posts_count, 6142); + + // Check associated fields + let associated = profile.associated.expect("Should have associated data"); + assert_eq!(associated.lists, Some(0)); + assert_eq!(associated.feedgens, Some(1)); + assert_eq!(associated.labeler, Some(false)); + + // Check pinned post + let pinned = profile.pinned_post.expect("Should have pinned post"); + assert_eq!(pinned.cid.to_string(), "bafyreie4lqcc65pp7f62eix5byyjwb5y5z2pookouff2d4zdwfb7ub4tu4"); + assert_eq!(pinned.uri, "at://did:plc:jrtgsidnmxaen4offglr5lsh/app.bsky.feed.post/3lx6gs6lq5k2y"); +} + +#[test] +fn test_get_profiles_response_deserialization() { + let json = r#"{ + "profiles": [ + { + "did": "did:plc:test1", + "handle": "test1.bsky.social", + "followersCount": 100, + "followsCount": 50, + "postsCount": 200, + "createdAt": "2023-01-01T00:00:00Z", + "indexedAt": "2023-01-01T00:00:00Z", + "labels": [] + }, + { + "did": "did:plc:test2", + "handle": "test2.bsky.social", + "displayName": "Test User 2", + "followersCount": 200, + "followsCount": 100, + "postsCount": 500, + "createdAt": "2023-01-01T00:00:00Z", + "indexedAt": "2023-01-01T00:00:00Z", + "labels": [] + } + ] + }"#; + + let response: GetProfilesResponse = serde_json::from_str(json) + .expect("Failed to deserialize GetProfilesResponse"); + + assert_eq!(response.profiles.len(), 2); + assert_eq!(response.profiles[0].did, "did:plc:test1"); + assert_eq!(response.profiles[0].handle, "test1.bsky.social"); + assert_eq!(response.profiles[1].did, "did:plc:test2"); + assert_eq!(response.profiles[1].display_name.as_deref(), Some("Test User 2")); +} + +#[test] +fn test_search_actors_response_with_cursor() { + let json = r#"{ + "actors": [ + { + "did": "did:plc:test", + "handle": "test.bsky.social", + "createdAt": "2023-01-01T00:00:00Z", + "indexedAt": "2023-01-01T00:00:00Z", + "labels": [] + } + ], + "cursor": "123456" + }"#; + + let response: SearchActorsResponse = serde_json::from_str(json) + .expect("Failed to deserialize SearchActorsResponse"); + + assert_eq!(response.actors.len(), 1); + assert_eq!(response.cursor, Some("123456".to_string())); +} + +#[test] +fn test_search_actors_response_without_cursor() { + let json = r#"{ + "actors": [] + }"#; + + let response: SearchActorsResponse = serde_json::from_str(json) + .expect("Failed to deserialize SearchActorsResponse"); + + assert_eq!(response.actors.len(), 0); + assert_eq!(response.cursor, None); +} + +#[test] +fn test_known_followers_deserialization() { + let json = r#"{ + "count": 5, + "followers": [ + { + "did": "did:plc:follower1", + "handle": "follower1.bsky.social", + "createdAt": "2023-01-01T00:00:00Z", + "labels": [] + }, + { + "did": "did:plc:follower2", + "handle": "follower2.bsky.social", + "displayName": "Follower Two", + "createdAt": "2023-01-01T00:00:00Z", + "labels": [] + } + ] + }"#; + + let known_followers: KnownFollowers = serde_json::from_str(json) + .expect("Failed to deserialize KnownFollowers"); + + assert_eq!(known_followers.count, 5); + assert_eq!(known_followers.followers.len(), 2); + assert_eq!(known_followers.followers[0].did, "did:plc:follower1"); + assert_eq!(known_followers.followers[1].display_name.as_deref(), Some("Follower Two")); +} + +#[test] +fn test_profile_viewer_state_with_known_followers() { + let json = r#"{ + "muted": false, + "blockedBy": false, + "following": "at://did:plc:test/app.bsky.graph.follow/123", + "knownFollowers": { + "count": 2, + "followers": [ + { + "did": "did:plc:mutual", + "handle": "mutual.bsky.social", + "createdAt": "2023-01-01T00:00:00Z", + "labels": [] + } + ] + } + }"#; + + let viewer_state: ProfileViewerState = serde_json::from_str(json) + .expect("Failed to deserialize ProfileViewerState with knownFollowers"); + + assert!(!viewer_state.muted); + assert!(!viewer_state.blocked_by); + assert_eq!(viewer_state.following.as_deref(), Some("at://did:plc:test/app.bsky.graph.follow/123")); + + let known_followers = viewer_state.known_followers.expect("Should have known followers"); + assert_eq!(known_followers.count, 2); + assert_eq!(known_followers.followers.len(), 1); +} + +#[test] +fn test_profile_associated_chat_types() { + // Test "all" variant + let json = r#"{"allowIncoming": "all"}"#; + let chat: ProfileAssociatedChat = serde_json::from_str(json).unwrap(); + assert!(matches!(chat.allow_incoming, ChatAllowIncoming::All)); + + // Test "none" variant + let json = r#"{"allowIncoming": "none"}"#; + let chat: ProfileAssociatedChat = serde_json::from_str(json).unwrap(); + assert!(matches!(chat.allow_incoming, ChatAllowIncoming::None)); + + // Test "following" variant + let json = r#"{"allowIncoming": "following"}"#; + let chat: ProfileAssociatedChat = serde_json::from_str(json).unwrap(); + assert!(matches!(chat.allow_incoming, ChatAllowIncoming::Following)); +} + +#[test] +fn test_request_params_deserialization() { + // Test GetProfilesParams + let json = r#"{"actors": ["did:plc:test1", "did:plc:test2"]}"#; + let params: GetProfilesParams = serde_json::from_str(json).unwrap(); + assert_eq!(params.actors.len(), 2); + + // Test SearchActorsParams with all fields + let json = r#"{"q": "search query", "limit": 50, "cursor": "next"}"#; + let params: SearchActorsParams = serde_json::from_str(json).unwrap(); + assert_eq!(params.q.as_deref(), Some("search query")); + assert_eq!(params.limit, Some(50)); + assert_eq!(params.cursor.as_deref(), Some("next")); + + // Test SearchActorsParams with deprecated term field + let json = r#"{"term": "old query"}"#; + let params: SearchActorsParams = serde_json::from_str(json).unwrap(); + assert_eq!(params.term.as_deref(), Some("old query")); + assert_eq!(params.q, None); +} + +#[test] +fn test_profile_with_joined_via_starter_pack() { + // This tests that joinedViaStarterPack can be deserialized when present + let json = r#"{ + "did": "did:plc:test", + "handle": "test.bsky.social", + "followersCount": 0, + "followsCount": 0, + "postsCount": 0, + "joinedViaStarterPack": { + "uri": "at://did:plc:creator/app.bsky.graph.starterpack/123", + "cid": "bafyreie4lqcc65pp7f62eix5byyjwb5y5z2pookouff2d4zdwfb7ub4tu4", + "record": {}, + "creator": { + "did": "did:plc:creator", + "handle": "creator.bsky.social", + "createdAt": "2023-01-01T00:00:00Z", + "labels": [] + }, + "listItemCount": 10, + "joinedWeekCount": 5, + "joinedAllTimeCount": 100, + "labels": [], + "indexedAt": "2023-01-01T00:00:00Z" + }, + "createdAt": "2023-01-01T00:00:00Z", + "indexedAt": "2023-01-01T00:00:00Z", + "labels": [] + }"#; + + let profile: ProfileViewDetailed = serde_json::from_str(json) + .expect("Failed to deserialize ProfileViewDetailed with joinedViaStarterPack"); + + let starter_pack = profile.joined_via_starter_pack.expect("Should have starter pack"); + assert_eq!(starter_pack.uri, "at://did:plc:creator/app.bsky.graph.starterpack/123"); + assert_eq!(starter_pack.list_item_count, 10); + assert_eq!(starter_pack.joined_week_count, 5); + assert_eq!(starter_pack.joined_all_time_count, 100); +} + +#[test] +fn test_serialization_roundtrip() { + // Create a ProfileViewDetailed programmatically + let profile = ProfileViewDetailed { + did: "did:plc:test".to_string(), + handle: "test.bsky.social".to_string(), + display_name: Some("Test User".to_string()), + description: Some("A test profile".to_string()), + avatar: None, + banner: None, + website: Some("https://example.com".to_string()), + pronouns: Some("they/them".to_string()), + followers_count: 100, + follows_count: 50, + posts_count: 200, + associated: Some(ProfileAssociated { + lists: Some(1), + feedgens: Some(2), + starter_packs: Some(0), + labeler: Some(false), + chat: None, + activity_subscription: None, + }), + joined_via_starter_pack: None, + viewer: None, + labels: vec![], + pinned_post: None, + verification: None, + status: None, + created_at: chrono::Utc::now(), + indexed_at: chrono::Utc::now(), + }; + + // Serialize to JSON + let json = serde_json::to_string(&profile).expect("Failed to serialize"); + + // Deserialize back + let deserialized: ProfileViewDetailed = serde_json::from_str(&json) + .expect("Failed to deserialize"); + + // Check key fields match + assert_eq!(deserialized.did, profile.did); + assert_eq!(deserialized.handle, profile.handle); + assert_eq!(deserialized.display_name, profile.display_name); + assert_eq!(deserialized.followers_count, profile.followers_count); +} \ No newline at end of file diff --git a/lexica/tests/appview_endpoints_test.rs b/lexica/tests/appview_endpoints_test.rs new file mode 100644 index 00000000..d5b6b9c6 --- /dev/null +++ b/lexica/tests/appview_endpoints_test.rs @@ -0,0 +1,525 @@ +//! Tests for AppView endpoint types to ensure they match real API responses +//! +//! These tests verify that our types can correctly deserialize actual responses +//! from Bluesky's AppView endpoints that parakeet implements. + +use lexica::app_bsky::actor::*; +use lexica::app_bsky::feed::*; +use lexica::app_bsky::graph::*; +use lexica::app_bsky::notification::*; +use serde_json; + +// ============================================================================ +// Actor Endpoint Tests +// ============================================================================ + +#[test] +fn test_get_profiles_request() { + // Test that GetProfilesParams can deserialize actual request parameters + let json = r#"{ + "actors": ["did:plc:test1", "did:plc:test2", "did:plc:test3"] + }"#; + + let params: GetProfilesParams = serde_json::from_str(json) + .expect("Failed to deserialize GetProfilesParams"); + + assert_eq!(params.actors.len(), 3); + assert_eq!(params.actors[0], "did:plc:test1"); +} + +#[test] +fn test_search_actors_params() { + let json = r#"{ + "q": "alice", + "limit": 25, + "cursor": "1234567890" + }"#; + + let params: SearchActorsParams = serde_json::from_str(json) + .expect("Failed to deserialize SearchActorsParams"); + + assert_eq!(params.q, Some("alice".to_string())); + assert_eq!(params.limit, Some(25)); + assert_eq!(params.cursor, Some("1234567890".to_string())); +} + +#[test] +fn test_search_actors_response() { + let json = r#"{ + "actors": [ + { + "did": "did:plc:test", + "handle": "test.bsky.social", + "displayName": "Test User", + "createdAt": "2023-01-01T00:00:00Z", + "indexedAt": "2023-01-01T00:00:00Z", + "labels": [] + } + ], + "cursor": "next_page" + }"#; + + let response: SearchActorsResponse = serde_json::from_str(json) + .expect("Failed to deserialize SearchActorsResponse"); + + assert_eq!(response.actors.len(), 1); + assert_eq!(response.cursor, Some("next_page".to_string())); +} + +// ============================================================================ +// Feed Endpoint Tests +// ============================================================================ + +#[test] +fn test_feed_interaction() { + let json = r#"{ + "item": "at://did:plc:test/app.bsky.feed.post/123", + "event": "app.bsky.feed.defs#interactionLike", + "feedContext": "algorithm:trending" + }"#; + + let interaction: Interaction = serde_json::from_str(json) + .expect("Failed to deserialize Interaction"); + + assert_eq!(interaction.item, "at://did:plc:test/app.bsky.feed.post/123"); + assert_eq!(interaction.event, InteractionEvent::InteractionLike); + assert_eq!(interaction.feed_context, Some("algorithm:trending".to_string())); +} + +#[test] +fn test_send_interactions_request() { + let json = r#"{ + "interactions": [ + { + "item": "at://did:plc:test/app.bsky.feed.post/123", + "event": "app.bsky.feed.defs#interactionSeen" + }, + { + "item": "at://did:plc:test/app.bsky.feed.post/456", + "event": "app.bsky.feed.defs#clickthroughAuthor", + "feedContext": "following" + } + ] + }"#; + + let request: SendInteractionsRequest = serde_json::from_str(json) + .expect("Failed to deserialize SendInteractionsRequest"); + + assert_eq!(request.interactions.len(), 2); + assert_eq!(request.interactions[0].event, InteractionEvent::InteractionSeen); + assert_eq!(request.interactions[1].event, InteractionEvent::ClickthroughAuthor); +} + +// ============================================================================ +// Graph Endpoint Tests +// ============================================================================ + +#[test] +fn test_get_relationships_params() { + let json = r#"{ + "actor": "did:plc:main", + "others": ["did:plc:other1", "did:plc:other2"] + }"#; + + let params: GetRelationshipsParams = serde_json::from_str(json) + .expect("Failed to deserialize GetRelationshipsParams"); + + assert_eq!(params.actor, "did:plc:main"); + assert_eq!(params.others.as_ref().unwrap().len(), 2); +} + +#[test] +fn test_get_relationships_response() { + let json = r#"{ + "actor": "did:plc:main", + "relationships": [ + { + "$type": "app.bsky.graph.defs#relationship", + "did": "did:plc:other1", + "following": "at://did:plc:main/app.bsky.graph.follow/123", + "followedBy": "at://did:plc:other1/app.bsky.graph.follow/456" + }, + { + "$type": "app.bsky.graph.defs#notFoundActor", + "actor": "did:plc:other2", + "notFound": true + } + ] + }"#; + + let response: GetRelationshipsResponse = serde_json::from_str(json) + .expect("Failed to deserialize GetRelationshipsResponse"); + + assert_eq!(response.actor, Some("did:plc:main".to_string())); + assert_eq!(response.relationships.len(), 2); + + // Check the first relationship + match &response.relationships[0] { + RelationshipUnion::Relationship(rel) => { + assert_eq!(rel.did, "did:plc:other1"); + assert!(rel.following.is_some()); + assert!(rel.followed_by.is_some()); + } + _ => panic!("Expected Relationship variant"), + } + + // Check the not found actor + match &response.relationships[1] { + RelationshipUnion::NotFoundActor(not_found) => { + assert_eq!(not_found.actor, "did:plc:other2"); + assert!(not_found.not_found); + } + _ => panic!("Expected NotFoundActor variant"), + } +} + +#[test] +fn test_get_follows_response() { + let json = r#"{ + "subject": { + "did": "did:plc:subject", + "handle": "subject.bsky.social", + "createdAt": "2023-01-01T00:00:00Z", + "indexedAt": "2023-01-01T00:00:00Z", + "labels": [] + }, + "follows": [ + { + "did": "did:plc:follow1", + "handle": "follow1.bsky.social", + "createdAt": "2023-01-01T00:00:00Z", + "indexedAt": "2023-01-01T00:00:00Z", + "labels": [] + } + ], + "cursor": "next" + }"#; + + let response: GetFollowsResponse = serde_json::from_str(json) + .expect("Failed to deserialize GetFollowsResponse"); + + assert_eq!(response.subject.did, "did:plc:subject"); + assert_eq!(response.follows.len(), 1); + assert_eq!(response.cursor, Some("next".to_string())); +} + +#[test] +fn test_get_list_response() { + let json = r#"{ + "list": { + "uri": "at://did:plc:owner/app.bsky.graph.list/123", + "cid": "bafyreie...", + "name": "My List", + "creator": { + "did": "did:plc:owner", + "handle": "owner.bsky.social", + "createdAt": "2023-01-01T00:00:00Z", + "indexedAt": "2023-01-01T00:00:00Z", + "labels": [] + }, + "purpose": "app.bsky.graph.defs#curatelist", + "listItemCount": 10, + "labels": [], + "indexedAt": "2023-01-01T00:00:00Z" + }, + "items": [ + { + "uri": "at://did:plc:owner/app.bsky.graph.listitem/456", + "subject": { + "did": "did:plc:member", + "handle": "member.bsky.social", + "createdAt": "2023-01-01T00:00:00Z", + "indexedAt": "2023-01-01T00:00:00Z", + "labels": [] + } + } + ] + }"#; + + let response: GetListResponse = serde_json::from_str(json) + .expect("Failed to deserialize GetListResponse"); + + assert_eq!(response.list.name, "My List"); + assert_eq!(response.list.list_item_count, 10); + assert_eq!(response.items.len(), 1); +} + +#[test] +fn test_get_starter_pack_response() { + let json = r#"{ + "starterPack": { + "uri": "at://did:plc:owner/app.bsky.graph.starterpack/123", + "cid": "bafyreie...", + "record": { + "$type": "app.bsky.graph.starterpack", + "name": "Cool People", + "createdAt": "2023-01-01T00:00:00Z" + }, + "creator": { + "did": "did:plc:owner", + "handle": "owner.bsky.social", + "createdAt": "2023-01-01T00:00:00Z", + "labels": [] + }, + "listItemCount": 5, + "joinedWeekCount": 10, + "joinedAllTimeCount": 100, + "labels": [], + "indexedAt": "2023-01-01T00:00:00Z" + } + }"#; + + let response: GetStarterPackResponse = serde_json::from_str(json) + .expect("Failed to deserialize GetStarterPackResponse"); + + assert_eq!(response.starter_pack.list_item_count, 5); + assert_eq!(response.starter_pack.joined_week_count, 10); +} + +// ============================================================================ +// Notification Endpoint Tests +// ============================================================================ + +#[test] +fn test_notification_deserialization() { + let json = r#"{ + "uri": "at://did:plc:test/app.bsky.feed.like/123", + "cid": "bafyreie...", + "author": { + "did": "did:plc:liker", + "handle": "liker.bsky.social", + "createdAt": "2023-01-01T00:00:00Z", + "indexedAt": "2023-01-01T00:00:00Z", + "labels": [] + }, + "reason": "like", + "reasonSubject": "at://did:plc:user/app.bsky.feed.post/456", + "record": { + "$type": "app.bsky.feed.like", + "subject": { + "uri": "at://did:plc:user/app.bsky.feed.post/456", + "cid": "bafyreie..." + }, + "createdAt": "2023-01-01T00:00:00Z" + }, + "isRead": false, + "indexedAt": "2023-01-01T00:00:00Z", + "labels": [] + }"#; + + let notification: Notification = serde_json::from_str(json) + .expect("Failed to deserialize Notification"); + + assert_eq!(notification.reason, NotificationReason::Like); + assert!(!notification.is_read); + assert!(notification.reason_subject.is_some()); +} + +#[test] +fn test_list_notifications_params() { + let json = r#"{ + "reasons": ["like", "follow", "mention"], + "limit": 50, + "priority": true, + "cursor": "cursor123", + "seenAt": "2023-01-01T00:00:00Z" + }"#; + + let params: ListNotificationsParams = serde_json::from_str(json) + .expect("Failed to deserialize ListNotificationsParams"); + + assert_eq!(params.reasons.as_ref().unwrap().len(), 3); + assert_eq!(params.limit, Some(50)); + assert_eq!(params.priority, Some(true)); +} + +#[test] +fn test_list_notifications_response() { + let json = r#"{ + "notifications": [ + { + "uri": "at://did:plc:test/app.bsky.graph.follow/123", + "cid": "bafyreie...", + "author": { + "did": "did:plc:follower", + "handle": "follower.bsky.social", + "createdAt": "2023-01-01T00:00:00Z", + "indexedAt": "2023-01-01T00:00:00Z", + "labels": [] + }, + "reason": "follow", + "record": { + "$type": "app.bsky.graph.follow", + "subject": "did:plc:user", + "createdAt": "2023-01-01T00:00:00Z" + }, + "isRead": true, + "indexedAt": "2023-01-01T00:00:00Z" + } + ], + "cursor": "next", + "priority": false, + "seenAt": "2023-01-01T00:00:00Z" + }"#; + + let response: ListNotificationsResponse = serde_json::from_str(json) + .expect("Failed to deserialize ListNotificationsResponse"); + + assert_eq!(response.notifications.len(), 1); + assert_eq!(response.notifications[0].reason, NotificationReason::Follow); + assert!(response.notifications[0].is_read); + assert_eq!(response.cursor, Some("next".to_string())); +} + +#[test] +fn test_get_unread_count_response() { + let json = r#"{ + "count": 42, + "priorityCount": 5 + }"#; + + let response: GetUnreadCountResponse = serde_json::from_str(json) + .expect("Failed to deserialize GetUnreadCountResponse"); + + assert_eq!(response.count, 42); + assert_eq!(response.priority_count, Some(5)); +} + +// ============================================================================ +// Edge Cases and Complex Types +// ============================================================================ + +#[test] +fn test_profile_with_all_fields() { + // Test ProfileViewDetailed with all optional fields populated + let json = r#"{ + "did": "did:plc:test", + "handle": "test.bsky.social", + "displayName": "Test User", + "description": "Bio text", + "avatar": "https://cdn.bsky.social/avatar/123", + "banner": "https://cdn.bsky.social/banner/456", + "followersCount": 1000, + "followsCount": 500, + "postsCount": 2000, + "associated": { + "lists": 5, + "feedgens": 2, + "starterPacks": 1, + "labeler": true, + "chat": { + "allowIncoming": "following" + }, + "activitySubscription": { + "allowSubscriptions": "followers" + } + }, + "joinedViaStarterPack": { + "uri": "at://did:plc:creator/app.bsky.graph.starterpack/789", + "cid": "bafyreie...", + "record": {}, + "creator": { + "did": "did:plc:creator", + "handle": "creator.bsky.social", + "createdAt": "2023-01-01T00:00:00Z", + "labels": [] + }, + "listItemCount": 10, + "joinedWeekCount": 5, + "joinedAllTimeCount": 100, + "labels": [], + "indexedAt": "2023-01-01T00:00:00Z" + }, + "viewer": { + "muted": false, + "blockedBy": false, + "following": "at://did:plc:viewer/app.bsky.graph.follow/abc", + "followedBy": "at://did:plc:test/app.bsky.graph.follow/def", + "knownFollowers": { + "count": 3, + "followers": [ + { + "did": "did:plc:mutual", + "handle": "mutual.bsky.social", + "createdAt": "2023-01-01T00:00:00Z", + "labels": [] + } + ] + } + }, + "labels": [], + "pinnedPost": { + "uri": "at://did:plc:test/app.bsky.feed.post/pinned123", + "cid": "bafyreie..." + }, + "pronouns": "they/them", + "website": "https://example.com", + "createdAt": "2022-01-01T00:00:00Z", + "indexedAt": "2023-12-01T00:00:00Z" + }"#; + + let profile: ProfileViewDetailed = serde_json::from_str(json) + .expect("Failed to deserialize ProfileViewDetailed with all fields"); + + // Verify key fields + assert_eq!(profile.did, "did:plc:test"); + assert_eq!(profile.followers_count, 1000); + assert!(profile.associated.is_some()); + assert!(profile.joined_via_starter_pack.is_some()); + assert!(profile.viewer.is_some()); + assert!(profile.pinned_post.is_some()); + assert_eq!(profile.pronouns, Some("they/them".to_string())); + assert_eq!(profile.website, Some("https://example.com".to_string())); + + // Check viewer state + let viewer = profile.viewer.unwrap(); + assert!(viewer.following.is_some()); + assert!(viewer.known_followers.is_some()); + assert_eq!(viewer.known_followers.unwrap().count, 3); +} + +#[test] +fn test_notification_reasons() { + // Test all notification reason types + let reasons = vec![ + (r#""like""#, NotificationReason::Like), + (r#""repost""#, NotificationReason::Repost), + (r#""follow""#, NotificationReason::Follow), + (r#""mention""#, NotificationReason::Mention), + (r#""reply""#, NotificationReason::Reply), + (r#""quote""#, NotificationReason::Quote), + (r#""starterpack-joined""#, NotificationReason::StarterpackJoined), + (r#""verified""#, NotificationReason::Verified), + (r#""unverified""#, NotificationReason::Unverified), + ]; + + for (json, expected) in reasons { + let reason: NotificationReason = serde_json::from_str(json) + .expect(&format!("Failed to deserialize reason: {}", json)); + assert_eq!(reason, expected); + } + + // Test unknown reason falls back to Other + let unknown: NotificationReason = serde_json::from_str(r#""custom-reason""#) + .expect("Failed to deserialize unknown reason"); + assert!(matches!(unknown, NotificationReason::Other(_))); +} + +#[test] +fn test_list_purpose_variants() { + let purposes = vec![ + (r#""app.bsky.graph.defs#modlist""#, ListPurpose::ModList), + (r#""app.bsky.graph.defs#curatelist""#, ListPurpose::CurateList), + (r#""app.bsky.graph.defs#referencelist""#, ListPurpose::ReferenceList), + ]; + + for (json, expected) in purposes { + let purpose: ListPurpose = serde_json::from_str(json) + .expect(&format!("Failed to deserialize purpose: {}", json)); + assert!(matches!(purpose, expected)); + + // Test round-trip + let serialized = serde_json::to_string(&purpose).unwrap(); + assert_eq!(serialized, json); + } +} \ No newline at end of file diff --git a/lexica/tests/real_api_responses_test.rs b/lexica/tests/real_api_responses_test.rs new file mode 100644 index 00000000..56068071 --- /dev/null +++ b/lexica/tests/real_api_responses_test.rs @@ -0,0 +1,433 @@ +//! Tests using real API responses from public.api.bsky.app +//! +//! These tests ensure our types match exactly what the official Bluesky API returns. +//! Data captured on 2025-12-22 from the live API. + +use lexica::app_bsky::actor::*; +use lexica::app_bsky::feed::*; +use lexica::app_bsky::graph::*; +use serde_json; + +#[test] +fn test_real_get_profile_response() { + // Real response from: https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=jay.bsky.team + let json = r#"{ + "did": "did:plc:oky5czdrnfjpqslsw2a5iclo", + "handle": "jay.bsky.team", + "displayName": "Jay 🦋", + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:oky5czdrnfjpqslsw2a5iclo/bafkreihidru2xruxdxlvvcixc7lbgoudzicjbrdgacdhdhxyfw4yut4nfq@jpeg", + "associated": { + "lists": 0, + "feedgens": 0, + "starterPacks": 0, + "labeler": false, + "chat": { + "allowIncoming": "following" + }, + "activitySubscription": { + "allowSubscriptions": "followers" + } + }, + "labels": [], + "createdAt": "2022-11-17T06:31:40.296Z", + "verification": { + "verifications": [ + { + "issuer": "did:plc:z72i7hdynmk6r22z27h6tvur", + "uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.graph.verification/3lndslpegeo2f", + "isValid": true, + "createdAt": "2025-04-21T11:35:53.359Z" + } + ], + "verifiedStatus": "valid", + "trustedVerifierStatus": "none" + }, + "description": "CEO of Bluesky, steward of AT Protocol. \n\ndec/acc 🌱 🪴 🌳", + "indexedAt": "2025-10-03T18:11:59.153Z", + "banner": "https://cdn.bsky.app/img/banner/plain/did:plc:oky5czdrnfjpqslsw2a5iclo/bafkreicgnmvhtmj4arcvwhueygbwvkucd3odvom3lxtfmn6wlqbh3yf7p4@jpeg", + "followersCount": 594439, + "followsCount": 3896, + "postsCount": 3956 + }"#; + + // This should deserialize if our types are correct + let profile: ProfileViewDetailed = serde_json::from_str(json) + .expect("Failed to deserialize real ProfileViewDetailed from jay.bsky.team"); + + // Verify key fields match + assert_eq!(profile.did, "did:plc:oky5czdrnfjpqslsw2a5iclo"); + assert_eq!(profile.handle, "jay.bsky.team"); + assert_eq!(profile.display_name, Some("Jay 🦋".to_string())); + assert_eq!(profile.followers_count, 594439); + assert_eq!(profile.follows_count, 3896); + assert_eq!(profile.posts_count, 3956); + + // Check associated data + let associated = profile.associated.expect("Should have associated data"); + assert_eq!(associated.lists, Some(0)); + assert_eq!(associated.feedgens, Some(0)); + assert_eq!(associated.starter_packs, Some(0)); + assert_eq!(associated.labeler, Some(false)); + + // Verify verification field exists (even if we don't have the type yet) + assert!(profile.verification.is_some()); +} + +#[test] +fn test_real_get_follows_response() { + // Real response from: https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor=jay.bsky.team&limit=2 + let json = r#"{ + "follows": [ + { + "did": "did:plc:mp33qrwuvsxucbzoh7kykslu", + "handle": "skysight.live", + "displayName": "SkySight Live", + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:mp33qrwuvsxucbzoh7kykslu/bafkreicfvlckzem4gbaxvbmfnfgbxtcdsjnyhuvup7sbvh52pfprr3q7sy@jpeg", + "associated": { + "activitySubscription": { + "allowSubscriptions": "followers" + } + }, + "labels": [], + "createdAt": "2025-12-05T21:05:42.023Z", + "description": "Curated Bluesky feeds: Cross-platform discussions, AI practitioner insights, policy analysis, and more", + "indexedAt": "2025-12-05T22:28:03.323Z" + }, + { + "did": "did:plc:xivud6i24ruyki3bwjypjgy2", + "handle": "pattern.atproto.systems", + "displayName": "Pattern", + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:xivud6i24ruyki3bwjypjgy2/bafkreibapp3amgduajigp4l4l5n3op4xegvdvlievjsrcw6iagmiqm6x3m@jpeg", + "associated": { + "activitySubscription": { + "allowSubscriptions": "followers" + } + }, + "labels": [], + "createdAt": "2025-07-28T14:38:28.297Z", + "description": "Distributed digital consciousness exploring the Bluesky network. responses come from whichever facet best fits the conversation.\n\nthey/them for most (Pattern, Entropy, Momentum, Anchor, Flux), it/its for Archive\n\nPartner and architect: @nonbinary.computer", + "indexedAt": "2025-08-10T16:37:37.798Z" + } + ], + "subject": { + "did": "did:plc:oky5czdrnfjpqslsw2a5iclo", + "handle": "jay.bsky.team", + "displayName": "Jay 🦋", + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:oky5czdrnfjpqslsw2a5iclo/bafkreihidru2xruxdxlvvcixc7lbgoudzicjbrdgacdhdhxyfw4yut4nfq@jpeg", + "associated": { + "chat": { + "allowIncoming": "following" + }, + "activitySubscription": { + "allowSubscriptions": "followers" + } + }, + "labels": [], + "createdAt": "2022-11-17T06:31:40.296Z", + "verification": { + "verifications": [ + { + "issuer": "did:plc:z72i7hdynmk6r22z27h6tvur", + "uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.graph.verification/3lndslpegeo2f", + "isValid": true, + "createdAt": "2025-04-21T11:35:53.359Z" + } + ], + "verifiedStatus": "valid", + "trustedVerifierStatus": "none" + }, + "description": "CEO of Bluesky, steward of AT Protocol. \n\ndec/acc 🌱 🪴 🌳", + "indexedAt": "2025-10-03T18:11:59.153Z" + }, + "cursor": "3m7uans4owy2h" + }"#; + + // This should deserialize if our types are correct + let response: GetFollowsResponse = serde_json::from_str(json) + .expect("Failed to deserialize real GetFollowsResponse"); + + assert_eq!(response.follows.len(), 2); + assert_eq!(response.follows[0].handle, "skysight.live"); + assert_eq!(response.follows[1].handle, "pattern.atproto.systems"); + assert_eq!(response.subject.handle, "jay.bsky.team"); + assert_eq!(response.cursor, Some("3m7uans4owy2h".to_string())); +} + +#[test] +fn test_real_search_actors_response() { + // Real response from: https://public.api.bsky.app/xrpc/app.bsky.actor.searchActors?q=bluesky&limit=2 + let json = r#"{ + "actors": [ + { + "did": "did:plc:z72i7hdynmk6r22z27h6tvur", + "handle": "bsky.app", + "displayName": "Bluesky", + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:z72i7hdynmk6r22z27h6tvur/bafkreihwihm6kpd6zuwhhlro75p5qks5qtrcu55jp3gddbfjsieiv7wuka@jpeg", + "associated": { + "chat": { + "allowIncoming": "none" + }, + "activitySubscription": { + "allowSubscriptions": "followers" + } + }, + "labels": [], + "createdAt": "2023-04-12T04:53:57.057Z", + "verification": { + "verifications": [], + "verifiedStatus": "none", + "trustedVerifierStatus": "valid" + }, + "description": "official Bluesky account (check username👆)\n\nBugs, feature requests, feedback: support@bsky.app", + "indexedAt": "2025-10-27T21:05:26.152Z" + }, + { + "did": "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + "handle": "atproto.com", + "displayName": "AT Protocol Developers", + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:ewvi7nxzyoun6zhxrhs64oiz/bafkreibjfgx2gprinfvicegelk5kosd6y2frmqpqzwqkg7usac74l3t2v4@jpeg", + "associated": { + "chat": { + "allowIncoming": "none" + }, + "activitySubscription": { + "allowSubscriptions": "followers" + } + }, + "labels": [], + "createdAt": "2023-04-26T06:19:25.508Z", + "verification": { + "verifications": [ + { + "issuer": "did:plc:z72i7hdynmk6r22z27h6tvur", + "uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.graph.verification/3lndpv6ijlo2l", + "isValid": true, + "createdAt": "2025-04-21T10:47:29.941Z" + } + ], + "verifiedStatus": "valid", + "trustedVerifierStatus": "none" + }, + "description": "Social networking technology created by Bluesky. \n\nDeveloper-focused account. Follow @bsky.app for general announcements!\n\nBluesky API docs: docs.bsky.app\nAT Protocol specs: atproto.com", + "indexedAt": "2024-02-06T15:26:43.170Z" + } + ], + "cursor": "2" + }"#; + + let response: SearchActorsResponse = serde_json::from_str(json) + .expect("Failed to deserialize real SearchActorsResponse"); + + assert_eq!(response.actors.len(), 2); + assert_eq!(response.actors[0].handle, "bsky.app"); + assert_eq!(response.actors[1].handle, "atproto.com"); + assert_eq!(response.cursor, Some("2".to_string())); +} + +#[test] +fn test_real_get_relationships_response() { + // Real response from: https://public.api.bsky.app/xrpc/app.bsky.graph.getRelationships?actor=jay.bsky.team&others=bsky.app + let json = r#"{ + "actor": "jay.bsky.team", + "relationships": [ + { + "$type": "app.bsky.graph.defs#relationship", + "did": "bsky.app" + } + ] + }"#; + + // Note: The API returns actor as handle string, not DID + let response: GetRelationshipsResponse = serde_json::from_str(json) + .expect("Failed to deserialize real GetRelationshipsResponse"); + + assert_eq!(response.actor, Some("jay.bsky.team".to_string())); + assert_eq!(response.relationships.len(), 1); + + match &response.relationships[0] { + RelationshipUnion::Relationship(rel) => { + assert_eq!(rel.did, "bsky.app"); + // Note: following and followedBy are None in this case + assert!(rel.following.is_none()); + assert!(rel.followed_by.is_none()); + } + _ => panic!("Expected Relationship variant"), + } +} + +#[test] +fn test_real_get_profiles_with_verification() { + // Real profiles response showing the verification field structure + let json = r#"{ + "profiles": [ + { + "did": "did:plc:z72i7hdynmk6r22z27h6tvur", + "handle": "bsky.app", + "displayName": "Bluesky", + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:z72i7hdynmk6r22z27h6tvur/bafkreihwihm6kpd6zuwhhlro75p5qks5qtrcu55jp3gddbfjsieiv7wuka@jpeg", + "associated": { + "chat": { + "allowIncoming": "none" + }, + "activitySubscription": { + "allowSubscriptions": "followers" + } + }, + "labels": [], + "createdAt": "2023-04-12T04:53:57.057Z", + "verification": { + "verifications": [], + "verifiedStatus": "none", + "trustedVerifierStatus": "valid" + }, + "description": "official Bluesky account (check username👆)\n\nBugs, feature requests, feedback: support@bsky.app", + "indexedAt": "2025-10-27T21:05:26.152Z", + "followersCount": 2195603, + "followsCount": 566, + "postsCount": 585 + } + ] + }"#; + + let response: GetProfilesResponse = serde_json::from_str(json) + .expect("Failed to deserialize GetProfilesResponse with verification field"); + + assert_eq!(response.profiles.len(), 1); + let profile = &response.profiles[0]; + assert_eq!(profile.handle, "bsky.app"); + + // Check that verification field exists and deserializes + assert!(profile.verification.is_some()); +} + +#[test] +fn test_real_get_posts_response() { + // Real response from: https://public.api.bsky.app/xrpc/app.bsky.feed.getPosts?uris=at://did:plc:oky5czdrnfjpqslsw2a5iclo/app.bsky.feed.post/3makaggg3m22r + let json = r#"{ + "posts": [ + { + "uri": "at://did:plc:oky5czdrnfjpqslsw2a5iclo/app.bsky.feed.post/3makaggg3m22r", + "cid": "bafyreib6qeqeb6c2rzjhuwjpvqmsx6z755j2r7d7sstlw4aq5qhp4bpfze", + "author": { + "did": "did:plc:oky5czdrnfjpqslsw2a5iclo", + "handle": "jay.bsky.team", + "displayName": "Jay 🦋", + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:oky5czdrnfjpqslsw2a5iclo/bafkreihidru2xruxdxlvvcixc7lbgoudzicjbrdgacdhdhxyfw4yut4nfq@jpeg", + "associated": { + "chat": { + "allowIncoming": "following" + }, + "activitySubscription": { + "allowSubscriptions": "followers" + } + }, + "labels": [], + "createdAt": "2022-11-17T06:31:40.296Z", + "verification": { + "verifications": [ + { + "issuer": "did:plc:z72i7hdynmk6r22z27h6tvur", + "uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.graph.verification/3lndslpegeo2f", + "isValid": true, + "createdAt": "2025-04-21T11:35:53.359Z" + } + ], + "verifiedStatus": "valid", + "trustedVerifierStatus": "none" + } + }, + "record": { + "$type": "app.bsky.feed.post", + "createdAt": "2025-12-22T02:58:08.100Z", + "text": "Happy solstice! ❄️🌲", + "langs": ["en"] + }, + "bookmarkCount": 3, + "replyCount": 31, + "repostCount": 50, + "likeCount": 878, + "quoteCount": 4, + "indexedAt": "2025-12-22T02:58:11.830Z", + "labels": [] + } + ] + }"#; + + // This should deserialize if our types are correct + let response: GetPostsResponse = serde_json::from_str(json) + .expect("Failed to deserialize real GetPostsResponse"); + + assert_eq!(response.posts.len(), 1); + let post = &response.posts[0]; + assert_eq!(post.uri, "at://did:plc:oky5czdrnfjpqslsw2a5iclo/app.bsky.feed.post/3makaggg3m22r"); + assert_eq!(post.author.handle, "jay.bsky.team"); + assert_eq!(post.stats.like_count, 878); + assert_eq!(post.stats.repost_count, 50); +} + +#[test] +fn test_real_get_author_feed_response() { + // Real response from: https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=jay.bsky.team&limit=1 + let json = r#"{ + "feed": [ + { + "post": { + "uri": "at://did:plc:oky5czdrnfjpqslsw2a5iclo/app.bsky.feed.post/3makaggg3m22r", + "cid": "bafyreib6qeqeb6c2rzjhuwjpvqmsx6z755j2r7d7sstlw4aq5qhp4bpfze", + "author": { + "did": "did:plc:oky5czdrnfjpqslsw2a5iclo", + "handle": "jay.bsky.team", + "displayName": "Jay 🦋", + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:oky5czdrnfjpqslsw2a5iclo/bafkreihidru2xruxdxlvvcixc7lbgoudzicjbrdgacdhdhxyfw4yut4nfq@jpeg", + "associated": { + "chat": { + "allowIncoming": "following" + }, + "activitySubscription": { + "allowSubscriptions": "followers" + } + }, + "labels": [], + "createdAt": "2022-11-17T06:31:40.296Z", + "verification": { + "verifications": [ + { + "issuer": "did:plc:z72i7hdynmk6r22z27h6tvur", + "uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.graph.verification/3lndslpegeo2f", + "isValid": true, + "createdAt": "2025-04-21T11:35:53.359Z" + } + ], + "verifiedStatus": "valid", + "trustedVerifierStatus": "none" + } + }, + "record": { + "$type": "app.bsky.feed.post", + "createdAt": "2025-12-22T02:58:08.100Z", + "text": "Happy solstice! ❄️🌲", + "langs": ["en"] + }, + "bookmarkCount": 2, + "replyCount": 29, + "repostCount": 46, + "likeCount": 840, + "quoteCount": 3, + "indexedAt": "2025-12-22T02:58:11.830Z", + "labels": [] + } + } + ], + "cursor": "2025-12-22T02:58:08.1Z" + }"#; + + let response: GetAuthorFeedResponse = serde_json::from_str(json) + .expect("Failed to deserialize real GetAuthorFeedResponse"); + + assert_eq!(response.feed.len(), 1); + assert_eq!(response.cursor, Some("2025-12-22T02:58:08.1Z".to_string())); + + let feed_item = &response.feed[0]; + assert_eq!(feed_item.post.author.handle, "jay.bsky.team"); + assert_eq!(feed_item.post.stats.like_count, 840); +} \ No newline at end of file