this repo has no description
at main 490 lines 15 kB view raw
1import Testing 2import Foundation 3@testable import bskyKit 4 5@Suite("Model Decoding Tests") 6struct ModelDecodingTests { 7 8 // MARK: - Helper 9 10 private func decode<T: Decodable>(_ type: T.Type, from json: String) throws -> T { 11 let data = Data(json.utf8) 12 let decoder = JSONDecoder.atDecoder 13 return try decoder.decode(type, from: data) 14 } 15 16 // MARK: - Profile 17 18 @Test("Decodes basic profile") 19 func decodesBasicProfile() throws { 20 let json = """ 21 { 22 "did": "did:plc:abc123", 23 "handle": "alice.bsky.social", 24 "displayName": "Alice", 25 "description": "Hello world", 26 "avatar": "https://example.com/avatar.jpg", 27 "indexedAt": "2024-01-15T10:30:00.000Z" 28 } 29 """ 30 31 let profile = try decode(Profile.self, from: json) 32 #expect(profile.did == "did:plc:abc123") 33 #expect(profile.handle == "alice.bsky.social") 34 #expect(profile.displayName == "Alice") 35 #expect(profile.description == "Hello world") 36 #expect(profile.avatar == "https://example.com/avatar.jpg") 37 #expect(profile.indexedAt != nil) 38 #expect(profile.id == "did:plc:abc123") 39 } 40 41 @Test("Decodes profile with missing optional fields") 42 func decodesProfileMissingOptionals() throws { 43 let json = """ 44 { 45 "did": "did:plc:abc123", 46 "handle": "alice.bsky.social" 47 } 48 """ 49 50 let profile = try decode(Profile.self, from: json) 51 #expect(profile.did == "did:plc:abc123") 52 #expect(profile.displayName == nil) 53 #expect(profile.avatar == nil) 54 } 55 56 // MARK: - Viewer 57 58 @Test("Decodes viewer state") 59 func decodesViewerState() throws { 60 let json = """ 61 { 62 "muted": false, 63 "blockedBy": false, 64 "following": "at://did:plc:xyz/app.bsky.graph.follow/123" 65 } 66 """ 67 68 let viewer = try decode(Viewer.self, from: json) 69 #expect(viewer.muted == false) 70 #expect(viewer.blockedBy == false) 71 #expect(viewer.following == "at://did:plc:xyz/app.bsky.graph.follow/123") 72 #expect(viewer.followedBy == nil) 73 } 74 75 // MARK: - Feed 76 77 @Test("Decodes feed") 78 func decodesFeed() throws { 79 let json = """ 80 { 81 "uri": "at://did:plc:abc/app.bsky.feed.generator/following", 82 "cid": "bafyreiabc123", 83 "did": "did:plc:abc", 84 "creator": { 85 "did": "did:plc:creator", 86 "handle": "creator.bsky.social" 87 }, 88 "displayName": "Following", 89 "likeCount": 100, 90 "indexedAt": "2024-01-01T00:00:00.000Z" 91 } 92 """ 93 94 let feed = try decode(Feed.self, from: json) 95 #expect(feed.uri == "at://did:plc:abc/app.bsky.feed.generator/following") 96 #expect(feed.displayName == "Following") 97 #expect(feed.likeCount == 100) 98 #expect(feed.id == "at://did:plc:abc/app.bsky.feed.generator/following") 99 } 100 101 // MARK: - Follows 102 103 @Test("Decodes follows response") 104 func decodesFollowsResponse() throws { 105 let json = """ 106 { 107 "subject": { 108 "did": "did:plc:subject", 109 "handle": "subject.bsky.social" 110 }, 111 "follows": [ 112 { 113 "did": "did:plc:follow1", 114 "handle": "follow1.bsky.social", 115 "displayName": "Follow 1" 116 }, 117 { 118 "did": "did:plc:follow2", 119 "handle": "follow2.bsky.social" 120 } 121 ], 122 "cursor": "cursor123" 123 } 124 """ 125 126 let follows = try decode(Follows.self, from: json) 127 #expect(follows.subject.did == "did:plc:subject") 128 #expect(follows.follows.count == 2) 129 #expect(follows.follows[0].displayName == "Follow 1") 130 #expect(follows.follows[1].displayName == nil) 131 #expect(follows.cursor == "cursor123") 132 } 133 134 // MARK: - Notifications 135 136 @Test("Decodes notification") 137 func decodesNotification() throws { 138 let json = """ 139 { 140 "uri": "at://did:plc:abc/app.bsky.feed.like/123", 141 "cid": "bafyreiabc", 142 "author": { 143 "did": "did:plc:author", 144 "handle": "author.bsky.social" 145 }, 146 "reason": "like", 147 "isRead": false, 148 "indexedAt": "2024-01-15T10:30:00.000Z" 149 } 150 """ 151 152 let notification = try decode(Notification.self, from: json) 153 #expect(notification.uri == "at://did:plc:abc/app.bsky.feed.like/123") 154 #expect(notification.author.handle == "author.bsky.social") 155 #expect(notification.reason == .like) 156 #expect(notification.isRead == false) 157 #expect(notification.id == "at://did:plc:abc/app.bsky.feed.like/123") 158 } 159 160 @Test("Decodes all notification reasons") 161 func decodesNotificationReasons() throws { 162 let reasons = ["like", "repost", "follow", "mention", "reply", "quote", "starterpack-joined"] 163 164 for reason in reasons { 165 let json = """ 166 { 167 "uri": "at://did:plc:abc/collection/123", 168 "cid": "bafyreiabc", 169 "author": { 170 "did": "did:plc:author", 171 "handle": "author.bsky.social" 172 }, 173 "reason": "\(reason)", 174 "isRead": true, 175 "indexedAt": "2024-01-15T10:30:00.000Z" 176 } 177 """ 178 179 let notification = try decode(Notification.self, from: json) 180 #expect(notification.reason != .unknown(reason)) 181 } 182 } 183 184 // MARK: - Unread Count 185 186 @Test("Decodes unread count") 187 func decodesUnreadCount() throws { 188 let json = """ 189 { 190 "count": 42 191 } 192 """ 193 194 let unreadCount = try decode(UnreadCount.self, from: json) 195 #expect(unreadCount.count == 42) 196 } 197 198 // MARK: - Search Actors 199 200 @Test("Decodes search actors result") 201 func decodesSearchActorsResult() throws { 202 let json = """ 203 { 204 "actors": [ 205 { 206 "did": "did:plc:actor1", 207 "handle": "actor1.bsky.social", 208 "displayName": "Actor One" 209 } 210 ], 211 "cursor": "next" 212 } 213 """ 214 215 let result = try decode(SearchActorsResult.self, from: json) 216 #expect(result.actors.count == 1) 217 #expect(result.actors[0].handle == "actor1.bsky.social") 218 #expect(result.cursor == "next") 219 } 220 221 @Test("Decodes actor suggestions response") 222 func decodesActorSuggestionsResponse() throws { 223 let json = """ 224 { 225 "actors": [ 226 { 227 "did": "did:plc:actor1", 228 "handle": "actor1.bsky.social" 229 } 230 ], 231 "cursor": "next", 232 "recId": 42 233 } 234 """ 235 236 let result = try decode(ActorSuggestionsResponse.self, from: json) 237 #expect(result.actors.count == 1) 238 #expect(result.actors[0].did == "did:plc:actor1") 239 #expect(result.cursor == "next") 240 #expect(result.recId == 42) 241 } 242 243 // MARK: - Feed and Search 244 245 @Test("Decodes search posts response") 246 func decodesSearchPostsResponse() throws { 247 let json = """ 248 { 249 "cursor": "next", 250 "hitsTotal": 1, 251 "posts": [ 252 { 253 "uri": "at://did:plc:author/app.bsky.feed.post/1", 254 "cid": "bafy-post", 255 "author": { 256 "did": "did:plc:author", 257 "handle": "author.bsky.social" 258 }, 259 "record": { 260 "$type": "app.bsky.feed.post", 261 "text": "Hello world", 262 "createdAt": "2024-01-15T10:30:00.000Z" 263 }, 264 "indexedAt": "2024-01-15T10:30:00.000Z" 265 } 266 ] 267 } 268 """ 269 270 let result = try decode(SearchPostsResponse.self, from: json) 271 #expect(result.posts.count == 1) 272 #expect(result.hitsTotal == 1) 273 #expect(result.cursor == "next") 274 } 275 276 @Test("Decodes relationships response with union members") 277 func decodesRelationshipsResponse() throws { 278 let json = """ 279 { 280 "actor": "did:plc:me", 281 "relationships": [ 282 { 283 "did": "did:plc:alice", 284 "following": "at://did:plc:me/app.bsky.graph.follow/abc" 285 }, 286 { 287 "actor": "did:plc:missing", 288 "notFound": true 289 } 290 ] 291 } 292 """ 293 294 let relationships = try decode(RelationshipsResponse.self, from: json) 295 #expect(relationships.actor == "did:plc:me") 296 #expect(relationships.relationships.count == 2) 297 298 if case .relationship(let relationship) = relationships.relationships[0] { 299 #expect(relationship.did == "did:plc:alice") 300 } else { 301 Issue.record("Expected first relationship entry to decode as relationship") 302 } 303 304 if case .notFound(let missing) = relationships.relationships[1] { 305 #expect(missing.actor == "did:plc:missing") 306 #expect(missing.notFound == true) 307 } else { 308 Issue.record("Expected second relationship entry to decode as notFound") 309 } 310 } 311 312 // MARK: - Repo 313 314 @Test("Decodes describe repo response with didDoc") 315 func decodesDescribeRepoResponse() throws { 316 let json = """ 317 { 318 "handle": "alice.bsky.social", 319 "did": "did:plc:alice", 320 "didDoc": { 321 "id": "did:plc:alice", 322 "service": [ 323 { 324 "id": "#atproto_pds", 325 "type": "AtprotoPersonalDataServer" 326 } 327 ] 328 }, 329 "collections": ["app.bsky.feed.post"], 330 "handleIsCorrect": true 331 } 332 """ 333 334 let response = try decode(DescribeRepoResponse.self, from: json) 335 #expect(response.handle == "alice.bsky.social") 336 #expect(response.did == "did:plc:alice") 337 #expect(response.collections == ["app.bsky.feed.post"]) 338 #expect(response.handleIsCorrect == true) 339 #expect(response.didDoc != nil) 340 } 341 342 @Test("Decodes list missing blobs response") 343 func decodesListMissingBlobsResponse() throws { 344 let json = """ 345 { 346 "cursor": "next", 347 "blobs": [ 348 { 349 "cid": "bafkreiabc", 350 "recordUri": "at://did:plc:alice/app.bsky.feed.post/1" 351 } 352 ] 353 } 354 """ 355 356 let response = try decode(ListMissingBlobsResponse.self, from: json) 357 #expect(response.cursor == "next") 358 #expect(response.blobs.count == 1) 359 #expect(response.blobs[0].cid == "bafkreiabc") 360 } 361 362 @Test("Decodes video upload limits") 363 func decodesVideoUploadLimits() throws { 364 let json = """ 365 { 366 "canUpload": true, 367 "remainingDailyVideos": 3, 368 "remainingDailyBytes": 10485760 369 } 370 """ 371 372 let limits = try decode(VideoUploadLimits.self, from: json) 373 #expect(limits.canUpload == true) 374 #expect(limits.remainingDailyVideos == 3) 375 #expect(limits.remainingDailyBytes == 10485760) 376 #expect(limits.error == nil) 377 } 378 379 // MARK: - Likes 380 381 @Test("Decodes likes response") 382 func decodesLikesResponse() throws { 383 let json = """ 384 { 385 "uri": "at://did:plc:post/app.bsky.feed.post/123", 386 "cid": "bafyreiabc", 387 "likes": [ 388 { 389 "actor": { 390 "did": "did:plc:liker", 391 "handle": "liker.bsky.social" 392 }, 393 "createdAt": "2024-01-15T10:30:00.000Z", 394 "indexedAt": "2024-01-15T10:30:00.000Z" 395 } 396 ], 397 "cursor": "next" 398 } 399 """ 400 401 let likes = try decode(Likes.self, from: json) 402 #expect(likes.uri == "at://did:plc:post/app.bsky.feed.post/123") 403 #expect(likes.likes.count == 1) 404 #expect(likes.likes[0].actor.handle == "liker.bsky.social") 405 } 406 407 // MARK: - Blocks 408 409 @Test("Decodes blocks response") 410 func decodesBlocksResponse() throws { 411 let json = """ 412 { 413 "blocks": [ 414 { 415 "did": "did:plc:blocked", 416 "handle": "blocked.bsky.social" 417 } 418 ] 419 } 420 """ 421 422 let blocks = try decode(Blocks.self, from: json) 423 #expect(blocks.blocks.count == 1) 424 #expect(blocks.cursor == nil) 425 } 426 427 // MARK: - Date Formats 428 429 @Test("Decodes various date formats") 430 func decodesDateFormats() throws { 431 let dateFormats = [ 432 "2024-01-15T10:30:00.000Z", 433 "2024-01-15T10:30:00Z", 434 "2024-01-15T10:30:00.123456Z", 435 "2024-01-15T10:30:00+00:00" 436 ] 437 438 for dateString in dateFormats { 439 let json = """ 440 { 441 "did": "did:plc:abc", 442 "handle": "test.bsky.social", 443 "indexedAt": "\(dateString)" 444 } 445 """ 446 447 let profile = try decode(Profile.self, from: json) 448 #expect(profile.indexedAt != nil, "Failed to decode date: \(dateString)") 449 } 450 } 451} 452 453// MARK: - JSONDecoder Extension 454 455extension JSONDecoder { 456 static var atDecoder: JSONDecoder { 457 let decoder = JSONDecoder() 458 decoder.dateDecodingStrategy = .custom { decoder in 459 let container = try decoder.singleValueContainer() 460 let dateString = try container.decode(String.self) 461 462 // Try various ISO 8601 formats 463 let formatters = [ 464 "yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXXXX", 465 "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX", 466 "yyyy-MM-dd'T'HH:mm:ssXXXXX", 467 "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", 468 "yyyy-MM-dd'T'HH:mm:ss'Z'" 469 ] 470 471 for format in formatters { 472 let formatter = DateFormatter() 473 formatter.dateFormat = format 474 formatter.locale = Locale(identifier: "en_US_POSIX") 475 formatter.timeZone = TimeZone(secondsFromGMT: 0) 476 if let date = formatter.date(from: dateString) { 477 return date 478 } 479 } 480 481 throw DecodingError.dataCorrupted( 482 DecodingError.Context( 483 codingPath: decoder.codingPath, 484 debugDescription: "Cannot decode date: \(dateString)" 485 ) 486 ) 487 } 488 return decoder 489 } 490}