Skip to content

fix(messages): decode the metadata route's real {links} shape - #106

Merged
Adron merged 2 commits into
mainfrom
fix/link-metadata-response-shape
Sep 17, 2026
Merged

Adron merged 2 commits into
mainfrom
fix/link-metadata-response-shape

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Summary

Closes #104. Stacked on #103 → #94: this branch is based on feat/apply-link-metadata-to-posted-row (#103), which is itself based on feat/refresh-link-metadata-after-publish (#94). Both must merge first, in that order; the diff below is only this commit, on top of that stack.

APIClient.refreshMessageMetadata decoded {"metadata":{"links":[…]}} into a flat MessageLinkPreview. app/api/messages/[id]/metadata/route.ts returns serialize({ links: … }) — top-level links, no wrapper — from both exits (:86 when no link is detected, :105 with the fetched items), and serialize() normalizes values without ever restructuring. So response.metadata was always nil and the call returned [] against the live API, silently, which is what made #96/#103 inert.

Each entry is built by fetchMultipleLinkMetadata (lib/messages/metadata-fetcher.ts) — the same producer behind GET /api/link-metadata, which iOS already decodes into LinkMetadataItem, and the same shape persisted onto message.linkMetadata and rendered by the feed row. So the fix is to decode { links: [LinkMetadataItem] } directly.

links is decoded as required, not optional-with-?? []. A missing key now throws instead of degrading to an empty array, because that degradation is precisely how the mismatch survived a year of green tests.

What's included

  • refreshMessageMetadata returns [LinkMetadataItem] and decodes the route's real shape. The path, method, postCamel encoder, empty body and fire-and-forget try? at the call site are unchanged.
  • ComposeView.refreshLinkMetadata(for:) hands the decoded links straight to AppDataStore.applyLinkMetadata(_:toMessageId:) (feat(messages): apply refreshed link metadata to posted row #103), which is unchanged and now receives real entries end to end: a success entry carries metadata and renders; a fetchStatus:"failed" entry has no metadata, and FeedView:883 only draws LinkPreviewBlock for a non-nil metadata, so it renders nothing — mirroring exactly what the route just persisted on the message.
  • Fixtures rewritten from the route/fetcher sources with a comment naming them, covering: a full success entry (platform, metadata.{thumbnail,title,description,type,ogType}, fetchStatus, fetchedAt), the {links:[]} short-circuit, a failed entry ({url,platform,fetchStatus} only), Instagram's unmodelled caption/derivedList extras, order across multiple links, the 401 owner-only path, and the old wrapper shape, which must now throw. They live in APIClientMessagesTests (the messages domain file) rather than a new file, so no project.pbxproj entry is added.
  • The sibling GET on that route (:47-48) returns the same { links } shape from stored metadata — no iOS code calls it, so there is nothing else decoding it wrongly. GET /api/link-metadata ({ link: LinkMetadataItem }) was already correct.

What this removes from #103

LinkMetadataItem.init(preview:) / .from(previews:) and their 10 LinkMetadataConversionTests cases are deleted: with the route's entries already in the feed row's shape, there are no longer two shapes to bridge. MessageLinkPreview had no other consumer in the app or tests, so it is deleted too rather than left as a dead type, and LinkMetadataConversionTests.swift is unslotted from project.pbxproj (hand-edited, no re-sort). The other half of #103 — AppDataStore.applyLinkMetadata, Message.linkMetadata becoming var, the ComposeView wiring and the 6 store tests — is untouched and is what this makes work.

Testing

  • Full suite, simulator 18ABC526-B80A-406E-986C-8F29844B248E, serialized, E2E skipped, private DerivedData: 1169 tests, 0 failures. (1172 on the feat(messages): apply refreshed link metadata to posted row #103 base − 11 deleted (10 conversion + 1 old wrapper-shaped test in APIClientGapPhasesTests) + 8 new.) Clean build-for-testing: no errors, no new warnings.
  • Mutation check that the new tests bite: restoring the old metadata-wrapper decode (with entries retyped to LinkMetadataItem) fails test_refreshMessageMetadata_routeShape_decodesNonEmptyLinks, …_failedEntry_keepsUrlWithNilMetadata, …_instagramExtras_decodeWithoutError, …_multipleLinks_preservesRouteOrder and …_legacyMetadataWrapper_throwsDecodingError — 17 assertion failures across the metadata section. The mutation was reverted and the full suite re-run green.
  • The old fixture is kept deliberately, as test_refreshMessageMetadata_legacyMetadataWrapper_throwsDecodingError: it asserts the pre-Bug: refreshMessageMetadata decodes a response shape the route never sends — always returns [] #104 body no longer decodes at all, rather than quietly yielding [].
  • ComposeLinkDetectionTests' two publish-guard fixtures were route-shaped as well; they assert request issuance, so they passed either way, but leaving an invented shape in a fixture is the defect this issue is about.

Not verified

  • No live-API call was made. The wire shape is read from the backend source (route.ts, metadata-fetcher.ts, serialize.ts, lib/types/index.ts), not from a captured production response.
  • The user-visible outcome — post a message with a link, watch the card fill in without a feed refresh — is still blocked by the second item in feat(messages): apply refreshed link metadata to posted row #103's summary: FeedView mirrors store.feedMessages into local @State and syncs on count, so an in-place linkMetadata change isn't picked up until a full reload. The store and cache are correct; the view's private copy is stale. Not touched here.
  • fetchedAt, ogType, caption and derivedList are sent by the route but deliberately not modelled on LinkMetadataItem (nothing renders them); the tests only prove they don't break the decode.

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bss5MgZa7Jvj2m9zdaUd1
One conflict, in APIClient.swift, resolved by taking this branch's version.
Main carries the "No caller yet, deliberately" note #90 added to
refreshMessageMetadata; that became stale the moment #94 wired the call from
ComposeView, and this branch replaces it with a comment describing the route's
actual response shape. Keeping both would have restated a claim the code now
contradicts.

Verified after resolving: the stale note is gone, the decode reads top-level
`links` as required, and MessageLinkPreview has no remaining references.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bss5MgZa7Jvj2m9zdaUd1
@Adron
Adron merged commit c26ca6b into main Sep 17, 2026
1 check passed
@Adron
Adron deleted the fix/link-metadata-response-shape branch September 17, 2026 09:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: refreshMessageMetadata decodes a response shape the route never sends — always returns []

1 participant