diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index dd51b5b..a3d5714 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -66,7 +66,6 @@ 61621697746E8CF1769E9C80 /* IdentityHealth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D1D19E4DB2D914AD6551D7 /* IdentityHealth.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 */; }; @@ -274,7 +273,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 = ""; }; @@ -842,7 +840,6 @@ 93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */, F78B096C366108083D174366 /* MarkdownBlockTests.swift */, C0FFEE9101ABCDEF00000091 /* ComposeLinkDetectionTests.swift */, - C0FFEE9601ABCDEF00000096 /* LinkMetadataConversionTests.swift */, 05A695D62B746B092CF51AFA /* FeedTruncationTests.swift */, EE6BD1CB3C5C5C494971E0B4 /* DirectMessageModelTests.swift */, A5C9FAA36F8413788E1776C7 /* LinkedInTargetModelTests.swift */, @@ -1188,7 +1185,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 1aac8e0..b6eb4a8 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 66ac522..62f8671 100644 --- a/InterlinedList/Services/APIClient.swift +++ b/InterlinedList/Services/APIClient.swift @@ -420,20 +420,19 @@ final class APIClient { /// Fetch/refresh OpenGraph link-preview metadata for a message's links. /// - /// No caller yet, deliberately. The web fires this after publishing and after an - /// edit (`MessageInput.tsx:511`) so a new post's link previews populate without a - /// reload. Wiring it adds a request to the publish path, so it is tracked - /// separately rather than slipped into a hygiene sweep. + /// `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 729fe19..7232b42 100644 --- a/InterlinedList/Views/ComposeView.swift +++ b/InterlinedList/Views/ComposeView.swift @@ -820,9 +820,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 fa0ba93..93b645e 100644 --- a/InterlinedListTests/APIClientTests/APIClientGapPhasesTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientGapPhasesTests.swift @@ -272,12 +272,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") - } -}