import Testing import Foundation @testable import bskyKit @Suite("Model Decoding Tests") struct ModelDecodingTests { // MARK: - Helper private func decode(_ type: T.Type, from json: String) throws -> T { let data = Data(json.utf8) let decoder = JSONDecoder.atDecoder return try decoder.decode(type, from: data) } // MARK: - Profile @Test("Decodes basic profile") func decodesBasicProfile() throws { let json = """ { "did": "did:plc:abc123", "handle": "alice.bsky.social", "displayName": "Alice", "description": "Hello world", "avatar": "https://example.com/avatar.jpg", "indexedAt": "2024-01-15T10:30:00.000Z" } """ let profile = try decode(Profile.self, from: json) #expect(profile.did == "did:plc:abc123") #expect(profile.handle == "alice.bsky.social") #expect(profile.displayName == "Alice") #expect(profile.description == "Hello world") #expect(profile.avatar == "https://example.com/avatar.jpg") #expect(profile.indexedAt != nil) #expect(profile.id == "did:plc:abc123") } @Test("Decodes profile with missing optional fields") func decodesProfileMissingOptionals() throws { let json = """ { "did": "did:plc:abc123", "handle": "alice.bsky.social" } """ let profile = try decode(Profile.self, from: json) #expect(profile.did == "did:plc:abc123") #expect(profile.displayName == nil) #expect(profile.avatar == nil) } // MARK: - Viewer @Test("Decodes viewer state") func decodesViewerState() throws { let json = """ { "muted": false, "blockedBy": false, "following": "at://did:plc:xyz/app.bsky.graph.follow/123" } """ let viewer = try decode(Viewer.self, from: json) #expect(viewer.muted == false) #expect(viewer.blockedBy == false) #expect(viewer.following == "at://did:plc:xyz/app.bsky.graph.follow/123") #expect(viewer.followedBy == nil) } // MARK: - Feed @Test("Decodes feed") func decodesFeed() throws { let json = """ { "uri": "at://did:plc:abc/app.bsky.feed.generator/following", "cid": "bafyreiabc123", "did": "did:plc:abc", "creator": { "did": "did:plc:creator", "handle": "creator.bsky.social" }, "displayName": "Following", "likeCount": 100, "indexedAt": "2024-01-01T00:00:00.000Z" } """ let feed = try decode(Feed.self, from: json) #expect(feed.uri == "at://did:plc:abc/app.bsky.feed.generator/following") #expect(feed.displayName == "Following") #expect(feed.likeCount == 100) #expect(feed.id == "at://did:plc:abc/app.bsky.feed.generator/following") } // MARK: - Follows @Test("Decodes follows response") func decodesFollowsResponse() throws { let json = """ { "subject": { "did": "did:plc:subject", "handle": "subject.bsky.social" }, "follows": [ { "did": "did:plc:follow1", "handle": "follow1.bsky.social", "displayName": "Follow 1" }, { "did": "did:plc:follow2", "handle": "follow2.bsky.social" } ], "cursor": "cursor123" } """ let follows = try decode(Follows.self, from: json) #expect(follows.subject.did == "did:plc:subject") #expect(follows.follows.count == 2) #expect(follows.follows[0].displayName == "Follow 1") #expect(follows.follows[1].displayName == nil) #expect(follows.cursor == "cursor123") } // MARK: - Notifications @Test("Decodes notification") func decodesNotification() throws { let json = """ { "uri": "at://did:plc:abc/app.bsky.feed.like/123", "cid": "bafyreiabc", "author": { "did": "did:plc:author", "handle": "author.bsky.social" }, "reason": "like", "isRead": false, "indexedAt": "2024-01-15T10:30:00.000Z" } """ let notification = try decode(Notification.self, from: json) #expect(notification.uri == "at://did:plc:abc/app.bsky.feed.like/123") #expect(notification.author.handle == "author.bsky.social") #expect(notification.reason == .like) #expect(notification.isRead == false) #expect(notification.id == "at://did:plc:abc/app.bsky.feed.like/123") } @Test("Decodes all notification reasons") func decodesNotificationReasons() throws { let reasons = ["like", "repost", "follow", "mention", "reply", "quote", "starterpack-joined"] for reason in reasons { let json = """ { "uri": "at://did:plc:abc/collection/123", "cid": "bafyreiabc", "author": { "did": "did:plc:author", "handle": "author.bsky.social" }, "reason": "\(reason)", "isRead": true, "indexedAt": "2024-01-15T10:30:00.000Z" } """ let notification = try decode(Notification.self, from: json) #expect(notification.reason != .unknown(reason)) } } // MARK: - Unread Count @Test("Decodes unread count") func decodesUnreadCount() throws { let json = """ { "count": 42 } """ let unreadCount = try decode(UnreadCount.self, from: json) #expect(unreadCount.count == 42) } // MARK: - Search Actors @Test("Decodes search actors result") func decodesSearchActorsResult() throws { let json = """ { "actors": [ { "did": "did:plc:actor1", "handle": "actor1.bsky.social", "displayName": "Actor One" } ], "cursor": "next" } """ let result = try decode(SearchActorsResult.self, from: json) #expect(result.actors.count == 1) #expect(result.actors[0].handle == "actor1.bsky.social") #expect(result.cursor == "next") } @Test("Decodes actor suggestions response") func decodesActorSuggestionsResponse() throws { let json = """ { "actors": [ { "did": "did:plc:actor1", "handle": "actor1.bsky.social" } ], "cursor": "next", "recId": 42 } """ let result = try decode(ActorSuggestionsResponse.self, from: json) #expect(result.actors.count == 1) #expect(result.actors[0].did == "did:plc:actor1") #expect(result.cursor == "next") #expect(result.recId == 42) } // MARK: - Feed and Search @Test("Decodes search posts response") func decodesSearchPostsResponse() throws { let json = """ { "cursor": "next", "hitsTotal": 1, "posts": [ { "uri": "at://did:plc:author/app.bsky.feed.post/1", "cid": "bafy-post", "author": { "did": "did:plc:author", "handle": "author.bsky.social" }, "record": { "$type": "app.bsky.feed.post", "text": "Hello world", "createdAt": "2024-01-15T10:30:00.000Z" }, "indexedAt": "2024-01-15T10:30:00.000Z" } ] } """ let result = try decode(SearchPostsResponse.self, from: json) #expect(result.posts.count == 1) #expect(result.hitsTotal == 1) #expect(result.cursor == "next") } @Test("Decodes relationships response with union members") func decodesRelationshipsResponse() throws { let json = """ { "actor": "did:plc:me", "relationships": [ { "did": "did:plc:alice", "following": "at://did:plc:me/app.bsky.graph.follow/abc" }, { "actor": "did:plc:missing", "notFound": true } ] } """ let relationships = try decode(RelationshipsResponse.self, from: json) #expect(relationships.actor == "did:plc:me") #expect(relationships.relationships.count == 2) if case .relationship(let relationship) = relationships.relationships[0] { #expect(relationship.did == "did:plc:alice") } else { Issue.record("Expected first relationship entry to decode as relationship") } if case .notFound(let missing) = relationships.relationships[1] { #expect(missing.actor == "did:plc:missing") #expect(missing.notFound == true) } else { Issue.record("Expected second relationship entry to decode as notFound") } } // MARK: - Repo @Test("Decodes describe repo response with didDoc") func decodesDescribeRepoResponse() throws { let json = """ { "handle": "alice.bsky.social", "did": "did:plc:alice", "didDoc": { "id": "did:plc:alice", "service": [ { "id": "#atproto_pds", "type": "AtprotoPersonalDataServer" } ] }, "collections": ["app.bsky.feed.post"], "handleIsCorrect": true } """ let response = try decode(DescribeRepoResponse.self, from: json) #expect(response.handle == "alice.bsky.social") #expect(response.did == "did:plc:alice") #expect(response.collections == ["app.bsky.feed.post"]) #expect(response.handleIsCorrect == true) #expect(response.didDoc != nil) } @Test("Decodes list missing blobs response") func decodesListMissingBlobsResponse() throws { let json = """ { "cursor": "next", "blobs": [ { "cid": "bafkreiabc", "recordUri": "at://did:plc:alice/app.bsky.feed.post/1" } ] } """ let response = try decode(ListMissingBlobsResponse.self, from: json) #expect(response.cursor == "next") #expect(response.blobs.count == 1) #expect(response.blobs[0].cid == "bafkreiabc") } @Test("Decodes video upload limits") func decodesVideoUploadLimits() throws { let json = """ { "canUpload": true, "remainingDailyVideos": 3, "remainingDailyBytes": 10485760 } """ let limits = try decode(VideoUploadLimits.self, from: json) #expect(limits.canUpload == true) #expect(limits.remainingDailyVideos == 3) #expect(limits.remainingDailyBytes == 10485760) #expect(limits.error == nil) } // MARK: - Likes @Test("Decodes likes response") func decodesLikesResponse() throws { let json = """ { "uri": "at://did:plc:post/app.bsky.feed.post/123", "cid": "bafyreiabc", "likes": [ { "actor": { "did": "did:plc:liker", "handle": "liker.bsky.social" }, "createdAt": "2024-01-15T10:30:00.000Z", "indexedAt": "2024-01-15T10:30:00.000Z" } ], "cursor": "next" } """ let likes = try decode(Likes.self, from: json) #expect(likes.uri == "at://did:plc:post/app.bsky.feed.post/123") #expect(likes.likes.count == 1) #expect(likes.likes[0].actor.handle == "liker.bsky.social") } // MARK: - Blocks @Test("Decodes blocks response") func decodesBlocksResponse() throws { let json = """ { "blocks": [ { "did": "did:plc:blocked", "handle": "blocked.bsky.social" } ] } """ let blocks = try decode(Blocks.self, from: json) #expect(blocks.blocks.count == 1) #expect(blocks.cursor == nil) } // MARK: - Date Formats @Test("Decodes various date formats") func decodesDateFormats() throws { let dateFormats = [ "2024-01-15T10:30:00.000Z", "2024-01-15T10:30:00Z", "2024-01-15T10:30:00.123456Z", "2024-01-15T10:30:00+00:00" ] for dateString in dateFormats { let json = """ { "did": "did:plc:abc", "handle": "test.bsky.social", "indexedAt": "\(dateString)" } """ let profile = try decode(Profile.self, from: json) #expect(profile.indexedAt != nil, "Failed to decode date: \(dateString)") } } } // MARK: - JSONDecoder Extension extension JSONDecoder { static var atDecoder: JSONDecoder { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() let dateString = try container.decode(String.self) // Try various ISO 8601 formats let formatters = [ "yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXXXX", "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX", "yyyy-MM-dd'T'HH:mm:ssXXXXX", "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", "yyyy-MM-dd'T'HH:mm:ss'Z'" ] for format in formatters { let formatter = DateFormatter() formatter.dateFormat = format formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone(secondsFromGMT: 0) if let date = formatter.date(from: dateString) { return date } } throw DecodingError.dataCorrupted( DecodingError.Context( codingPath: decoder.codingPath, debugDescription: "Cannot decode date: \(dateString)" ) ) } return decoder } }