From 6d5d99dfffb7dbf2ff3f596a4d0521231e302d60 Mon Sep 17 00:00:00 2001 From: Thomas Rademaker Date: Tue, 8 Sep 2026 11:11:22 -0400 Subject: [PATCH] Fix post-sign-in hang and timeline load failures: keep the login flow alive during OAuth, update the timeline cache in place, and decode deleted or blocked reply parents while dropping undecodable feed items --- Sources/bskyKit/Models/AuthorFeed.swift | 20 +- Sources/bskyKit/Models/Chat.swift | 9 - Sources/bskyKit/Models/LossyDecoding.swift | 52 ++++ Sources/bskyKit/Models/Notifications.swift | 14 + Sources/bskyKit/Models/PostThread.swift | 11 + Sources/bskyKit/Models/Posts.swift | 39 +++ Sources/bskyKit/Models/Timeline.swift | 184 +++++++++++-- Tests/bskyKitTests/LossyDecodingTests.swift | 247 ++++++++++++++++++ .../ReplyReferenceDecodingTests.swift | 172 ++++++++++++ 9 files changed, 710 insertions(+), 38 deletions(-) create mode 100644 Sources/bskyKit/Models/LossyDecoding.swift create mode 100644 Tests/bskyKitTests/LossyDecodingTests.swift create mode 100644 Tests/bskyKitTests/ReplyReferenceDecodingTests.swift diff --git a/Sources/bskyKit/Models/AuthorFeed.swift b/Sources/bskyKit/Models/AuthorFeed.swift index 0660cdf..adda090 100644 --- a/Sources/bskyKit/Models/AuthorFeed.swift +++ b/Sources/bskyKit/Models/AuthorFeed.swift @@ -7,8 +7,26 @@ import Foundation -/// Response from app.bsky.feed.getAuthorFeed +/// Response from app.bsky.feed.getAuthorFeed, and the other endpoints that +/// return a page of feed items (`getFeed`, `getListFeed`, `getActorLikes`). public struct AuthorFeed: Codable, Sendable { public let feed: [TimelineItem] public let cursor: String? + + enum CodingKeys: String, CodingKey { + case feed, cursor + } + + public init(feed: [TimelineItem], cursor: String? = nil) { + self.feed = feed + self.cursor = cursor + } + + /// Items the models can't decode are dropped (and logged) rather than + /// failing the page; see ``FailableDecodable``. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + feed = try container.decodeLossyArray(TimelineItem.self, forKey: .feed) + cursor = try container.decodeIfPresent(String.self, forKey: .cursor) + } } diff --git a/Sources/bskyKit/Models/Chat.swift b/Sources/bskyKit/Models/Chat.swift index d4a26ef..758418f 100644 --- a/Sources/bskyKit/Models/Chat.swift +++ b/Sources/bskyKit/Models/Chat.swift @@ -1,14 +1,5 @@ import Foundation -// MARK: - Fault-Tolerant Decoding - -private struct FailableDecodable: Decodable, Sendable { - let value: T? - init(from decoder: Decoder) throws { - value = try? T(from: decoder) - } -} - /// Response from chat.bsky.convo.listConvos public struct ConvoListResponse: Codable, Sendable { public let convos: [ConvoView] diff --git a/Sources/bskyKit/Models/LossyDecoding.swift b/Sources/bskyKit/Models/LossyDecoding.swift new file mode 100644 index 0000000..4587c03 --- /dev/null +++ b/Sources/bskyKit/Models/LossyDecoding.swift @@ -0,0 +1,52 @@ +import Foundation +import os + +let decodingLog = Logger(subsystem: "com.sparrowtek.bskyKit", category: "Decoding") + +/// Decodes `T`, or records the failure and yields `nil`. +/// +/// Wrapping the elements of a collection in this type lets one malformed +/// element be dropped instead of failing the whole response. Feeds, threads, +/// and notification lists are built from records written by arbitrary +/// clients and from view shapes that evolve over time, so a page is never +/// worth losing over a single item the models don't understand. +struct FailableDecodable: Decodable, Sendable { + let value: T? + + init(from decoder: Decoder) throws { + do { + value = try T(from: decoder) + } catch { + let path = decoder.codingPath.map(\.stringValue).joined(separator: ".") + decodingLog.error("Dropped undecodable \(String(describing: T.self), privacy: .public) at \(path, privacy: .public): \(String(describing: error), privacy: .public)") + value = nil + } + } +} + +extension KeyedDecodingContainer { + /// Decodes an array, skipping (and logging) elements that fail to decode. + func decodeLossyArray(_ type: T.Type, forKey key: Key) throws -> [T] { + try decode([FailableDecodable].self, forKey: key).compactMap(\.value) + } + + /// Decodes an optional array, skipping (and logging) elements that fail to decode. + func decodeLossyArrayIfPresent(_ type: T.Type, forKey key: Key) throws -> [T]? { + try decodeIfPresent([FailableDecodable].self, forKey: key)?.compactMap(\.value) + } + + /// Decodes an optional value, treating a malformed value like a missing one. + /// + /// For fields of records written by other clients (`createdAt`, facets, + /// embeds): the lexicon says what they should look like, but the network + /// doesn't enforce it, and a post is still worth showing without them. + func decodeLeniently(_ type: T.Type, forKey key: Key) -> T? { + do { + return try decodeIfPresent(type, forKey: key) + } catch { + let path = (codingPath + [key]).map(\.stringValue).joined(separator: ".") + decodingLog.debug("Ignoring malformed \(String(describing: T.self), privacy: .public) at \(path, privacy: .public): \(String(describing: error), privacy: .public)") + return nil + } + } +} diff --git a/Sources/bskyKit/Models/Notifications.swift b/Sources/bskyKit/Models/Notifications.swift index 94b455c..be66430 100644 --- a/Sources/bskyKit/Models/Notifications.swift +++ b/Sources/bskyKit/Models/Notifications.swift @@ -8,10 +8,24 @@ import Foundation /// Response from app.bsky.notification.listNotifications +/// +/// Notifications the models can't decode are dropped (and logged) rather +/// than failing the page; see ``FailableDecodable``. public struct NotificationsResponse: Codable, Sendable { public let notifications: [Notification] public let cursor: String? public let seenAt: Date? + + enum CodingKeys: String, CodingKey { + case notifications, cursor, seenAt + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + notifications = try container.decodeLossyArray(Notification.self, forKey: .notifications) + cursor = try container.decodeIfPresent(String.self, forKey: .cursor) + seenAt = try container.decodeIfPresent(Date.self, forKey: .seenAt) + } } /// A single notification diff --git a/Sources/bskyKit/Models/PostThread.swift b/Sources/bskyKit/Models/PostThread.swift index 5054c77..cb1526c 100644 --- a/Sources/bskyKit/Models/PostThread.swift +++ b/Sources/bskyKit/Models/PostThread.swift @@ -70,6 +70,17 @@ public final class ThreadViewPost: Codable, Sendable, Identifiable { self.parent = parent self.replies = replies } + + /// Replies the models can't decode are dropped (and logged) rather than + /// failing the whole thread, and a parent in a shape this package doesn't + /// know is treated as absent; see ``FailableDecodable``. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + type = try container.decodeIfPresent(String.self, forKey: .type) + post = try container.decode(Post.self, forKey: .post) + parent = container.decodeLeniently(ThreadNode.self, forKey: .parent) + replies = try container.decodeLossyArrayIfPresent(ThreadNode.self, forKey: .replies) + } } /// A post that was not found diff --git a/Sources/bskyKit/Models/Posts.swift b/Sources/bskyKit/Models/Posts.swift index a5ea39a..c7de99b 100644 --- a/Sources/bskyKit/Models/Posts.swift +++ b/Sources/bskyKit/Models/Posts.swift @@ -8,8 +8,24 @@ import Foundation /// Response from app.bsky.feed.getPosts +/// +/// Posts the models can't decode are dropped (and logged) rather than +/// failing the response; see ``FailableDecodable``. public struct Posts: Codable, Sendable { public let posts: [Post] + + enum CodingKeys: String, CodingKey { + case posts + } + + public init(posts: [Post]) { + self.posts = posts + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + posts = try container.decodeLossyArray(Post.self, forKey: .posts) + } } /// Response from app.bsky.feed.searchPosts @@ -17,6 +33,17 @@ public struct SearchPostsResponse: Codable, Sendable { public let posts: [Post] public let cursor: String? public let hitsTotal: Int? + + enum CodingKeys: String, CodingKey { + case posts, cursor, hitsTotal + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + posts = try container.decodeLossyArray(Post.self, forKey: .posts) + cursor = try container.decodeIfPresent(String.self, forKey: .cursor) + hitsTotal = try container.decodeIfPresent(Int.self, forKey: .hitsTotal) + } } /// Response from app.bsky.feed.getQuotes @@ -25,4 +52,16 @@ public struct QuotesResponse: Codable, Sendable { public let cid: String? public let posts: [Post] public let cursor: String? + + enum CodingKeys: String, CodingKey { + case uri, cid, posts, cursor + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + uri = try container.decode(String.self, forKey: .uri) + cid = try container.decodeIfPresent(String.self, forKey: .cid) + posts = try container.decodeLossyArray(Post.self, forKey: .posts) + cursor = try container.decodeIfPresent(String.self, forKey: .cursor) + } } diff --git a/Sources/bskyKit/Models/Timeline.swift b/Sources/bskyKit/Models/Timeline.swift index 197ed57..205b286 100644 --- a/Sources/bskyKit/Models/Timeline.swift +++ b/Sources/bskyKit/Models/Timeline.swift @@ -3,6 +3,23 @@ import Foundation public struct Timeline: Codable, Sendable { public var feed: [TimelineItem] public var cursor: String? + + enum CodingKeys: String, CodingKey { + case feed, cursor + } + + public init(feed: [TimelineItem], cursor: String? = nil) { + self.feed = feed + self.cursor = cursor + } + + /// Items the models can't decode are dropped (and logged) rather than + /// failing the page; see ``FailableDecodable``. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + feed = try container.decodeLossyArray(TimelineItem.self, forKey: .feed) + cursor = try container.decodeIfPresent(String.self, forKey: .cursor) + } } public struct TimelineItem: Codable, Sendable, Identifiable, Equatable { @@ -457,6 +474,18 @@ public struct EmbedRecordValue: Codable, Sendable { self.facets = facets } + /// Like ``Record``, the quoted record was written by an arbitrary client, + /// so its optional fields are decoded leniently. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + type = try container.decodeIfPresent(String.self, forKey: .type) + text = try container.decodeIfPresent(String.self, forKey: .text) + langs = container.decodeLeniently([String].self, forKey: .langs) + reply = container.decodeLeniently(ReplyDetail.self, forKey: .reply) + createdAt = container.decodeLeniently(Date.self, forKey: .createdAt) + facets = container.decodeLeniently([Facet].self, forKey: .facets) + } + /// Rich-text rendering of the embedded post body, with mention/tag/link /// facets converted into tappable `link` attributes (see ``FacetURL``). public var attributedText: AttributedString { @@ -594,9 +623,124 @@ public struct EmbedImageAspectRatio: Codable, Sendable, Equatable { } } +/// The thread context of a feed item (`app.bsky.feed.defs#replyRef`): the +/// root of the thread and the post being replied to, plus the grandparent's +/// author when the AppView includes it. public struct Reply: Codable, Sendable { - public let root: Root - public let parent: Parent + public let root: ReferencedPost + public let parent: ReferencedPost + public let grandparentAuthor: Author? + + public init(root: ReferencedPost, parent: ReferencedPost, grandparentAuthor: Author? = nil) { + self.root = root + self.parent = parent + self.grandparentAuthor = grandparentAuthor + } +} + +/// One side of a reply reference. +/// +/// The referenced post is hydrated when the viewer may see it. A post that +/// has been deleted arrives as a `notFoundPost` stub, and one whose author +/// is blocked (either direction) as a `blockedPost` stub; both carry only +/// the post's URI. Shapes this package doesn't know are kept as +/// ``unknown(type:uri:)`` so the reply itself still renders. +public enum ReferencedPost: Codable, Sendable { + case post(ReplyPost) + case notFound(NotFoundPost) + case blocked(BlockedPost) + case unknown(type: String?, uri: String?) + + enum CodingKeys: String, CodingKey { + case type = "$type" + case uri + } + + private static let postViewType = "app.bsky.feed.defs#postView" + private static let notFoundPostType = "app.bsky.feed.defs#notFoundPost" + private static let blockedPostType = "app.bsky.feed.defs#blockedPost" + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decodeIfPresent(String.self, forKey: .type) + let uri = try container.decodeIfPresent(String.self, forKey: .uri) + + switch type { + case Self.notFoundPostType: + if let stub = try? NotFoundPost(from: decoder) { + self = .notFound(stub) + } else { + self = .unknown(type: type, uri: uri) + } + case Self.blockedPostType: + if let stub = try? BlockedPost(from: decoder) { + self = .blocked(stub) + } else { + self = .unknown(type: type, uri: uri) + } + default: + // A hydrated post, whether or not it is typed. Cached feeds written + // by earlier versions of this package land here too. + do { + self = .post(try ReplyPost(from: decoder)) + } catch { + let path = decoder.codingPath.map(\.stringValue).joined(separator: ".") + decodingLog.error("Reply reference at \(path, privacy: .public) is not a decodable post (\(type ?? "untyped", privacy: .public)): \(String(describing: error), privacy: .public)") + self = .unknown(type: type, uri: uri) + } + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .post(let post): + try post.encode(to: encoder) + case .notFound(let stub): + try stub.encode(to: encoder) + case .blocked(let stub): + try stub.encode(to: encoder) + case .unknown(let type, let uri): + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(type, forKey: .type) + try container.encodeIfPresent(uri, forKey: .uri) + } + } + + /// The hydrated post, when the viewer may see it. + public var post: ReplyPost? { + if case .post(let post) = self { return post } + return nil + } + + public var uri: String? { + switch self { + case .post(let post): post.uri + case .notFound(let stub): stub.uri + case .blocked(let stub): stub.uri + case .unknown(_, let uri): uri + } + } + + /// The author of the referenced post, when it is hydrated. + public var author: Author? { + post?.author + } + + public var isNotFound: Bool { + if case .notFound = self { return true } + return false + } + + public var isBlocked: Bool { + if case .blocked = self { return true } + return false + } + + /// True when there is no post to show: deleted, blocked, or a shape this + /// package doesn't understand. + public var isUnavailable: Bool { + post == nil + } } public struct Author: Codable, Sendable { @@ -631,15 +775,18 @@ public struct Record: Codable, Sendable { case text, langs, reply, createdAt, embed, facets } + /// Records are written by whichever client created the post, so every + /// field beyond the text is decoded leniently: a malformed timestamp, + /// facet, or embed is dropped rather than failing the post. public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) type = try container.decodeIfPresent(String.self, forKey: .type) text = try container.decodeIfPresent(String.self, forKey: .text) - langs = try container.decodeIfPresent([String].self, forKey: .langs) - reply = try container.decodeIfPresent(ReplyDetail.self, forKey: .reply) - createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) - embed = try container.decodeIfPresent(Embed.self, forKey: .embed) - facets = try container.decodeIfPresent([Facet].self, forKey: .facets) + langs = container.decodeLeniently([String].self, forKey: .langs) + reply = container.decodeLeniently(ReplyDetail.self, forKey: .reply) + createdAt = container.decodeLeniently(Date.self, forKey: .createdAt) + embed = container.decodeLeniently(Embed.self, forKey: .embed) + facets = container.decodeLeniently([Facet].self, forKey: .facets) } public func encode(to encoder: Encoder) throws { @@ -793,27 +940,8 @@ public struct UnpopulatedPost: Codable, Sendable { } } -public struct Root: Codable, Sendable { - public let type: String? - public let uri: String? - public let cid: String? - public let author: Author - public let record: Record - public let replyCount: Int? - public let repostCount: Int? - public let likeCount: Int? - public let quoteCount: Int? - public let indexedAt: Date? - public let viewer: FeedViewer? - public let labels: [AuthorLabels]? - - enum CodingKeys: String, CodingKey { - case type = "$type" - case uri, cid, author, record, replyCount, repostCount, likeCount, quoteCount, indexedAt, viewer, labels - } -} - -public struct Parent: Codable, Sendable { +/// A hydrated post referenced from a reply (`app.bsky.feed.defs#postView`). +public struct ReplyPost: Codable, Sendable { public let type: String? public let uri: String? public let cid: String? diff --git a/Tests/bskyKitTests/LossyDecodingTests.swift b/Tests/bskyKitTests/LossyDecodingTests.swift new file mode 100644 index 0000000..e4f432a --- /dev/null +++ b/Tests/bskyKitTests/LossyDecodingTests.swift @@ -0,0 +1,247 @@ +import Testing +import Foundation +@testable import bskyKit + +@Suite("Lossy decoding") +struct LossyDecodingTests { + + private func decode(_ type: T.Type, from json: String) throws -> T { + try JSONDecoder.atDecoder.decode(type, from: Data(json.utf8)) + } + + private static func postJSON(id: Int, record: String = "{\"text\": \"post\"}") -> String { + """ + { + "uri": "at://did:plc:alice/app.bsky.feed.post/\(id)", + "cid": "bafy\(id)", + "author": {"did": "did:plc:alice", "handle": "alice.bsky.social"}, + "record": \(record), + "indexedAt": "2026-09-07T12:00:00.000Z" + } + """ + } + + /// A post view missing its author, which no model can represent. + private static let brokenPostJSON = """ + { + "uri": "at://did:plc:alice/app.bsky.feed.post/broken", + "cid": "bafybroken", + "record": {"text": "no author"}, + "indexedAt": "2026-09-07T12:00:00.000Z" + } + """ + + // MARK: - Feeds + + @Test("A timeline keeps the items around one that fails to decode") + func timelineDropsBrokenItem() throws { + let json = """ + { + "feed": [ + {"post": \(Self.postJSON(id: 1))}, + {"post": \(Self.brokenPostJSON)}, + {"post": \(Self.postJSON(id: 2))} + ], + "cursor": "next" + } + """ + let timeline = try decode(Timeline.self, from: json) + #expect(timeline.feed.map(\.post.cid) == ["bafy1", "bafy2"]) + #expect(timeline.cursor == "next") + } + + @Test("An author feed keeps the items around one that fails to decode") + func authorFeedDropsBrokenItem() throws { + let json = """ + { + "feed": [ + {"post": \(Self.brokenPostJSON)}, + {"post": \(Self.postJSON(id: 1))} + ] + } + """ + let feed = try decode(AuthorFeed.self, from: json) + #expect(feed.feed.map(\.post.cid) == ["bafy1"]) + #expect(feed.cursor == nil) + } + + @Test("An empty feed still decodes") + func emptyFeed() throws { + let timeline = try decode(Timeline.self, from: "{\"feed\": []}") + #expect(timeline.feed.isEmpty) + } + + @Test("A feed with no feed key is still an error") + func missingFeedKeyFails() { + #expect(throws: DecodingError.self) { + try decode(Timeline.self, from: "{\"cursor\": \"x\"}") + } + } + + @Test("getPosts, searchPosts, and getQuotes drop posts that fail to decode") + func postCollectionsDropBrokenPosts() throws { + let posts = try decode(Posts.self, from: "{\"posts\": [\(Self.brokenPostJSON), \(Self.postJSON(id: 1))]}") + #expect(posts.posts.map(\.cid) == ["bafy1"]) + + let search = try decode(SearchPostsResponse.self, from: "{\"posts\": [\(Self.postJSON(id: 3)), \(Self.brokenPostJSON)], \"cursor\": \"c\", \"hitsTotal\": 2}") + #expect(search.posts.map(\.cid) == ["bafy3"]) + #expect(search.cursor == "c") + #expect(search.hitsTotal == 2) + + let quotes = try decode(QuotesResponse.self, from: "{\"uri\": \"at://x\", \"posts\": [\(Self.brokenPostJSON)]}") + #expect(quotes.uri == "at://x") + #expect(quotes.posts.isEmpty) + } + + // MARK: - Threads + + @Test("A thread keeps the replies around one that fails to decode") + func threadDropsBrokenReply() throws { + let json = """ + { + "thread": { + "$type": "app.bsky.feed.defs#threadViewPost", + "post": \(Self.postJSON(id: 1)), + "replies": [ + {"$type": "app.bsky.feed.defs#threadViewPost", "post": \(Self.postJSON(id: 2))}, + {"$type": "app.bsky.feed.defs#threadViewPost", "post": \(Self.brokenPostJSON)}, + {"$type": "app.bsky.feed.defs#notFoundPost", "uri": "at://gone", "notFound": true} + ] + } + } + """ + let response = try decode(PostThreadResponse.self, from: json) + let thread = try #require(response.threadPost) + let replies = try #require(thread.replies) + #expect(replies.count == 2) + if case .post(let reply) = replies[0] { + #expect(reply.post.cid == "bafy2") + } else { + Issue.record("expected the first reply to be a post") + } + if case .notFound(let stub) = replies[1] { + #expect(stub.uri == "at://gone") + } else { + Issue.record("expected the second reply to be a not-found stub") + } + } + + @Test("A thread whose parent has an unknown shape keeps the post and its replies") + func threadIgnoresUndecodableParent() throws { + let json = """ + { + "thread": { + "$type": "app.bsky.feed.defs#threadViewPost", + "post": \(Self.postJSON(id: 1)), + "parent": {"$type": "app.bsky.feed.defs#futureThreadNode", "uri": "at://future"}, + "replies": [ + {"$type": "app.bsky.feed.defs#threadViewPost", "post": \(Self.postJSON(id: 2))} + ] + } + } + """ + let response = try decode(PostThreadResponse.self, from: json) + let thread = try #require(response.threadPost) + #expect(thread.post.cid == "bafy1") + #expect(thread.parent == nil) + #expect(thread.replies?.count == 1) + } + + // MARK: - Notifications + + @Test("A notification page keeps the entries around one that fails to decode") + func notificationsDropBrokenEntry() throws { + let json = """ + { + "notifications": [ + { + "uri": "at://did:plc:a/app.bsky.feed.like/1", + "cid": "c1", + "author": {"did": "did:plc:a", "handle": "a.bsky.social"}, + "reason": "like", + "isRead": true, + "indexedAt": "2026-09-07T12:00:00.000Z" + }, + { + "uri": "at://did:plc:b/app.bsky.feed.like/2", + "cid": "c2", + "reason": "like", + "isRead": true, + "indexedAt": "2026-09-07T12:00:00.000Z" + } + ], + "cursor": "n", + "seenAt": "2026-09-07T11:00:00.000Z" + } + """ + let response = try decode(NotificationsResponse.self, from: json) + #expect(response.notifications.map(\.cid) == ["c1"]) + #expect(response.cursor == "n") + #expect(response.seenAt != nil) + } + + // MARK: - Record leniency + + @Test("A malformed createdAt in a record is ignored, not fatal") + func recordIgnoresMalformedCreatedAt() throws { + let record = "{\"$type\": \"app.bsky.feed.post\", \"text\": \"hello\", \"createdAt\": \"yesterday\"}" + let post = try decode(Post.self, from: Self.postJSON(id: 1, record: record)) + #expect(post.record.text == "hello") + #expect(post.record.createdAt == nil) + } + + @Test("Malformed facets and embeds in a record are ignored, not fatal") + func recordIgnoresMalformedFacetsAndEmbed() throws { + let record = """ + { + "text": "hello #tag", + "createdAt": "2026-09-07T12:00:00.000Z", + "facets": [{"index": {"byteStart": "six"}, "features": []}], + "embed": {"$type": "app.bsky.embed.images", "images": [{"aspectRatio": {"width": "wide"}}]}, + "langs": "en" + } + """ + let post = try decode(Post.self, from: Self.postJSON(id: 1, record: record)) + #expect(post.record.text == "hello #tag") + #expect(post.record.createdAt != nil) + #expect(post.record.facets == nil) + #expect(post.record.embed == nil) + #expect(post.record.langs == nil) + } + + @Test("A well-formed record keeps every field") + func recordKeepsWellFormedFields() throws { + let record = """ + { + "text": "hello #tag", + "createdAt": "2026-09-07T12:00:00.000Z", + "langs": ["en"], + "facets": [{"index": {"byteStart": 6, "byteEnd": 10}, "features": [{"$type": "app.bsky.richtext.facet#tag", "tag": "tag"}]}], + "reply": {"root": {"uri": "at://r", "cid": "cr"}, "parent": {"uri": "at://p", "cid": "cp"}} + } + """ + let post = try decode(Post.self, from: Self.postJSON(id: 1, record: record)) + #expect(post.record.langs == ["en"]) + #expect(post.record.facets?.count == 1) + #expect(post.record.reply?.parent.uri == "at://p") + #expect(post.record.createdAt != nil) + } + + @Test("A quoted record with a malformed createdAt still decodes") + func quotedRecordIgnoresMalformedCreatedAt() throws { + let json = """ + { + "$type": "app.bsky.embed.record#viewRecord", + "uri": "at://did:plc:alice/app.bsky.feed.post/1", + "cid": "bafy1", + "author": {"did": "did:plc:alice", "handle": "alice.bsky.social"}, + "value": {"$type": "app.bsky.feed.post", "text": "quoted", "createdAt": "not a date", "facets": "nope"}, + "indexedAt": "2026-09-07T12:00:00.000Z" + } + """ + let record = try decode(EmbedRecord.self, from: json) + #expect(record.value?.text == "quoted") + #expect(record.value?.createdAt == nil) + #expect(record.value?.facets == nil) + } +} diff --git a/Tests/bskyKitTests/ReplyReferenceDecodingTests.swift b/Tests/bskyKitTests/ReplyReferenceDecodingTests.swift new file mode 100644 index 0000000..2b78c80 --- /dev/null +++ b/Tests/bskyKitTests/ReplyReferenceDecodingTests.swift @@ -0,0 +1,172 @@ +import Testing +import Foundation +@testable import bskyKit + +@Suite("Reply reference decoding") +struct ReplyReferenceDecodingTests { + + private func decode(_ type: T.Type, from json: String) throws -> T { + try JSONDecoder.atDecoder.decode(type, from: Data(json.utf8)) + } + + private static let rootPostJSON = """ + { + "$type": "app.bsky.feed.defs#postView", + "uri": "at://did:plc:root/app.bsky.feed.post/1", + "cid": "bafyroot", + "author": {"did": "did:plc:root", "handle": "root.bsky.social", "displayName": "Root"}, + "record": {"$type": "app.bsky.feed.post", "text": "thread root", "createdAt": "2026-09-01T10:00:00.000Z"}, + "replyCount": 4, + "indexedAt": "2026-09-01T10:00:00.000Z" + } + """ + + private static func timelineItemJSON(parent: String) -> String { + """ + { + "post": { + "uri": "at://did:plc:alice/app.bsky.feed.post/9", + "cid": "bafyalice", + "author": {"did": "did:plc:alice", "handle": "alice.bsky.social"}, + "record": {"$type": "app.bsky.feed.post", "text": "a reply", "createdAt": "2026-09-07T12:00:00.000Z"}, + "indexedAt": "2026-09-07T12:00:00.000Z" + }, + "reply": { + "root": \(rootPostJSON), + "parent": \(parent) + } + } + """ + } + + @Test("A hydrated parent decodes as a post") + func hydratedParent() throws { + let item = try decode(TimelineItem.self, from: Self.timelineItemJSON(parent: Self.rootPostJSON)) + let reply = try #require(item.reply) + + let parent = try #require(reply.parent.post) + #expect(parent.author.handle == "root.bsky.social") + #expect(parent.record.text == "thread root") + #expect(parent.replyCount == 4) + #expect(reply.parent.uri == "at://did:plc:root/app.bsky.feed.post/1") + #expect(reply.parent.author?.did == "did:plc:root") + #expect(reply.parent.isUnavailable == false) + #expect(reply.root.post != nil) + } + + @Test("A reply to a deleted post keeps the reply and marks the parent not found") + func notFoundParent() throws { + let parent = """ + {"$type": "app.bsky.feed.defs#notFoundPost", "uri": "at://did:plc:gone/app.bsky.feed.post/1", "notFound": true} + """ + let item = try decode(TimelineItem.self, from: Self.timelineItemJSON(parent: parent)) + let reply = try #require(item.reply) + + #expect(item.post.record.text == "a reply") + #expect(reply.parent.isNotFound) + #expect(reply.parent.isUnavailable) + #expect(reply.parent.post == nil) + #expect(reply.parent.author == nil) + #expect(reply.parent.uri == "at://did:plc:gone/app.bsky.feed.post/1") + #expect(reply.root.post?.author.handle == "root.bsky.social") + } + + @Test("A reply to a blocked post keeps the reply and exposes the blocked author's DID") + func blockedParent() throws { + let parent = """ + { + "$type": "app.bsky.feed.defs#blockedPost", + "uri": "at://did:plc:blocked/app.bsky.feed.post/1", + "blocked": true, + "author": {"did": "did:plc:blocked", "viewer": {"blockedBy": true}} + } + """ + let item = try decode(TimelineItem.self, from: Self.timelineItemJSON(parent: parent)) + let reply = try #require(item.reply) + + #expect(reply.parent.isBlocked) + #expect(reply.parent.isUnavailable) + #expect(reply.parent.author == nil) + if case .blocked(let stub) = reply.parent { + #expect(stub.author.did == "did:plc:blocked") + #expect(stub.author.viewer?.blockedBy == true) + } else { + Issue.record("expected a blocked stub") + } + } + + @Test("An unknown stub is kept with its type and URI") + func unknownStub() throws { + let parent = """ + {"$type": "app.bsky.feed.defs#futurePost", "uri": "at://did:plc:future/app.bsky.feed.post/1"} + """ + let item = try decode(TimelineItem.self, from: Self.timelineItemJSON(parent: parent)) + let reply = try #require(item.reply) + + #expect(reply.parent.isUnavailable) + #expect(reply.parent.isNotFound == false) + #expect(reply.parent.isBlocked == false) + if case .unknown(let type, let uri) = reply.parent { + #expect(type == "app.bsky.feed.defs#futurePost") + #expect(uri == "at://did:plc:future/app.bsky.feed.post/1") + } else { + Issue.record("expected an unknown stub") + } + } + + @Test("An untyped hydrated post still decodes as a post") + func untypedPost() throws { + let parent = """ + { + "uri": "at://did:plc:root/app.bsky.feed.post/1", + "cid": "bafyroot", + "author": {"did": "did:plc:root", "handle": "root.bsky.social"}, + "record": {"text": "thread root"}, + "indexedAt": "2026-09-01T10:00:00.000Z" + } + """ + let item = try decode(TimelineItem.self, from: Self.timelineItemJSON(parent: parent)) + #expect(item.reply?.parent.post?.author.handle == "root.bsky.social") + } + + @Test("Reply references survive an encode/decode round trip") + func roundTrip() throws { + let parent = """ + {"$type": "app.bsky.feed.defs#notFoundPost", "uri": "at://did:plc:gone/app.bsky.feed.post/1", "notFound": true} + """ + let original = try decode(TimelineItem.self, from: Self.timelineItemJSON(parent: parent)) + + let data = try JSONEncoder().encode(original) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .deferredToDate + let restored = try decoder.decode(TimelineItem.self, from: data) + + let reply = try #require(restored.reply) + #expect(reply.parent.isNotFound) + #expect(reply.parent.uri == "at://did:plc:gone/app.bsky.feed.post/1") + #expect(reply.root.post?.author.handle == "root.bsky.social") + #expect(reply.root.post?.record.text == "thread root") + } + + @Test("The grandparent author is decoded when present") + func grandparentAuthor() throws { + let json = """ + { + "post": { + "uri": "at://did:plc:alice/app.bsky.feed.post/9", + "cid": "bafyalice", + "author": {"did": "did:plc:alice", "handle": "alice.bsky.social"}, + "record": {"text": "a reply"}, + "indexedAt": "2026-09-07T12:00:00.000Z" + }, + "reply": { + "root": \(Self.rootPostJSON), + "parent": \(Self.rootPostJSON), + "grandparentAuthor": {"did": "did:plc:grand", "handle": "grand.bsky.social"} + } + } + """ + let item = try decode(TimelineItem.self, from: json) + #expect(item.reply?.grandparentAuthor?.handle == "grand.bsky.social") + } +} -- 2.51.2