this repo has no description
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}