From 2e5726e94973c1632e64f9f5de57fe095e1d0484 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 13:37:04 -0700 Subject: [PATCH] fix(messages): decode the metadata route's real {links} shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/messages/{id}/metadata answers `{ "links": [...] }` at the top level from both of its exits (route.ts:86 for "no links detected", :105 for the fetched items), and every entry is a full LinkMetadataItem built by fetchMultipleLinkMetadata — the same producer behind /api/link-metadata and the same shape the feed row already carries. `serialize()` normalizes values only; it never restructures. refreshMessageMetadata expected a `metadata` wrapper around flat {url,title,description,image} previews, so `response.metadata` was always nil and the call returned [] against the live API, silently. `links` is now decoded as required rather than optional: degrading a missing key to an empty array is exactly how the mismatch stayed invisible. With the route's entries already in the feed row's shape, the MessageLinkPreview -> LinkMetadataItem conversion added by #103 has nothing left to convert. It and MessageLinkPreview (no other consumer) are deleted along with their tests, and ComposeView passes the decoded links straight to AppDataStore.applyLinkMetadata. The fixtures are rewritten from the route and metadata-fetcher sources, not from the Swift type: success, failed-entry (no `metadata`, no `fetchedAt`), the empty short-circuit, Instagram's unmodelled caption/derivedList extras, and the old wrapper shape, which must now throw instead of decoding to []. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017bss5MgZa7Jvj2m9zdaUd1 --- InterlinedList.xcodeproj/project.pbxproj | 4 - InterlinedList/Models/Message.swift | 45 ------- InterlinedList/Services/APIClient.swift | 16 ++- InterlinedList/Views/ComposeView.swift | 4 +- .../APIClientGapPhasesTests.swift | 8 -- .../APIClientMessagesTests.swift | 124 ++++++++++++++++++ .../ComposeLinkDetectionTests.swift | 4 +- .../LinkMetadataConversionTests.swift | 102 -------------- 8 files changed, 138 insertions(+), 169 deletions(-) delete mode 100644 InterlinedListTests/ModelTests/LinkMetadataConversionTests.swift diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index 4547383..b912dd9 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -60,7 +60,6 @@ 5F39C031ED69D47574ACF17F /* APIClientGitHubMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94499C7F9007F4B2CE90CA91 /* APIClientGitHubMetadataTests.swift */; }; 64F5804ECC25725FD1E58E84 /* APIClientModerationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A569D8DB8DFD3CC59072FDB /* APIClientModerationTests.swift */; }; C0FFEE9102ABCDEF00000091 /* ComposeLinkDetectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FFEE9101ABCDEF00000091 /* ComposeLinkDetectionTests.swift */; }; - C0FFEE9602ABCDEF00000096 /* LinkMetadataConversionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FFEE9601ABCDEF00000096 /* LinkMetadataConversionTests.swift */; }; 6749119D27FA93BE00D5A27F /* FeedTruncationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05A695D62B746B092CF51AFA /* FeedTruncationTests.swift */; }; 6A89622E299B0D172D5B5556 /* GitHubModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C337406C10876F331B1888E3 /* GitHubModelTests.swift */; }; 6C10CC420377B0E8AE5C82DB /* GapModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */; }; @@ -253,7 +252,6 @@ 03C47A3ECFD7F4D25F91E3BE /* ShareLinksSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareLinksSheet.swift; sourceTree = ""; }; 03D6C33FD59101503195F14F /* SharedDocumentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedDocumentView.swift; sourceTree = ""; }; C0FFEE9101ABCDEF00000091 /* ComposeLinkDetectionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeLinkDetectionTests.swift; sourceTree = ""; }; - C0FFEE9601ABCDEF00000096 /* LinkMetadataConversionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LinkMetadataConversionTests.swift; sourceTree = ""; }; 05A695D62B746B092CF51AFA /* FeedTruncationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedTruncationTests.swift; sourceTree = ""; }; 06CAA8B0033D6DD27ED089CD /* ShareInvitesSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareInvitesSheet.swift; sourceTree = ""; }; 08D80C6D8E2B0C7A3AB7E0B2 /* DocumentLinkView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentLinkView.swift; sourceTree = ""; }; @@ -785,7 +783,6 @@ 93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */, F78B096C366108083D174366 /* MarkdownBlockTests.swift */, C0FFEE9101ABCDEF00000091 /* ComposeLinkDetectionTests.swift */, - C0FFEE9601ABCDEF00000096 /* LinkMetadataConversionTests.swift */, 05A695D62B746B092CF51AFA /* FeedTruncationTests.swift */, EE6BD1CB3C5C5C494971E0B4 /* DirectMessageModelTests.swift */, A5C9FAA36F8413788E1776C7 /* LinkedInTargetModelTests.swift */, @@ -1112,7 +1109,6 @@ 09037C64E9D988371F29AD75 /* ComposeImageUploaderTests.swift in Sources */, 98CC00030F4340A4BD821FC4 /* MarkdownBlockTests.swift in Sources */, C0FFEE9102ABCDEF00000091 /* ComposeLinkDetectionTests.swift in Sources */, - C0FFEE9602ABCDEF00000096 /* LinkMetadataConversionTests.swift in Sources */, 6749119D27FA93BE00D5A27F /* FeedTruncationTests.swift in Sources */, 362DEA03EA2CDD9E135B1411 /* APIClientDirectMessagesTests.swift in Sources */, 874DE298749DF285984F1182 /* DirectMessageModelTests.swift in Sources */, diff --git a/InterlinedList/Models/Message.swift b/InterlinedList/Models/Message.swift index 8af9889..4151419 100644 --- a/InterlinedList/Models/Message.swift +++ b/InterlinedList/Models/Message.swift @@ -32,51 +32,6 @@ struct LinkMetadata: Codable { let links: [LinkMetadataItem] } -/// A single resolved link preview from POST /api/messages/:id/metadata. -struct MessageLinkPreview: Codable, Identifiable { - let url: String - let title: String? - let description: String? - let image: String? - - var id: String { url } -} - -extension LinkMetadataItem { - /// Rebuilds the feed row's nested preview shape from the flat previews the - /// metadata refresh returns, so a just-published message can render its card - /// without waiting for the next feed fetch. - /// - /// `metadata` stays nil when a preview resolved to nothing renderable: the feed - /// card is drawn only for a non-nil `metadata` (`LinkPreviewBlock`), so an - /// all-nil content object would draw an empty box. The item itself is kept, - /// because its `url` is what the rest of the app reads off a link. - /// - /// `platform` and `fetchStatus` stay nil rather than being guessed: the flat - /// preview carries neither, and nothing renders off them. - init(preview: MessageLinkPreview) { - let title = Self.trimmedNonEmpty(preview.title) - let description = Self.trimmedNonEmpty(preview.description) - let thumbnail = Self.trimmedNonEmpty(preview.image) - let content: LinkMetadataItemContent? = - title == nil && description == nil && thumbnail == nil - ? nil - : LinkMetadataItemContent(thumbnail: thumbnail, title: title, - description: description, text: nil, type: nil) - self.init(url: preview.url, platform: nil, metadata: content, fetchStatus: nil) - } - - static func from(previews: [MessageLinkPreview]) -> [LinkMetadataItem] { - previews.map(LinkMetadataItem.init(preview:)) - } - - private static func trimmedNonEmpty(_ value: String?) -> String? { - guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), - !trimmed.isEmpty else { return nil } - return trimmed - } -} - /// One destination a message was actually cross-posted to, echoed back on the /// Message after it publishes (server field: `crossPostUrls`). The shape differs /// per platform — Mastodon carries `statusId`/`instanceUrl`, Bluesky carries diff --git a/InterlinedList/Services/APIClient.swift b/InterlinedList/Services/APIClient.swift index 589c422..0176daa 100644 --- a/InterlinedList/Services/APIClient.swift +++ b/InterlinedList/Services/APIClient.swift @@ -366,16 +366,20 @@ final class APIClient { } /// Fetch/refresh OpenGraph link-preview metadata for a message's links. + /// + /// `app/api/messages/[id]/metadata/route.ts` answers `{ "links": [...] }` at the + /// top level from both exits (no links detected → `[]`; otherwise the fetched + /// items), and each entry is the same `LinkMetadataItem` the feed row carries — + /// both come out of `fetchMultipleLinkMetadata`, as `/api/link-metadata` does. + /// `links` is decoded as required on purpose: the previous wrapper mismatch + /// survived because a missing key degraded to an empty array in silence. @discardableResult - func refreshMessageMetadata(messageId: String) async throws -> [MessageLinkPreview] { - struct Response: Decodable { - struct Meta: Decodable { let links: [MessageLinkPreview]? } - let metadata: Meta? - } + func refreshMessageMetadata(messageId: String) async throws -> [LinkMetadataItem] { + struct Response: Decodable { let links: [LinkMetadataItem] } let encoded = messageId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? messageId struct Empty: Encodable {} let response: Response = try await postCamel("/api/messages/\(encoded)/metadata", body: Empty()) - return response.metadata?.links ?? [] + return response.links } func editMessage(id: String, content: String, publiclyVisible: Bool?) async throws -> Message { diff --git a/InterlinedList/Views/ComposeView.swift b/InterlinedList/Views/ComposeView.swift index a8bb354..6d51961 100644 --- a/InterlinedList/Views/ComposeView.swift +++ b/InterlinedList/Views/ComposeView.swift @@ -810,9 +810,9 @@ struct ComposeView: View { /// can't change server-side anyway. See issue #76. private func refreshLinkMetadata(for messageId: String) { Task { @MainActor in - guard let previews = try? await APIClient.shared.refreshMessageMetadata(messageId: messageId) + guard let links = try? await APIClient.shared.refreshMessageMetadata(messageId: messageId) else { return } - store.applyLinkMetadata(LinkMetadataItem.from(previews: previews), toMessageId: messageId) + store.applyLinkMetadata(links, toMessageId: messageId) } } } diff --git a/InterlinedListTests/APIClientTests/APIClientGapPhasesTests.swift b/InterlinedListTests/APIClientTests/APIClientGapPhasesTests.swift index 58f6f61..ce60cf2 100644 --- a/InterlinedListTests/APIClientTests/APIClientGapPhasesTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientGapPhasesTests.swift @@ -279,12 +279,4 @@ final class APIClientGapPhasesTests: XCTestCase { XCTAssertEqual(result.crossPostResults.first?.platform, "bluesky") XCTAssertEqual(result.crossPostResults.first?.success, true) } - - func test_refreshMessageMetadata_decodesLinks() async throws { - session.stub(json: #"{"message":"ok","metadata":{"links":[{"url":"https://x.com","title":"X","description":"d","image":"i"}]}}"#) - let links = try await sut.refreshMessageMetadata(messageId: "m1") - XCTAssertEqual(session.lastRequest?.httpMethod, "POST") - XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/messages/m1/metadata") == true) - XCTAssertEqual(links.first?.title, "X") - } } diff --git a/InterlinedListTests/APIClientTests/APIClientMessagesTests.swift b/InterlinedListTests/APIClientTests/APIClientMessagesTests.swift index 7aa59ef..f376470 100644 --- a/InterlinedListTests/APIClientTests/APIClientMessagesTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientMessagesTests.swift @@ -247,4 +247,128 @@ final class APIClientMessagesTests: XCTestCase { XCTAssertTrue(msg.lowercased().contains("own")) } } + + // MARK: refreshMessageMetadata() + + /// Every fixture below is transcribed from the route itself — + /// `app/api/messages/[id]/metadata/route.ts` (`serialize({ links: … })` at :86 + /// and :105) — and from the entry shape its items are built with, + /// `lib/messages/metadata-fetcher.ts` (`fetchMultipleLinkMetadata`, which also + /// feeds `GET /api/link-metadata`). Do not re-derive them from the Swift type: + /// the bug this covers (#104) shipped green because the old fixture was written + /// to the decoder's assumption instead of to the wire. + private let metadataSuccessJSON = #""" + {"links":[{ + "url":"https://example.com/post", + "platform":"other", + "metadata":{ + "thumbnail":"https://example.com/og.png", + "title":"Example Post", + "description":"A description", + "type":"link", + "ogType":"article" + }, + "fetchStatus":"success", + "fetchedAt":"2026-09-16T12:00:00.000Z" + }]} + """# + + func test_refreshMessageMetadata_sendsPostToMetadataPath() async throws { + session.stub(json: metadataSuccessJSON) + _ = try await sut.refreshMessageMetadata(messageId: "m1") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/messages/m1/metadata") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_refreshMessageMetadata_routeShape_decodesNonEmptyLinks() async throws { + session.stub(json: metadataSuccessJSON) + let links = try await sut.refreshMessageMetadata(messageId: "m1") + XCTAssertEqual(links.count, 1) + XCTAssertEqual(links.first?.url, "https://example.com/post") + XCTAssertEqual(links.first?.platform, "other") + XCTAssertEqual(links.first?.fetchStatus, "success") + XCTAssertEqual(links.first?.metadata?.title, "Example Post") + XCTAssertEqual(links.first?.metadata?.description, "A description") + XCTAssertEqual(links.first?.metadata?.thumbnail, "https://example.com/og.png") + XCTAssertEqual(links.first?.metadata?.type, "link") + } + + /// The pre-#104 shape: a `metadata` wrapper the route has never sent. It must + /// now fail loudly rather than decode to an empty array, which is how the + /// mismatch stayed invisible. + func test_refreshMessageMetadata_legacyMetadataWrapper_throwsDecodingError() async throws { + session.stub(json: #"{"message":"ok","metadata":{"links":[{"url":"https://x.com","title":"X","description":"d","image":"i"}]}}"#) + do { + _ = try await sut.refreshMessageMetadata(messageId: "m1") + XCTFail("Expected a decoding failure for the wrapped shape") + } catch is DecodingError { + // expected + } + } + + /// Route :84-89 — content with no detectable link short-circuits to `{links:[]}`. + func test_refreshMessageMetadata_noDetectedLinks_returnsEmpty() async throws { + session.stub(json: #"{"links":[]}"#) + let links = try await sut.refreshMessageMetadata(messageId: "m1") + XCTAssertTrue(links.isEmpty) + } + + /// `fetchMultipleLinkMetadata` emits `{url, platform, fetchStatus:"failed"}` with + /// no `metadata` and no `fetchedAt` when a fetch throws or resolves to nothing — + /// and the route persists exactly that on the message, so the item is kept. + func test_refreshMessageMetadata_failedEntry_keepsUrlWithNilMetadata() async throws { + session.stub(json: #"{"links":[{"url":"https://dead.example","platform":"other","fetchStatus":"failed"}]}"#) + let links = try await sut.refreshMessageMetadata(messageId: "m1") + XCTAssertEqual(links.count, 1) + XCTAssertEqual(links.first?.url, "https://dead.example") + XCTAssertEqual(links.first?.fetchStatus, "failed") + XCTAssertNil(links.first?.metadata) + } + + /// Instagram entries carry `caption`/`derivedList` for the composer's + /// paste-to-expand assist; the link card models neither, and neither may break + /// the decode of the fields it does model. + func test_refreshMessageMetadata_instagramExtras_decodeWithoutError() async throws { + session.stub(json: #""" + {"links":[{ + "url":"https://www.instagram.com/p/abc123/", + "platform":"instagram", + "metadata":{"thumbnail":"https://cdn.example/ig.jpg","title":"On Instagram","description":"caption text","type":"image"}, + "caption":"caption text - one - two", + "derivedList":{"markdown":"- one","ordered":false,"itemCount":2}, + "fetchStatus":"success", + "fetchedAt":"2026-09-16T12:00:00.000Z" + }]} + """#) + let links = try await sut.refreshMessageMetadata(messageId: "m1") + XCTAssertEqual(links.first?.platform, "instagram") + XCTAssertEqual(links.first?.metadata?.type, "image") + XCTAssertEqual(links.first?.metadata?.thumbnail, "https://cdn.example/ig.jpg") + } + + func test_refreshMessageMetadata_multipleLinks_preservesRouteOrder() async throws { + session.stub(json: #""" + {"links":[ + {"url":"https://one.example","platform":"other","metadata":{"title":"One","type":"link"},"fetchStatus":"success"}, + {"url":"https://two.example","platform":"other","metadata":{"title":"Two","type":"link"},"fetchStatus":"success"} + ]} + """#) + let links = try await sut.refreshMessageMetadata(messageId: "m1") + XCTAssertEqual(links.map(\.url), ["https://one.example", "https://two.example"]) + XCTAssertEqual(links.map { $0.metadata?.title }, ["One", "Two"]) + } + + /// Route :65-67 — the POST is owner-only, so an unauthenticated refresh 401s. + /// It stays a bare `.status(401)`: the publish path swallows it rather than + /// treating it as a logout (CLAUDE.md). + func test_refreshMessageMetadata_401_throwsStatus401() async throws { + session.stub(data: Data(), statusCode: 401) + do { + _ = try await sut.refreshMessageMetadata(messageId: "m1") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } } diff --git a/InterlinedListTests/ModelTests/ComposeLinkDetectionTests.swift b/InterlinedListTests/ModelTests/ComposeLinkDetectionTests.swift index 5149e1f..fe9bbae 100644 --- a/InterlinedListTests/ModelTests/ComposeLinkDetectionTests.swift +++ b/InterlinedListTests/ModelTests/ComposeLinkDetectionTests.swift @@ -52,7 +52,7 @@ final class ComposeLinkDetectionTests: XCTestCase { let session = MockURLSession() let sut = APIClient(session: session) sut.setBearerToken("tok") - session.stub(json: #"{"metadata":{"links":[]}}"#) + session.stub(json: #"{"links":[]}"#) await publishAndRefreshIfLinked("just a thought, no links at all", using: sut) @@ -64,7 +64,7 @@ final class ComposeLinkDetectionTests: XCTestCase { let session = MockURLSession() let sut = APIClient(session: session) sut.setBearerToken("tok") - session.stub(json: #"{"metadata":{"links":[{"url":"https://example.com","title":"E","description":null,"image":null}]}}"#) + session.stub(json: #"{"links":[{"url":"https://example.com","platform":"other","metadata":{"title":"E","type":"link"},"fetchStatus":"success"}]}"#) await publishAndRefreshIfLinked("read this https://example.com", using: sut, messageId: "m42") diff --git a/InterlinedListTests/ModelTests/LinkMetadataConversionTests.swift b/InterlinedListTests/ModelTests/LinkMetadataConversionTests.swift deleted file mode 100644 index 144fb85..0000000 --- a/InterlinedListTests/ModelTests/LinkMetadataConversionTests.swift +++ /dev/null @@ -1,102 +0,0 @@ -import XCTest -@testable import InterlinedList - -/// `LinkMetadataItem.from(previews:)` bridges the two link-preview shapes: the flat -/// previews the metadata refresh returns and the nested shape the feed row renders. -/// The feed only draws a card for a non-nil `metadata`, so a preview that resolved -/// to nothing must convert to an item with no `metadata` — not an empty one. -final class LinkMetadataConversionTests: XCTestCase { - - private func preview(url: String = "https://example.com", - title: String? = nil, - description: String? = nil, - image: String? = nil) -> MessageLinkPreview { - MessageLinkPreview(url: url, title: title, description: description, image: image) - } - - // MARK: - Full previews - - func test_from_fullPreview_mapsEveryField() { - let items = LinkMetadataItem.from(previews: [ - preview(url: "https://example.com/a", title: "T", description: "D", - image: "https://cdn.example.com/hero.png") - ]) - - XCTAssertEqual(items.count, 1) - XCTAssertEqual(items.first?.url, "https://example.com/a") - XCTAssertEqual(items.first?.metadata?.title, "T") - XCTAssertEqual(items.first?.metadata?.description, "D") - XCTAssertEqual(items.first?.metadata?.thumbnail, "https://cdn.example.com/hero.png") - } - - /// The flat preview carries neither, so neither is invented. - func test_from_fullPreview_leavesPlatformAndFetchStatusNil() { - let items = LinkMetadataItem.from(previews: [preview(title: "T")]) - XCTAssertNil(items.first?.platform) - XCTAssertNil(items.first?.fetchStatus) - XCTAssertNil(items.first?.metadata?.text) - XCTAssertNil(items.first?.metadata?.type) - } - - func test_from_multiplePreviews_preservesOrder() { - let items = LinkMetadataItem.from(previews: [ - preview(url: "https://one.example", title: "One"), - preview(url: "https://two.example", title: "Two"), - preview(url: "https://three.example", title: "Three") - ]) - XCTAssertEqual(items.map(\.url), - ["https://one.example", "https://two.example", "https://three.example"]) - XCTAssertEqual(items.map { $0.metadata?.title }, ["One", "Two", "Three"]) - } - - func test_from_emptyPreviews_returnsEmpty() { - XCTAssertTrue(LinkMetadataItem.from(previews: []).isEmpty) - } - - // MARK: - Partial previews - - func test_from_previewWithNoImageAndNoTitle_keepsUrlWithNilMetadata() { - let items = LinkMetadataItem.from(previews: [preview(url: "https://bare.example")]) - - XCTAssertEqual(items.count, 1) - XCTAssertEqual(items.first?.url, "https://bare.example") - XCTAssertNil(items.first?.metadata) - } - - func test_from_previewWithOnlyDescription_keepsMetadata() { - let items = LinkMetadataItem.from(previews: [preview(description: "just a blurb")]) - XCTAssertEqual(items.first?.metadata?.description, "just a blurb") - XCTAssertNil(items.first?.metadata?.title) - XCTAssertNil(items.first?.metadata?.thumbnail) - } - - func test_from_previewWithOnlyImage_keepsMetadata() { - let items = LinkMetadataItem.from(previews: [preview(image: "https://cdn.example/x.png")]) - XCTAssertEqual(items.first?.metadata?.thumbnail, "https://cdn.example/x.png") - XCTAssertNil(items.first?.metadata?.title) - } - - /// The feed row already treats an empty title as nothing to draw; an all-blank - /// preview must not sneak past the nil-metadata rule on whitespace alone. - func test_from_previewWithBlankFields_treatsThemAsAbsent() { - let items = LinkMetadataItem.from(previews: [ - preview(title: "", description: " ", image: "\n") - ]) - XCTAssertNil(items.first?.metadata) - } - - func test_from_previewWithPaddedTitle_trimsIt() { - let items = LinkMetadataItem.from(previews: [preview(title: " Padded ")]) - XCTAssertEqual(items.first?.metadata?.title, "Padded") - } - - /// One dead preview must not suppress a live one alongside it. - func test_from_mixedPreviews_convertsEachIndependently() { - let items = LinkMetadataItem.from(previews: [ - preview(url: "https://dead.example"), - preview(url: "https://live.example", title: "Live") - ]) - XCTAssertNil(items.first?.metadata) - XCTAssertEqual(items.last?.metadata?.title, "Live") - } -}