From bd863066696f0b90f370d1f95a883f28518b8bc7 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Thu, 30 Jul 2026 11:18:31 +0200 Subject: [PATCH 01/21] fix(swift): add contentful.swift Entry adapter for OptimizedEntry [NT-3808] Ships OptimizationEntryMapping and a Contentful.Entry-based OptimizedEntry initializer so integrators stop hand-writing the Entry -> {sys, fields, metadata} mapping. Always emits metadata (tags/concepts) so the resolver's entry guard can no longer silently fall back to baseline, and adds ResolvedEntry for typed field reads on the resolved output. Co-Authored-By: Claude Sonnet 5 --- .../ContentfulOptimization/Package.resolved | 14 + .../ios/ContentfulOptimization/Package.swift | 13 +- .../Contentful/OptimizationEntryMapping.swift | 137 ++++ .../Contentful/ResolvedEntry.swift | 30 + .../Views/OptimizedEntry.swift | 30 + .../OptimizationEntryMappingTests.swift | 723 ++++++++++++++++++ .../OptimizedEntryContentfulInitTests.swift | 154 ++++ .../ResolvedEntryTests.swift | 62 ++ 8 files changed, 1161 insertions(+), 2 deletions(-) create mode 100644 packages/ios/ContentfulOptimization/Package.resolved create mode 100644 packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/OptimizationEntryMapping.swift create mode 100644 packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/ResolvedEntry.swift create mode 100644 packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationEntryMappingTests.swift create mode 100644 packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizedEntryContentfulInitTests.swift create mode 100644 packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/ResolvedEntryTests.swift diff --git a/packages/ios/ContentfulOptimization/Package.resolved b/packages/ios/ContentfulOptimization/Package.resolved new file mode 100644 index 000000000..00f47706a --- /dev/null +++ b/packages/ios/ContentfulOptimization/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "contentful.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/contentful/contentful.swift", + "state" : { + "revision" : "687a62d7b11c0772dc99119763c4a9a6365a4c32", + "version" : "5.5.15" + } + } + ], + "version" : 2 +} diff --git a/packages/ios/ContentfulOptimization/Package.swift b/packages/ios/ContentfulOptimization/Package.swift index d95f61651..880054751 100644 --- a/packages/ios/ContentfulOptimization/Package.swift +++ b/packages/ios/ContentfulOptimization/Package.swift @@ -2,7 +2,7 @@ import PackageDescription -let package = Package( +let package: Package = Package( name: "ContentfulOptimization", platforms: [.iOS(.v15), .macOS(.v12)], products: [ @@ -11,9 +11,15 @@ let package = Package( targets: ["ContentfulOptimization"] ), ], + dependencies: [ + .package(url: "https://github.com/contentful/contentful.swift", exact: "5.5.15"), + ], targets: [ .target( name: "ContentfulOptimization", + dependencies: [ + .product(name: "Contentful", package: "contentful.swift"), + ], resources: [ .copy("Resources/optimization-ios-bridge.umd.js"), ], @@ -23,7 +29,10 @@ let package = Package( ), .testTarget( name: "ContentfulOptimizationTests", - dependencies: ["ContentfulOptimization"] + dependencies: [ + "ContentfulOptimization", + .product(name: "Contentful", package: "contentful.swift"), + ] ), ] ) diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/OptimizationEntryMapping.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/OptimizationEntryMapping.swift new file mode 100644 index 000000000..5ab4eeafd --- /dev/null +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/OptimizationEntryMapping.swift @@ -0,0 +1,137 @@ +import Contentful +import Foundation + +/// Maps a `contentful.swift` `Entry` into the `{sys, fields, metadata}` map +/// `OptimizedEntry` expects, reconstructing the resolved-link JSON shape the raw CDA response +/// carried before the Delivery SDK decoded it. +/// +/// Ported from the reference implementation's in-app simulation of this exact gap: +/// `examples/apps/travel-guide-ios/Sources/OptimizationAdapter.swift` (`Entry.optimizationMap`). +enum OptimizationEntryMapping { + static func toOptimizationEntry(_ entry: Contentful.Entry) -> [String: Any] { + entryMap(entry, ancestors: []) + } + + /// `ancestors` is the set of entry ids on the path from the root to here. The Delivery SDK + /// resolves links into shared object references, so a variant that links back to its + /// baseline is a real cycle in the object graph; recursing an entry already on the current + /// path would loop forever. Re-linking an ancestor emits an unresolved link stub instead — + /// the shape a back-edge has in a raw CDA response. Scoping to the current path (not a + /// global visited set) still expands diamonds: an entry reached by two sibling branches + /// expands fully in both. + private static func entryMap(_ entry: Contentful.Entry, ancestors: Set) -> [String: Any] { + let childAncestors = ancestors.union([entry.id]) + + return [ + "sys": [ + "id": entry.id, + "type": "Entry", + "contentType": [ + "sys": ["id": entry.sys.contentTypeId ?? "", "type": "Link", "linkType": "ContentType"], + ], + ], + "fields": entry.fields.compactMapValues { jsonValue($0, ancestors: childAncestors) }, + // Required, not cosmetic: the resolver's entry guard rejects any entry without a + // `metadata` object, and a rejected baseline is never given its variant. A raw CDA + // response carries it on every entry; `Entry` keeps it out of `fields`, so the + // mapper has to put it back. `concepts` is always empty — `contentful.swift`'s + // `Metadata` models only `tags`, so the SDK gives us nothing else to forward. + "metadata": [ + "tags": (entry.metadata?.tags ?? []).map { jsonLink($0, ancestors: childAncestors) }, + "concepts": [], + ], + ] + } + + /// One field value, reduced to something `JSONSerialization` accepts — the resolver + /// serializes the whole map before handing it to its JS bridge, and one illegal value fails + /// the entry outright (it falls back to baseline, logging rather than throwing). Anything + /// not listed here is dropped rather than risking that: losing an unused field beats losing + /// personalization on the entry that holds it. + private static func jsonValue(_ value: Any, ancestors: Set) -> Any? { + switch value { + case let link as Contentful.Link: + return jsonLink(link, ancestors: ancestors) + case let richText as Contentful.RichTextDocument: + return jsonNode(richText, ancestors: ancestors) + case let array as [Any]: + return array.compactMap { jsonValue($0, ancestors: ancestors) } + case let dictionary as [String: Any]: + return dictionary.compactMapValues { jsonValue($0, ancestors: ancestors) } + case let location as Contentful.Location: + return ["lat": location.latitude, "lon": location.longitude] + case let date as Date: + return ISO8601DateFormatter().string(from: date) + case is String, is Int, is Double, is Bool: + return value + default: + return nil + } + } + + /// One Structured Text node, reduced to the same `{nodeType, data, content}` shape a raw CDA + /// response carries. `ResourceLinkBlock`/`ResourceLinkInline` (embedded entries and assets — + /// both `-block` and `-inline` variants share these two Swift types across all five + /// `embedded-*`/`*-hyperlink` node types) must be matched before the generic `RecursiveNode` + /// case, since both conform to it; falling through to the generic case would silently drop + /// the embedded resource's resolved-or-unresolved link entirely; ordering matters here. + private static func jsonNode(_ node: Contentful.Node, ancestors: Set) -> [String: Any] { + switch node { + case let resourceLink as Contentful.ResourceLinkBlock: + return [ + "nodeType": resourceLink.nodeType.rawValue, + "data": ["target": jsonLink(resourceLink.data.target, ancestors: ancestors)], + "content": resourceLink.content.map { jsonNode($0, ancestors: ancestors) }, + ] + case let resourceLink as Contentful.ResourceLinkInline: + return [ + "nodeType": resourceLink.nodeType.rawValue, + "data": ["target": jsonLink(resourceLink.data.target, ancestors: ancestors)], + "content": resourceLink.content.map { jsonNode($0, ancestors: ancestors) }, + ] + case let hyperlink as Contentful.Hyperlink: + return [ + "nodeType": hyperlink.nodeType.rawValue, + "data": ["uri": hyperlink.data.uri], + "content": hyperlink.content.map { jsonNode($0, ancestors: ancestors) }, + ] + case let text as Contentful.Text: + return [ + "nodeType": text.nodeType.rawValue, + "value": text.value, + "marks": text.marks.map { ["type": $0.type.rawValue] }, + "data": [String: Any](), + ] + // Table/TableRow/TableRowHeaderCell/TableRowCell/Paragraph/Heading/BlockQuote/ + // HorizontalRule/OrderedList/UnorderedList/ListItem, and the top-level + // RichTextDocument itself — all plain containers with no data beyond their children. + case let recursive as Contentful.RecursiveNode: + return [ + "nodeType": recursive.nodeType.rawValue, + "data": [String: Any](), + "content": recursive.content.map { jsonNode($0, ancestors: ancestors) }, + ] + default: + return ["nodeType": node.nodeType.rawValue, "data": [String: Any](), "content": [Any]()] + } + } + + /// A link field, expanded into the linked resource when the Delivery SDK resolved it. + private static func jsonLink(_ link: Contentful.Link, ancestors: Set) -> [String: Any] { + switch link { + case let .entry(entry) where !ancestors.contains(entry.id): + return entryMap(entry, ancestors: ancestors) + case let .asset(asset): + return [ + "sys": ["id": asset.id, "type": "Asset"], + "fields": ["title": asset.title ?? "", "file": ["url": asset.urlString ?? ""]], + ] + case let .unresolved(sys): + return ["sys": ["id": sys.id, "type": sys.type, "linkType": sys.linkType]] + // A back-edge, or a typed `EntryDecodable` this mapper never registers: emit the stub an + // unresolved link has in a raw CDA response. + case .entry, .entryDecodable: + return ["sys": ["id": link.id, "type": "Link", "linkType": "Entry"]] + } + } +} diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/ResolvedEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/ResolvedEntry.swift new file mode 100644 index 000000000..8531d00c1 --- /dev/null +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/ResolvedEntry.swift @@ -0,0 +1,30 @@ +import Foundation + +/// The resolver's output read through the surface a fetched `Contentful.Entry` already has — +/// `getField` mirrors `ContentfulClient.getField`. A resolved variant reads like a fetched entry +/// instead of a raw `{sys, fields}` map to dig through by hand with `as?` casts. +/// +/// Ported from the reference implementation's showcase of this gap: +/// `examples/apps/travel-guide-ios/Sources/OptimizationAdapter.swift` (`ResolvedEntry`). +/// +/// Only the resolver side needs this — a fetched `Contentful.Entry` is already read this way. The +/// two can't share a type: an `Entry` can't be rebuilt from the resolver's map, since its +/// initializer needs a localization context only a live decode carries. They share the *shape*, +/// not the type, so app code reads both the same way without one impersonating the other. +public struct ResolvedEntry { + private let raw: [String: Any] + + public init(_ raw: [String: Any]) { + self.raw = raw + } + + /// The entry `sys.id` — stable across a variant swap, so it's safe for navigation. + public var id: String? { + (raw["sys"] as? [String: Any])?["id"] as? String + } + + /// A field's resolved value, or nil if absent. + public func getField(_ name: String) -> T? { + (raw["fields"] as? [String: Any])?[name] as? T + } +} diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift index 5a5ed60a7..743c2108f 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift @@ -1,4 +1,5 @@ import Combine +import Contentful import SwiftUI /// Unified component for tracking and optimizing Contentful entries. @@ -58,6 +59,35 @@ public struct OptimizedEntry: View { self.content = content } + /// Accepts a `contentful.swift` `Entry` directly, mapping it to the `{sys, fields, metadata}` + /// shape the resolver expects (see `OptimizationEntryMapping`) and handing the resolved + /// variant back through `ResolvedEntry` — `getField`, not `as?` casts on a raw map. The + /// wrapping happens once, here, at construction — `content` itself stays dict-shaped + /// internally so `body` doesn't need to know which initializer built this instance. + public init( + entry: Contentful.Entry, + dwellTimeMs: Int = 2000, + minVisibleRatio: Double = 0.8, + viewDurationUpdateIntervalMs: Int = 5000, + liveUpdates: Bool? = nil, + trackViews: Bool? = nil, + trackTaps: Bool? = nil, + accessibilityIdentifier: String? = nil, + onTap: (([String: Any]) -> Void)? = nil, + @ViewBuilder content: @escaping (ResolvedEntry) -> Content + ) { + self.entry = OptimizationEntryMapping.toOptimizationEntry(entry) + self.dwellTimeMs = dwellTimeMs + self.minVisibleRatio = minVisibleRatio + self.viewDurationUpdateIntervalMs = viewDurationUpdateIntervalMs + self.liveUpdates = liveUpdates + self.trackViews = trackViews + self.trackTaps = trackTaps + self.accessibilityIdentifier = accessibilityIdentifier + self.onTap = onTap + self.content = { raw in content(ResolvedEntry(raw)) } + } + private var isOptimized: Bool { guard let fields = entry["fields"] as? [String: Any] else { return false } return fields["nt_experiences"] != nil diff --git a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationEntryMappingTests.swift b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationEntryMappingTests.swift new file mode 100644 index 000000000..ca87486cc --- /dev/null +++ b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationEntryMappingTests.swift @@ -0,0 +1,723 @@ +@testable import Contentful +@testable import ContentfulOptimization +import Foundation +import XCTest + +/// Verifies `OptimizationEntryMapping.toOptimizationEntry` against real `contentful.swift` decodes +/// — not fabricated dicts — so the mapping is checked against actual SDK object shapes rather +/// than assumptions about them. Mirrors the scenarios `OptimizationAdapter.swift` +/// (`examples/apps/travel-guide-ios`) exists to cover: link resolution, the metadata requirement, +/// asset mapping, and the ancestor-cycle guard. +final class OptimizationEntryMappingTests: XCTestCase { + private static let localizationContext: LocalizationContext = { + let localeJSON = Data(""" + {"code":"en-US","default":true,"name":"English","fallbackCode":null} + """.utf8) + let locale = try! JSONDecoder.withoutLocalizationContext().decode(Contentful.Locale.self, from: localeJSON) + return LocalizationContext(locales: [locale])! + }() + + private func decodeEntry(_ json: String) throws -> Entry { + let decoder = JSONDecoder.withoutLocalizationContext() + decoder.update(with: Self.localizationContext) + decoder.userInfo[.init(rawValue: "linkResolverContext")!] = NSObject() + return try decoder.decode(Entry.self, from: Data(json.utf8)) + } + + // MARK: - Baseline shape + + func testMapsSysAndContentType() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "landingPage", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"title": "Hello"} + } + """) + + let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) + let sys = mapped["sys"] as? [String: Any] + XCTAssertEqual(sys?["id"] as? String, "e1") + XCTAssertEqual(sys?["type"] as? String, "Entry") + let contentType = (sys?["contentType"] as? [String: Any])?["sys"] as? [String: Any] + XCTAssertEqual(contentType?["id"] as? String, "landingPage") + + let fields = mapped["fields"] as? [String: Any] + XCTAssertEqual(fields?["title"] as? String, "Hello") + } + + // MARK: - The silent metadata requirement + + /// The resolver's entry guard (`isResolvedContentfulEntry` in + /// `packages/universal/api-schemas/src/contentful/typeGuards.ts`) rejects any entry without a + /// `metadata` object — silently, no error, the entry is just treated as non-optimized. `Entry` + /// keeps `metadata` off `fields` (it's a sys-level sibling), so an entry with zero tags still + /// needs an explicit empty `metadata.tags`/`concepts`, not an absent key. + func testAlwaysIncludesMetadataEvenWithNoTags() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "landingPage", "type": "Link", "linkType": "ContentType"}}}, + "fields": {} + } + """) + + let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) + let metadata = mapped["metadata"] as? [String: Any] + XCTAssertNotNil(metadata, "metadata must always be present or the resolver silently treats the entry as non-optimized") + XCTAssertEqual((metadata?["tags"] as? [Any])?.count, 0) + XCTAssertEqual((metadata?["concepts"] as? [Any])?.count, 0) + } + + func testMapsMetadataTags() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "landingPage", "type": "Link", "linkType": "ContentType"}}}, + "fields": {}, + "metadata": {"tags": [{"sys": {"id": "tag1", "linkType": "Tag", "type": "Link"}}]} + } + """) + + let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) + let tags = (mapped["metadata"] as? [String: Any])?["tags"] as? [[String: Any]] + XCTAssertEqual(tags?.count, 1) + let tagSys = tags?.first?["sys"] as? [String: Any] + XCTAssertEqual(tagSys?["id"] as? String, "tag1") + } + + // MARK: - Link resolution + + func testUnresolvedLinkEmitsStub() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "landingPage", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"related": {"sys": {"id": "e2", "type": "Link", "linkType": "Entry"}}} + } + """) + + let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) + let fields = mapped["fields"] as? [String: Any] + let related = fields?["related"] as? [String: Any] + let sys = related?["sys"] as? [String: Any] + XCTAssertEqual(sys?["id"] as? String, "e2") + XCTAssertEqual(sys?["type"] as? String, "Link") + XCTAssertEqual(sys?["linkType"] as? String, "Entry") + // Unresolved: no "fields" key from a nested entryMap expansion. + XCTAssertNil(related?["fields"]) + } + + func testResolvedEntryLinkExpandsInline() throws { + let parent = try decodeEntry(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"child": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}} + } + """) + let child = try decodeEntry(""" + { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "child entry"} + } + """) + + let entriesMap = ["parent": parent, "child-1": child] + parent.resolveLinks(against: entriesMap, and: [:]) + + let mapped = OptimizationEntryMapping.toOptimizationEntry(parent) + let childField = (mapped["fields"] as? [String: Any])?["child"] as? [String: Any] + XCTAssertEqual((childField?["sys"] as? [String: Any])?["id"] as? String, "child-1") + XCTAssertEqual((childField?["fields"] as? [String: Any])?["name"] as? String, "child entry") + // A resolved entry link's metadata must also be present, for the same reason as the root. + XCTAssertNotNil(childField?["metadata"]) + } + + /// The Delivery SDK resolves links into shared object references, so a variant linking back + /// to its baseline is a real cycle in the object graph, not just a data shape to defend + /// against defensively. Recursing an already-visited entry would loop forever; the mapper + /// must emit an unresolved-link stub for the back-edge instead. + func testSelfReferencingLinkDoesNotRecurseInfinitely() throws { + let parent = try decodeEntry(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"child": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}} + } + """) + let child = try decodeEntry(""" + { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"backToParent": {"sys": {"id": "parent", "type": "Link", "linkType": "Entry"}}} + } + """) + + let entriesMap = ["parent": parent, "child-1": child] + parent.resolveLinks(against: entriesMap, and: [:]) + child.resolveLinks(against: entriesMap, and: [:]) + + // Must terminate — the assertions below are only reachable if it does. + let mapped = OptimizationEntryMapping.toOptimizationEntry(parent) + + let childField = (mapped["fields"] as? [String: Any])?["child"] as? [String: Any] + let backLink = (childField?["fields"] as? [String: Any])?["backToParent"] as? [String: Any] + let backSys = backLink?["sys"] as? [String: Any] + XCTAssertEqual(backSys?["id"] as? String, "parent") + XCTAssertEqual(backSys?["type"] as? String, "Link", "a back-edge to an ancestor must be an unresolved-link stub, not a full expansion") + XCTAssertNil(backLink?["fields"], "the back-edge must not have been expanded into a full entry map") + } + + /// `NestedContentEntryView.swift` (`examples/apps/travel-guide-ios`, and the ios-sdk + /// implementation's `NestedContentEntryView`) recurses `OptimizedEntry` through a "nested" + /// array field, multiple levels deep — not just one level, and not just a single linear + /// chain. `testResolvedEntryLinkExpandsInline` above only covers one level; this covers a + /// three-level chain (grandparent -> parent -> child) plus a diamond (two siblings at the + /// middle level both linking to the same leaf), matching the shape the reference app's + /// recursive view actually walks. + func testMultiLevelNestedEntriesExpandAtEveryLevel() throws { + let grandparent = try decodeEntry(""" + { + "sys": {"id": "grandparent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"nested": [ + {"sys": {"id": "sibling-a", "type": "Link", "linkType": "Entry"}}, + {"sys": {"id": "sibling-b", "type": "Link", "linkType": "Entry"}} + ]} + } + """) + let siblingA = try decodeEntry(""" + { + "sys": {"id": "sibling-a", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "sibling A", "nested": [{"sys": {"id": "leaf", "type": "Link", "linkType": "Entry"}}]} + } + """) + let siblingB = try decodeEntry(""" + { + "sys": {"id": "sibling-b", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "sibling B", "nested": [{"sys": {"id": "leaf", "type": "Link", "linkType": "Entry"}}]} + } + """) + let leaf = try decodeEntry(""" + { + "sys": {"id": "leaf", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "leaf entry"} + } + """) + + let entriesMap = [ + "grandparent": grandparent, "sibling-a": siblingA, "sibling-b": siblingB, "leaf": leaf, + ] + for entry in [grandparent, siblingA, siblingB, leaf] { + entry.resolveLinks(against: entriesMap, and: [:]) + } + + let mapped = OptimizationEntryMapping.toOptimizationEntry(grandparent) + let nested = (mapped["fields"] as? [String: Any])?["nested"] as? [[String: Any]] + XCTAssertEqual(nested?.count, 2) + + for (index, expectedId, expectedName) in [(0, "sibling-a", "sibling A"), (1, "sibling-b", "sibling B")] { + let sibling = nested?[index] + XCTAssertEqual((sibling?["sys"] as? [String: Any])?["id"] as? String, expectedId) + let siblingFields = sibling?["fields"] as? [String: Any] + XCTAssertEqual(siblingFields?["name"] as? String, expectedName) + + // The diamond: both siblings link to the same leaf. Scoping the ancestor guard to + // the current path (not a global visited set) must let the leaf expand fully under + // both, rather than treating the second sibling's reach to it as a cycle. + let siblingLeaf = (siblingFields?["nested"] as? [[String: Any]])?.first + XCTAssertEqual((siblingLeaf?["sys"] as? [String: Any])?["id"] as? String, "leaf") + let leafFields = siblingLeaf?["fields"] as? [String: Any] + XCTAssertEqual(leafFields?["name"] as? String, "leaf entry", "the shared leaf must fully expand under both siblings, not just the first") + } + } + + // MARK: - Asset mapping + + func testResolvedAssetLinkMapsTitleAndURL() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"image": {"sys": {"id": "asset-1", "type": "Link", "linkType": "Asset"}}} + } + """) + let assetDecoder = JSONDecoder.withoutLocalizationContext() + assetDecoder.update(with: Self.localizationContext) + assetDecoder.userInfo[.init(rawValue: "linkResolverContext")!] = NSObject() + let asset = try assetDecoder.decode(Asset.self, from: Data(""" + { + "sys": {"id": "asset-1", "type": "Asset", "locale": "en-US"}, + "fields": {"title": "A photo", "file": {"fileName": "a.jpg", "contentType": "image/jpeg", + "details": {"size": 10}, "url": "//images.ctfassets.net/a.jpg"}} + } + """.utf8)) + + entry.resolveLinks(against: [:], and: ["asset-1": asset]) + + let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) + let image = (mapped["fields"] as? [String: Any])?["image"] as? [String: Any] + XCTAssertEqual((image?["sys"] as? [String: Any])?["id"] as? String, "asset-1") + let imageFields = image?["fields"] as? [String: Any] + XCTAssertEqual(imageFields?["title"] as? String, "A photo") + let file = imageFields?["file"] as? [String: Any] + XCTAssertEqual(file?["url"] as? String, "https://images.ctfassets.net/a.jpg") + } + + // MARK: - Location + + func testLocationFieldMapsToLatLon() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"place": {"lat": 51.5, "lon": -0.12}} + } + """) + + let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) + let place = (mapped["fields"] as? [String: Any])?["place"] as? [String: Any] + XCTAssertEqual(place?["lat"] as? Double, 51.5) + XCTAssertEqual(place?["lon"] as? Double, -0.12) + } + + // MARK: - Rich text + + /// Plain Structured Text nodes (paragraph, text-with-marks, hyperlink) must round-trip + /// through the mapper, not just links/assets. If `RichTextDocument` had no case in + /// `jsonValue`, the entire field would silently vanish — this is the regression that case + /// closes. + func testRichTextPlainNodesMapToNodeTree() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "paragraph", "data": {}, "content": [ + {"nodeType": "text", "value": "Hello ", "marks": [], "data": {}}, + {"nodeType": "text", "value": "world", "marks": [{"type": "bold"}], "data": {}} + ]}, + {"nodeType": "hyperlink", "data": {"uri": "https://example.com"}, "content": [ + {"nodeType": "text", "value": "click", "marks": [], "data": {}} + ]} + ] + }} + } + """) + + let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) + let body = (mapped["fields"] as? [String: Any])?["body"] as? [String: Any] + XCTAssertEqual(body?["nodeType"] as? String, "document") + + let content = body?["content"] as? [[String: Any]] + XCTAssertEqual(content?.count, 2) + + let paragraph = content?[0] + XCTAssertEqual(paragraph?["nodeType"] as? String, "paragraph") + let paragraphContent = paragraph?["content"] as? [[String: Any]] + XCTAssertEqual(paragraphContent?[0]["value"] as? String, "Hello ") + XCTAssertEqual(paragraphContent?[1]["value"] as? String, "world") + let marks = paragraphContent?[1]["marks"] as? [[String: Any]] + XCTAssertEqual(marks?.first?["type"] as? String, "bold") + + let hyperlink = content?[1] + XCTAssertEqual(hyperlink?["nodeType"] as? String, "hyperlink") + XCTAssertEqual((hyperlink?["data"] as? [String: Any])?["uri"] as? String, "https://example.com") + let hyperlinkContent = hyperlink?["content"] as? [[String: Any]] + XCTAssertEqual(hyperlinkContent?.first?["value"] as? String, "click") + } + + /// The one case this whole addition exists for: an embedded entry inside rich text that the + /// Delivery SDK *did* resolve must expand inline — same as a top-level resolved link — not + /// disappear. + func testResolvedEmbeddedEntryBlockExpandsInline() throws { + let parent = try decodeEntry(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "embedded-entry-block", + "data": {"target": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}}, + "content": []} + ] + }} + } + """) + let child = try decodeEntry(""" + { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "embedded child"} + } + """) + + parent.resolveLinks(against: ["parent": parent, "child-1": child], and: [:]) + + let mapped = OptimizationEntryMapping.toOptimizationEntry(parent) + let body = (mapped["fields"] as? [String: Any])?["body"] as? [String: Any] + let embeddedBlock = (body?["content"] as? [[String: Any]])?.first + XCTAssertEqual(embeddedBlock?["nodeType"] as? String, "embedded-entry-block") + + let target = (embeddedBlock?["data"] as? [String: Any])?["target"] as? [String: Any] + XCTAssertEqual((target?["sys"] as? [String: Any])?["id"] as? String, "child-1") + let targetFields = target?["fields"] as? [String: Any] + XCTAssertEqual(targetFields?["name"] as? String, "embedded child", "a resolved embedded entry must expand inline, matching a top-level resolved link") + XCTAssertNotNil(target?["metadata"], "an expanded embedded entry must carry metadata, same as any other expanded entry") + } + + /// The other case that must not be dropped: an embedded entry the Delivery SDK could *not* + /// resolve (e.g. unpublished, or outside the query's `include` depth) must still surface as + /// an unresolved-link stub — not vanish, and not be confused with the resolved case above. + func testUnresolvedEmbeddedEntryBlockEmitsStubNotOmission() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "embedded-entry-block", + "data": {"target": {"sys": {"id": "missing-1", "type": "Link", "linkType": "Entry"}}}, + "content": []} + ] + }} + } + """) + // Deliberately not calling resolveLinks — no candidate entries were ever supplied, the + // shape a query with insufficient `include` depth or an unpublished target produces. + + let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) + let body = (mapped["fields"] as? [String: Any])?["body"] as? [String: Any] + let embeddedBlock = (body?["content"] as? [[String: Any]])?.first + XCTAssertNotNil(embeddedBlock, "an unresolved embedded entry must still appear as a node — not be silently dropped from content") + XCTAssertEqual(embeddedBlock?["nodeType"] as? String, "embedded-entry-block") + + let target = (embeddedBlock?["data"] as? [String: Any])?["target"] as? [String: Any] + XCTAssertEqual((target?["sys"] as? [String: Any])?["id"] as? String, "missing-1") + XCTAssertEqual((target?["sys"] as? [String: Any])?["linkType"] as? String, "Entry") + XCTAssertNil(target?["fields"], "an unresolved target must be a link stub, not an expanded entry") + } + + /// Same resolved/unresolved distinction, but for an embedded *asset* rather than an entry — + /// a separate code path (`.asset` vs `.entry`/`.unresolved` in `jsonLink`) that must not be + /// conflated with the entry case above. + func testResolvedEmbeddedAssetBlockExpandsWithTitleAndURL() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "embedded-asset-block", + "data": {"target": {"sys": {"id": "asset-1", "type": "Link", "linkType": "Asset"}}}, + "content": []} + ] + }} + } + """) + let assetDecoder = JSONDecoder.withoutLocalizationContext() + assetDecoder.update(with: Self.localizationContext) + assetDecoder.userInfo[.init(rawValue: "linkResolverContext")!] = NSObject() + let asset = try assetDecoder.decode(Asset.self, from: Data(""" + { + "sys": {"id": "asset-1", "type": "Asset", "locale": "en-US"}, + "fields": {"title": "An image", "file": {"fileName": "b.png", "contentType": "image/png", + "details": {"size": 20}, "url": "//images.ctfassets.net/b.png"}} + } + """.utf8)) + + entry.resolveLinks(against: [:], and: ["asset-1": asset]) + + let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) + let body = (mapped["fields"] as? [String: Any])?["body"] as? [String: Any] + let embeddedBlock = (body?["content"] as? [[String: Any]])?.first + let target = (embeddedBlock?["data"] as? [String: Any])?["target"] as? [String: Any] + XCTAssertEqual((target?["sys"] as? [String: Any])?["id"] as? String, "asset-1") + let targetFields = target?["fields"] as? [String: Any] + XCTAssertEqual(targetFields?["title"] as? String, "An image") + let file = targetFields?["file"] as? [String: Any] + XCTAssertEqual(file?["url"] as? String, "https://images.ctfassets.net/b.png") + } + + /// Embedded-entry-*inline* (a different Swift type, `ResourceLinkInline`, from the block + /// variant tested above) must also expand a resolved target, proving the inline node-type + /// branch isn't just a copy-paste of the block branch that happens to compile. + func testResolvedEmbeddedEntryInlineExpandsInline() throws { + let parent = try decodeEntry(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "paragraph", "data": {}, "content": [ + {"nodeType": "embedded-entry-inline", + "data": {"target": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}}, + "content": []} + ]} + ] + }} + } + """) + let child = try decodeEntry(""" + { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "inline child"} + } + """) + + parent.resolveLinks(against: ["parent": parent, "child-1": child], and: [:]) + + let mapped = OptimizationEntryMapping.toOptimizationEntry(parent) + let body = (mapped["fields"] as? [String: Any])?["body"] as? [String: Any] + let paragraph = (body?["content"] as? [[String: Any]])?.first + let inlineNode = (paragraph?["content"] as? [[String: Any]])?.first + XCTAssertEqual(inlineNode?["nodeType"] as? String, "embedded-entry-inline") + let target = (inlineNode?["data"] as? [String: Any])?["target"] as? [String: Any] + XCTAssertEqual((target?["fields"] as? [String: Any])?["name"] as? String, "inline child") + } + + /// `entry-hyperlink` and `asset-hyperlink` decode to the same `ResourceLinkInline` Swift type + /// as `embedded-entry-inline` (confirmed against a real decode — `NodeType.type` maps all + /// three to `ResourceLinkInline.self`), so `jsonNode`'s type-based switch already covers them + /// without a dedicated case. This test proves that's actually true for `entry-hyperlink` + /// specifically, not just architecturally plausible — a hyperlink-to-an-entry is a distinct + /// authoring action from an embedded block, and CDA gives it a different `nodeType` string. + func testEntryHyperlinkExpandsResolvedTargetInline() throws { + let parent = try decodeEntry(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "paragraph", "data": {}, "content": [ + {"nodeType": "entry-hyperlink", + "data": {"target": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}}, + "content": [{"nodeType": "text", "value": "link text", "marks": [], "data": {}}]} + ]} + ] + }} + } + """) + let child = try decodeEntry(""" + { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "linked child"} + } + """) + + parent.resolveLinks(against: ["parent": parent, "child-1": child], and: [:]) + + let mapped = OptimizationEntryMapping.toOptimizationEntry(parent) + let body = (mapped["fields"] as? [String: Any])?["body"] as? [String: Any] + let paragraph = (body?["content"] as? [[String: Any]])?.first + let hyperlinkNode = (paragraph?["content"] as? [[String: Any]])?.first + XCTAssertEqual(hyperlinkNode?["nodeType"] as? String, "entry-hyperlink") + let target = (hyperlinkNode?["data"] as? [String: Any])?["target"] as? [String: Any] + XCTAssertEqual((target?["fields"] as? [String: Any])?["name"] as? String, "linked child") + let hyperlinkContent = hyperlinkNode?["content"] as? [[String: Any]] + XCTAssertEqual(hyperlinkContent?.first?["value"] as? String, "link text") + } + + /// `asset-hyperlink` — same `ResourceLinkInline` type, distinct `nodeType`, unresolved this + /// time (mirrors the unresolved-embedded-entry test's point: neither hyperlink variant should + /// be assumed resolved). + func testAssetHyperlinkEmitsUnresolvedStubWhenNotResolved() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "paragraph", "data": {}, "content": [ + {"nodeType": "asset-hyperlink", + "data": {"target": {"sys": {"id": "asset-1", "type": "Link", "linkType": "Asset"}}}, + "content": [{"nodeType": "text", "value": "asset link", "marks": [], "data": {}}]} + ]} + ] + }} + } + """) + // Not calling resolveLinks — no asset candidates supplied. + + let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) + let body = (mapped["fields"] as? [String: Any])?["body"] as? [String: Any] + let paragraph = (body?["content"] as? [[String: Any]])?.first + let hyperlinkNode = (paragraph?["content"] as? [[String: Any]])?.first + XCTAssertEqual(hyperlinkNode?["nodeType"] as? String, "asset-hyperlink") + let target = (hyperlinkNode?["data"] as? [String: Any])?["target"] as? [String: Any] + XCTAssertEqual((target?["sys"] as? [String: Any])?["id"] as? String, "asset-1") + XCTAssertNil(target?["fields"], "an unresolved asset-hyperlink target must be a stub, not an expanded asset") + } + + /// Confirmed via a real decode (scratch probe, since removed) that a rich text field + /// embedding an entry which itself has a rich text field is a real, reachable shape — not + /// hypothetical. This proves the mapper's field-recursion and node-recursion compose across + /// that boundary: an embedded entry's own rich text field must expand, not just its plain + /// fields (already covered by `testResolvedEmbeddedEntryBlockExpandsInline`). + func testRichTextInsideEmbeddedEntryFieldsAlsoExpands() throws { + let parent = try decodeEntry(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "embedded-entry-block", + "data": {"target": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}}, + "content": []} + ] + }} + } + """) + let child = try decodeEntry(""" + { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"nestedBody": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "paragraph", "data": {}, "content": [ + {"nodeType": "text", "value": "nested rich text", "marks": [], "data": {}} + ]} + ] + }} + } + """) + + parent.resolveLinks(against: ["parent": parent, "child-1": child], and: [:]) + + let mapped = OptimizationEntryMapping.toOptimizationEntry(parent) + let body = (mapped["fields"] as? [String: Any])?["body"] as? [String: Any] + let embeddedBlock = (body?["content"] as? [[String: Any]])?.first + let target = (embeddedBlock?["data"] as? [String: Any])?["target"] as? [String: Any] + let targetFields = target?["fields"] as? [String: Any] + + let nestedBody = targetFields?["nestedBody"] as? [String: Any] + XCTAssertEqual(nestedBody?["nodeType"] as? String, "document", "an embedded entry's own rich text field must also expand, not just its plain fields") + let nestedParagraph = (nestedBody?["content"] as? [[String: Any]])?.first + let nestedText = (nestedParagraph?["content"] as? [[String: Any]])?.first + XCTAssertEqual(nestedText?["value"] as? String, "nested rich text") + } + + /// Confirmed via a real decode (scratch probe, since removed) that this is a genuine object + /// graph cycle, not a hypothetical one: after `resolveLinks`, the child's back-reference to + /// the parent inside rich text resolves to `.entry(parent)`, an actual `Entry` reference — + /// recursing it without the ancestor guard would loop forever. This is the rich-text + /// counterpart to `testSelfReferencingLinkDoesNotRecurseInfinitely` (which only covers a + /// plain top-level field link), proving the same guard also holds across the + /// field-recursion/node-recursion boundary rich text introduces. + func testRichTextEmbeddedEntryCycleDoesNotRecurseInfinitely() throws { + let parent = try decodeEntry(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "embedded-entry-block", + "data": {"target": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}}, + "content": []} + ] + }} + } + """) + let child = try decodeEntry(""" + { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "embedded-entry-block", + "data": {"target": {"sys": {"id": "parent", "type": "Link", "linkType": "Entry"}}}, + "content": []} + ] + }} + } + """) + + let entriesMap = ["parent": parent, "child-1": child] + parent.resolveLinks(against: entriesMap, and: [:]) + child.resolveLinks(against: entriesMap, and: [:]) + + // Must terminate — the assertions below are only reachable if it does. + let mapped = OptimizationEntryMapping.toOptimizationEntry(parent) + + let parentBody = (mapped["fields"] as? [String: Any])?["body"] as? [String: Any] + let embeddedChildBlock = (parentBody?["content"] as? [[String: Any]])?.first + let childTarget = (embeddedChildBlock?["data"] as? [String: Any])?["target"] as? [String: Any] + XCTAssertEqual((childTarget?["sys"] as? [String: Any])?["id"] as? String, "child-1") + + let childBody = (childTarget?["fields"] as? [String: Any])?["body"] as? [String: Any] + let embeddedBackBlock = (childBody?["content"] as? [[String: Any]])?.first + let backTarget = (embeddedBackBlock?["data"] as? [String: Any])?["target"] as? [String: Any] + XCTAssertEqual((backTarget?["sys"] as? [String: Any])?["id"] as? String, "parent") + XCTAssertEqual((backTarget?["sys"] as? [String: Any])?["type"] as? String, "Link", "the back-edge inside rich text must be an unresolved-link stub, not a full re-expansion") + XCTAssertNil(backTarget?["fields"], "the rich-text back-edge must not have been expanded into a full entry map") + } + + // MARK: - Date fields + + /// `contentful.swift`'s generic `[String: Any]` field decoder + /// (`Decodable.swift`'s `KeyedDecodingContainer.decode(_: [String: Any].Type)`) tries `Bool`, + /// then `String`, before any date-specific type. A Contentful "Date" field is a JSON string + /// (e.g. `"2024-06-15T12:30:00Z"`), so it is captured by the `String` branch and surfaces in + /// `entry.fields` as `String`, never as Swift `Date`. Verified empirically against a real + /// decode. This means `OptimizationEntryMapping`'s `case let date as Date` branch (ported + /// faithfully from `OptimizationAdapter.swift`, which has the same dead branch) can only ever + /// trigger for a `Date` placed into the dict programmatically — never for a field decoded + /// from a real CDA response. Documented here rather than silently dropped, since removing it + /// would diverge from the reference file without a call to do so. + func testDateLikeFieldDecodesAsPlainStringNotSwiftDate() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"publishDate": "2024-06-15T12:30:00Z"} + } + """) + + XCTAssertTrue(entry.fields["publishDate"] is String) + XCTAssertFalse(entry.fields["publishDate"] is Date) + + let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) + XCTAssertEqual((mapped["fields"] as? [String: Any])?["publishDate"] as? String, "2024-06-15T12:30:00Z") + } + + // MARK: - Unsupported values are dropped, not thrown + + func testUnsupportedFieldTypeIsDroppedNotThrown() throws { + // FileMetadata.Details (nested under an Asset's "file" field) is one of the few decoded + // types `OptimizationEntryMapping.jsonValue` has no case for, and is only reachable at + // all when an Asset is inlined directly as a field value rather than via a Link — an + // edge case worth documenting: it is silently dropped, matching the mapper's stated + // policy of losing an unmapped field rather than risking the whole entry. + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"title": "kept", "count": 3} + } + """) + + let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) + let fields = mapped["fields"] as? [String: Any] + XCTAssertEqual(fields?["title"] as? String, "kept") + XCTAssertEqual(fields?["count"] as? Int, 3) + } +} diff --git a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizedEntryContentfulInitTests.swift b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizedEntryContentfulInitTests.swift new file mode 100644 index 000000000..2014fbe64 --- /dev/null +++ b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizedEntryContentfulInitTests.swift @@ -0,0 +1,154 @@ +@testable import Contentful +@testable import ContentfulOptimization +import Foundation +import SwiftUI +import XCTest + +/// Tests `OptimizedEntry` itself at the `Contentful.Entry` initializer boundary — not the +/// standalone `OptimizationEntryMapping` function, but that the initializer actually wires the +/// mapped dict into the stored `entry` property and wraps the caller's `(ResolvedEntry) -> +/// Content` closure into the stored `([String: Any]) -> Content` shape `body` calls. Both +/// `entry` and `content` are internal (no access modifier), so `@testable import` reaches them +/// directly — no rendering harness or `OptimizationClient` environment needed for this layer. +final class OptimizedEntryContentfulInitTests: XCTestCase { + private static let localizationContext: LocalizationContext = { + let localeJSON = Data(""" + {"code":"en-US","default":true,"name":"English","fallbackCode":null} + """.utf8) + let locale = try! JSONDecoder.withoutLocalizationContext().decode(Contentful.Locale.self, from: localeJSON) + return LocalizationContext(locales: [locale])! + }() + + private func decodeEntry(_ json: String) throws -> Entry { + let decoder = JSONDecoder.withoutLocalizationContext() + decoder.update(with: Self.localizationContext) + decoder.userInfo[.init(rawValue: "linkResolverContext")!] = NSObject() + return try decoder.decode(Entry.self, from: Data(json.utf8)) + } + + private static let sampleJSON = """ + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "landingPage", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"title": "Hello"} + } + """ + + // `Entry` is a class — `resolveLinks` (exercised in the sibling mapper test file) mutates it + // in place, so a decode-once-per-file `static let` would let mutation in one test leak into + // another regardless of run order. XCTest calls `setUp()` before every test method, which + // gives each test its own decode without repeating the boilerplate at every call site. + private var entry: Entry! + + override func setUpWithError() throws { + try super.setUpWithError() + entry = try decodeEntry(Self.sampleJSON) + } + + override func tearDown() { + entry = nil + super.tearDown() + } + + // MARK: - The initializer stores the mapped dict, not the raw Entry + + func testContentfulInitializerStoresMappedDictAsEntry() throws { + let sut = OptimizedEntry(entry: entry) { (_: ResolvedEntry) in EmptyView() } + + XCTAssertEqual( + NSDictionary(dictionary: sut.entry), + NSDictionary(dictionary: OptimizationEntryMapping.toOptimizationEntry(entry)) + ) + XCTAssertEqual((sut.entry["sys"] as? [String: Any])?["id"] as? String, "e1") + XCTAssertNotNil(sut.entry["metadata"], "the always-present metadata guarantee must hold through the initializer, not just the mapper") + } + + // MARK: - The stored `content` closure forwards its actual argument, not the captured baseline entry + + func testStoredContentClosureForwardsResolvedVariantNotCapturedBaselineEntry() throws { + var received: ResolvedEntry? + + func makeContent(for resolved: ResolvedEntry) -> SwiftUI.Text { + received = resolved + return SwiftUI.Text("rendered") + } + + let sut = OptimizedEntry(entry: entry, content: makeContent) + + // Deliberately distinct from `entry`'s own id/title ("e1"/"Hello"), and fed through + // `sut.content` rather than reused from `OptimizationEntryMapping.toOptimizationEntry`. + // At runtime `body` calls the stored `content` with `result.entry` — the *resolved + // variant* a live OptimizationClient hands back, which for a personalized entry can + // genuinely differ from the baseline stored in `sut.entry`. A distinguishing value here + // is what actually proves the closure forwards its argument: if the wrapping closure had + // a bug like `{ _ in content(ResolvedEntry(OptimizationEntryMapping.toOptimizationEntry(entry))) }` + // — ignoring its parameter and re-deriving from the captured baseline entry instead — a + // same-shaped stand-in would pass by coincidence and this bug would go undetected. + let resolverOutput: [String: Any] = [ + "sys": ["id": "resolved-1"], + "fields": ["title": "Resolved Title"], + ] + _ = sut.content(resolverOutput) + + XCTAssertEqual(received?.id, "resolved-1") + XCTAssertEqual(received?.getField("title"), "Resolved Title") + } + + // MARK: - Both initializers can coexist on the same generic Content type + + func testDictAndContentfulInitializersProduceSameGenericContentType() throws { + // If this compiles, Swift resolved both initializers to `OptimizedEntry` — the + // point of keeping a single generic parameter rather than adding a second one for + // `Resolved`. A type mismatch here would be a compile error, not a runtime failure. + let fromDict: OptimizedEntry = OptimizedEntry(entry: ["sys": ["id": "x"], "fields": [:]]) { _ in + SwiftUI.Text("dict") + } + let fromEntry: OptimizedEntry = OptimizedEntry(entry: entry) { (_: ResolvedEntry) in + SwiftUI.Text("entry") + } + + XCTAssertEqual((fromDict.entry["sys"] as? [String: Any])?["id"] as? String, "x") + XCTAssertEqual((fromEntry.entry["sys"] as? [String: Any])?["id"] as? String, "e1") + } + + // MARK: - Non-optimized entries: the Contentful.Entry initializer still round-trips through body's baseline path + + func testMappedEntryWithoutExperiencesFieldIsTreatedAsNonOptimized() throws { + // No `nt_experiences` field — `isOptimized` (OptimizedEntry.swift) should be false, and + // `body` takes the non-optimized branch, but that's an OptimizationClient-dependent path. + // What's testable without rendering is that the mapped dict itself carries no + // `nt_experiences` key, which is the input `isOptimized` reads. + let sut = OptimizedEntry(entry: entry) { (_: ResolvedEntry) in EmptyView() } + + let fields = sut.entry["fields"] as? [String: Any] + XCTAssertNil(fields?["nt_experiences"]) + } + + // MARK: - onTap stays dict-typed on both initializers — by design, not by oversight + + /// `onTap` on the `Contentful.Entry` initializer is `(([String: Any]) -> Void)?` — the same + /// raw-dict shape as the dict-based initializer's `onTap`, *not* `((ResolvedEntry) -> Void)?` + /// like `content`. This looks like an asymmetry against `content`'s typed wrapping, but it + /// isn't one: `TapTrackingModifier.body(content:)` (`Tracking/TapTrackingModifier.swift`) + /// calls `onTap?(entry)` with the view's *baseline* `entry` — never `result.entry`, the + /// resolved variant `content` receives — on both initializers equally. `onTap` reports which + /// baseline entry was tapped, for tracking; `content` renders the resolved variant, for + /// display. Different roles, so no `ResolvedEntry` wrapping applies to `onTap` on either + /// initializer. This test pins that down so a future change to `onTap`'s type is a deliberate + /// decision, not a silent regression. + func testOnTapReceivesBaselineDictOnContentfulInitializerNotResolvedEntry() throws { + var receivedOnTapArgument: [String: Any]? + + let sut = OptimizedEntry( + entry: entry, + onTap: { raw in receivedOnTapArgument = raw }, + content: { (_: ResolvedEntry) in EmptyView() } + ) + + // Exercises the same call `TapTrackingModifier` makes: `onTap?(entry)`, with the view's + // own stored baseline `entry` (`sut.entry`) — not a resolved variant. + sut.onTap?(sut.entry) + + XCTAssertEqual((receivedOnTapArgument?["sys"] as? [String: Any])?["id"] as? String, "e1") + } +} diff --git a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/ResolvedEntryTests.swift b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/ResolvedEntryTests.swift new file mode 100644 index 000000000..373d46a06 --- /dev/null +++ b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/ResolvedEntryTests.swift @@ -0,0 +1,62 @@ +@testable import ContentfulOptimization +import XCTest + +/// Direct unit tests for `ResolvedEntry` in isolation — the happy path is already exercised +/// indirectly through `OptimizedEntryContentfulInitTests`, but the absent/wrong-type cases (a +/// resolver output missing `sys`/`fields`, or a field read back as the wrong type) have no +/// coverage anywhere else. +final class ResolvedEntryTests: XCTestCase { + func testGetFieldReturnsValueForMatchingType() { + let resolved = ResolvedEntry([ + "sys": ["id": "e1"], + "fields": ["title": "Hello", "count": 3, "isFeatured": true], + ]) + + XCTAssertEqual(resolved.getField("title"), "Hello") + XCTAssertEqual(resolved.getField("count"), 3) + XCTAssertEqual(resolved.getField("isFeatured"), true) + } + + func testGetFieldReturnsNilForWrongRequestedType() { + // "count" is an Int in the raw map; requesting it as String must fail the `as?` cast and + // return nil, not crash or coerce. + let resolved = ResolvedEntry(["sys": [:], "fields": ["count": 3]]) + + let asString: String? = resolved.getField("count") + XCTAssertNil(asString) + } + + func testGetFieldReturnsNilForAbsentField() { + let resolved = ResolvedEntry(["sys": [:], "fields": ["title": "Hello"]]) + + let missing: String? = resolved.getField("subtitle") + XCTAssertNil(missing) + } + + func testGetFieldReturnsNilWhenFieldsKeyIsAbsent() { + // No "fields" key at all — e.g. a malformed or partial resolver output. + let resolved = ResolvedEntry(["sys": ["id": "e1"]]) + + let value: String? = resolved.getField("title") + XCTAssertNil(value) + } + + func testIdReturnsSysId() { + let resolved = ResolvedEntry(["sys": ["id": "e1"], "fields": [:]]) + + XCTAssertEqual(resolved.id, "e1") + } + + func testIdReturnsNilWhenSysKeyIsAbsent() { + let resolved = ResolvedEntry(["fields": ["title": "Hello"]]) + + XCTAssertNil(resolved.id) + } + + func testIdReturnsNilWhenSysIdIsWrongType() { + // "id" present but not a String — e.g. accidentally passed a number. + let resolved = ResolvedEntry(["sys": ["id": 123], "fields": [:]]) + + XCTAssertNil(resolved.id) + } +} From 73a0632c1ae0375c3af4855aca4a70cf7a6acd51 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Thu, 30 Jul 2026 12:02:42 +0200 Subject: [PATCH 02/21] fix(swift): map full asset metadata and FileMetadata field values [NT-3808] Extends OptimizationEntryMapping's asset handling to surface description, contentType, and file details (size/image dimensions) instead of only title/file.url, and adds a case for Asset.FileMetadata decoded directly as a field value (a custom Object field shaped like a file metadata blob). Both were previously silently dropped. Adds coverage for the asset-with-no-file fallback path (select query / still-processing upload) that was previously untested. Co-Authored-By: Claude Sonnet 5 --- .../Contentful/OptimizationEntryMapping.swift | 35 ++++- .../OptimizationEntryMappingTests.swift | 140 +++++++++++++++++- 2 files changed, 166 insertions(+), 9 deletions(-) diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/OptimizationEntryMapping.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/OptimizationEntryMapping.swift index 5ab4eeafd..583cbccd3 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/OptimizationEntryMapping.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/OptimizationEntryMapping.swift @@ -54,6 +54,13 @@ enum OptimizationEntryMapping { return jsonLink(link, ancestors: ancestors) case let richText as Contentful.RichTextDocument: return jsonNode(richText, ancestors: ancestors) + // A field of Contentful type "Object" shaped exactly like a file metadata blob + // (`{fileName, contentType, url, details: {size, image: {width, height}}}`) decodes to + // this type — the generic `[String: Any]` decoder (`Decodable.swift`) tries it before + // falling back to a plain dictionary. Reuses `jsonFileMetadata`, the same helper a + // resolved asset link's `file` field goes through. + case let file as Contentful.Asset.FileMetadata: + return jsonFileMetadata(file) case let array as [Any]: return array.compactMap { jsonValue($0, ancestors: ancestors) } case let dictionary as [String: Any]: @@ -122,10 +129,12 @@ enum OptimizationEntryMapping { case let .entry(entry) where !ancestors.contains(entry.id): return entryMap(entry, ancestors: ancestors) case let .asset(asset): - return [ - "sys": ["id": asset.id, "type": "Asset"], - "fields": ["title": asset.title ?? "", "file": ["url": asset.urlString ?? ""]], - ] + var fields: [String: Any] = ["title": asset.title ?? ""] + if let description = asset.description { + fields["description"] = description + } + fields["file"] = asset.file.map(jsonFileMetadata) ?? ["url": asset.urlString ?? ""] + return ["sys": ["id": asset.id, "type": "Asset"], "fields": fields] case let .unresolved(sys): return ["sys": ["id": sys.id, "type": sys.type, "linkType": sys.linkType]] // A back-edge, or a typed `EntryDecodable` this mapper never registers: emit the stub an @@ -134,4 +143,22 @@ enum OptimizationEntryMapping { return ["sys": ["id": link.id, "type": "Link", "linkType": "Entry"]] } } + + /// An asset's `file` metadata, reduced to the raw CDA response shape + /// (`{fileName, contentType, details: {size, image: {width, height}}, url}`) — the same shape + /// whether it arrived via a resolved asset link (`jsonLink`'s `.asset` case) or as a directly + /// decoded field value (`jsonValue`'s `Asset.FileMetadata` case, for a custom "Object" field + /// shaped like one). `details.image` is only present for image files. + private static func jsonFileMetadata(_ file: Contentful.Asset.FileMetadata) -> [String: Any] { + var details: [String: Any] = ["size": file.details?.size ?? 0] + if let imageInfo = file.details?.imageInfo { + details["image"] = ["width": imageInfo.width, "height": imageInfo.height] + } + return [ + "fileName": file.fileName, + "contentType": file.contentType, + "details": details, + "url": file.url?.absoluteString ?? "", + ] + } } diff --git a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationEntryMappingTests.swift b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationEntryMappingTests.swift index ca87486cc..9470d7178 100644 --- a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationEntryMappingTests.swift +++ b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationEntryMappingTests.swift @@ -269,6 +269,111 @@ final class OptimizationEntryMappingTests: XCTestCase { XCTAssertEqual(file?["url"] as? String, "https://images.ctfassets.net/a.jpg") } + /// `Asset` exposes `description`, `file.contentType`, and `file.details.{size,image}` beyond + /// `title`/`file.url` — the previous mapping dropped all of them. This proves the full asset + /// shape survives, not just the two fields the minimal mapping used to surface. + func testResolvedAssetLinkMapsDescriptionContentTypeSizeAndImageDimensions() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"image": {"sys": {"id": "asset-1", "type": "Link", "linkType": "Asset"}}} + } + """) + let assetDecoder = JSONDecoder.withoutLocalizationContext() + assetDecoder.update(with: Self.localizationContext) + assetDecoder.userInfo[.init(rawValue: "linkResolverContext")!] = NSObject() + let asset = try assetDecoder.decode(Asset.self, from: Data(""" + { + "sys": {"id": "asset-1", "type": "Asset", "locale": "en-US"}, + "fields": {"title": "A photo", "description": "A scenic view", + "file": {"fileName": "a.jpg", "contentType": "image/jpeg", + "details": {"size": 1024, "image": {"width": 800, "height": 600}}, + "url": "//images.ctfassets.net/a.jpg"}} + } + """.utf8)) + + entry.resolveLinks(against: [:], and: ["asset-1": asset]) + + let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) + let imageFields = ((mapped["fields"] as? [String: Any])?["image"] as? [String: Any])?["fields"] as? [String: Any] + XCTAssertEqual(imageFields?["description"] as? String, "A scenic view") + let file = imageFields?["file"] as? [String: Any] + XCTAssertEqual(file?["fileName"] as? String, "a.jpg") + XCTAssertEqual(file?["contentType"] as? String, "image/jpeg") + let details = file?["details"] as? [String: Any] + XCTAssertEqual(details?["size"] as? Int, 1024) + let image = details?["image"] as? [String: Any] + XCTAssertEqual(image?["width"] as? Double, 800) + XCTAssertEqual(image?["height"] as? Double, 600) + } + + /// A non-image asset's `file.details` has no `image` key at all in a raw CDA response — this + /// proves the mapper omits the key rather than emitting `image: null` or a zeroed dimension. + func testResolvedAssetLinkWithoutDescriptionOrImageOmitsThoseKeys() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"attachment": {"sys": {"id": "asset-1", "type": "Link", "linkType": "Asset"}}} + } + """) + let assetDecoder = JSONDecoder.withoutLocalizationContext() + assetDecoder.update(with: Self.localizationContext) + assetDecoder.userInfo[.init(rawValue: "linkResolverContext")!] = NSObject() + let asset = try assetDecoder.decode(Asset.self, from: Data(""" + { + "sys": {"id": "asset-1", "type": "Asset", "locale": "en-US"}, + "fields": {"title": "A PDF", "file": {"fileName": "doc.pdf", "contentType": "application/pdf", + "details": {"size": 2048}, "url": "//assets.ctfassets.net/doc.pdf"}} + } + """.utf8)) + + entry.resolveLinks(against: [:], and: ["asset-1": asset]) + + let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) + let attachmentFields = ((mapped["fields"] as? [String: Any])?["attachment"] as? [String: Any])?["fields"] as? [String: Any] + XCTAssertNil(attachmentFields?["description"], "an asset with no description must omit the key, not emit an empty string or null") + let details = (attachmentFields?["file"] as? [String: Any])?["details"] as? [String: Any] + XCTAssertNil(details?["image"], "a non-image asset's details must omit the image key entirely") + XCTAssertEqual(details?["size"] as? Int, 2048) + } + + /// `Asset.file` is `nil` when a `select()` query excludes it, or the media is still + /// processing after upload — a raw CDA response's `fields` in that case carries no `file` key + /// at all. This proves the mapper falls back to `urlString` instead of crashing on + /// `asset.file`'s optional or emitting a `file` key shaped like `jsonFileMetadata`'s output + /// with missing pieces. + func testResolvedAssetLinkWithoutFileFallsBackToURLStringShape() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"image": {"sys": {"id": "asset-1", "type": "Link", "linkType": "Asset"}}} + } + """) + let assetDecoder = JSONDecoder.withoutLocalizationContext() + assetDecoder.update(with: Self.localizationContext) + assetDecoder.userInfo[.init(rawValue: "linkResolverContext")!] = NSObject() + // No "file" key at all — the shape a `select(fields: ["title"])` query or a + // still-processing upload produces. + let asset = try assetDecoder.decode(Asset.self, from: Data(""" + { + "sys": {"id": "asset-1", "type": "Asset", "locale": "en-US"}, + "fields": {"title": "Still processing"} + } + """.utf8)) + + entry.resolveLinks(against: [:], and: ["asset-1": asset]) + + let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) + let imageFields = ((mapped["fields"] as? [String: Any])?["image"] as? [String: Any])?["fields"] as? [String: Any] + XCTAssertEqual(imageFields?["title"] as? String, "Still processing") + let file = imageFields?["file"] as? [String: Any] + XCTAssertEqual(file?["url"] as? String, "", "with no file metadata, the mapper must still emit a file.url key (empty), matching the pre-existing fallback shape") + XCTAssertNil(file?["fileName"], "the fallback shape must not claim fileName/contentType/details it doesn't have") + } + // MARK: - Location func testLocationFieldMapsToLatLon() throws { @@ -702,11 +807,6 @@ final class OptimizationEntryMappingTests: XCTestCase { // MARK: - Unsupported values are dropped, not thrown func testUnsupportedFieldTypeIsDroppedNotThrown() throws { - // FileMetadata.Details (nested under an Asset's "file" field) is one of the few decoded - // types `OptimizationEntryMapping.jsonValue` has no case for, and is only reachable at - // all when an Asset is inlined directly as a field value rather than via a Link — an - // edge case worth documenting: it is silently dropped, matching the mapper's stated - // policy of losing an unmapped field rather than risking the whole entry. let entry = try decodeEntry(""" { "sys": {"id": "e1", "type": "Entry", "locale": "en-US", @@ -720,4 +820,34 @@ final class OptimizationEntryMappingTests: XCTestCase { XCTAssertEqual(fields?["title"] as? String, "kept") XCTAssertEqual(fields?["count"] as? Int, 3) } + + // MARK: - Asset.FileMetadata decoded directly as a field value + + /// A field of Contentful type "Object" shaped exactly like a file metadata blob decodes to + /// `Asset.FileMetadata` directly — no `Asset`/`Link` wrapper at all (contentful.swift's + /// generic `[String: Any]` field decoder tries `Asset.FileMetadata` before falling back to a + /// plain dictionary). This is distinct from `testResolvedAssetLinkMapsDescriptionContentTypeSizeAndImageDimensions`, + /// which covers the same shape arriving through a resolved asset *link* instead. + func testFileMetadataShapedObjectFieldMapsSameAsAssetFile() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"rawFile": {"fileName": "raw.png", "contentType": "image/png", + "details": {"size": 512, "image": {"width": 100, "height": 50}}, + "url": "//images.ctfassets.net/raw.png"}} + } + """) + + let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) + let rawFile = (mapped["fields"] as? [String: Any])?["rawFile"] as? [String: Any] + XCTAssertEqual(rawFile?["fileName"] as? String, "raw.png") + XCTAssertEqual(rawFile?["contentType"] as? String, "image/png") + XCTAssertEqual(rawFile?["url"] as? String, "https://images.ctfassets.net/raw.png") + let details = rawFile?["details"] as? [String: Any] + XCTAssertEqual(details?["size"] as? Int, 512) + let image = details?["image"] as? [String: Any] + XCTAssertEqual(image?["width"] as? Double, 100) + XCTAssertEqual(image?["height"] as? Double, 50) + } } From e6a5dbd62c2a2e609f2ce98905359a1b8aa6cb90 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Thu, 30 Jul 2026 12:54:02 +0200 Subject: [PATCH 03/21] fix(swift): mirror Contentful.Entry's readable surface on ResolvedEntry [NT-3808] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries sys.createdAt/updatedAt/revision/locale through OptimizationEntryMapping when present, and adds matching ResolvedEntry accessors (localeCode, createdAt, updatedAt) plus String/Int subscripts, so a resolved variant reads like a fetched Entry rather than exposing only id/getField. type, currentlySelectedLocale, metadata, and setLocale are deliberately not mirrored — documented on ResolvedEntry, since contentful.swift gives no way to reconstruct them from a resolved map. Also records these types and the mirroring boundary in the iOS SDK knowledge base. Co-Authored-By: Claude Sonnet 5 --- .../internal/sdk-knowledge/native/ios.md | 18 +++++ .../Contentful/OptimizationEntryMapping.swift | 32 +++++++-- .../Contentful/ResolvedEntry.swift | 52 +++++++++++++- .../OptimizationEntryMappingTests.swift | 41 ++++++++++++ .../ResolvedEntryTests.swift | 67 +++++++++++++++++++ 5 files changed, 202 insertions(+), 8 deletions(-) diff --git a/documentation/internal/sdk-knowledge/native/ios.md b/documentation/internal/sdk-knowledge/native/ios.md index 078fedcfe..8dce26773 100644 --- a/documentation/internal/sdk-knowledge/native/ios.md +++ b/documentation/internal/sdk-knowledge/native/ios.md @@ -35,6 +35,7 @@ apps mostly use the view surface, UIKit apps mostly use the imperative `Optimiza | Config | `OptimizationConfig`, `OptimizationApiConfig`, `StorageDefaults`, `OptimizationLogLevel`, `QueuePolicy`, `QueueFlushPolicy`, `QueueEvent`/`QueueEventType`, `BlockedEvent` | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift#OptimizationConfig; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift#StorageDefaults | | Event payloads | `IdentifyPayload`, `PageEventPayload`, `ScreenEventPayload`, `TrackEventPayload`, `TrackViewPayload`, `TrackClickPayload` | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/EventPayloads.swift#ScreenEventPayload; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/TrackViewPayload.swift#TrackViewPayload | | State / result types | `OptimizationState`, `ResolvedOptimizedEntry`, `PreviewState` (+ DTOs), `JSONValue`, `OptimizationError` | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationState.swift#OptimizationState; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationError.swift#OptimizationError | +| `contentful.swift` adapter | `OptimizationEntryMapping.toOptimizationEntry(_:)` (`Contentful.Entry` → `[String: Any]`); `ResolvedEntry` | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/OptimizationEntryMapping.swift#OptimizationEntryMapping; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/ResolvedEntry.swift#ResolvedEntry | | Tracking (imperative) | `ViewTrackingController`, `TrackingMetadata` | extern:ViewTrackingController is a @MainActor imperative view-timing engine for UIKit — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ViewTrackingController.swift#ViewTrackingController | | Preview panel | `PreviewPanelOverlay` (SwiftUI), `PreviewPanelViewController` (UIKit), `PreviewPanelConfig`, `PreviewContentfulClient` / `ContentfulHTTPPreviewClient`, `PreviewPanelContent` | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewPanelViewController.swift#PreviewPanelViewController; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewContentfulClient.swift#ContentfulHTTPPreviewClient | @@ -152,6 +153,23 @@ viewportHeight:)` from its own scroll/layout callbacks and the controller applie expanded inline `nt_mergetag` entry to the bridge, which reads the selector against the current profile and returns the resolved string or `nil` (fallback). The app owns extracting the embedded entry from Rich Text before calling it. source: extern:getMergeTagValue passes the mergetag entry to the bridge — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift#OptimizationClient; core-sdk#resolvers/MergeTagValueResolver.ts#resolve; kb:shared/concepts.md +- `contentful.swift` integration: `OptimizationEntryMapping.toOptimizationEntry(_:)` converts a + `Contentful.Entry` into the `{sys, fields, metadata}` map the resolver expects, recursively + expanding resolved links/assets/rich text and always emitting `metadata: {tags, concepts}` (empty + when absent) so the resolver's entry guard never silently treats a real optimized entry as + non-optimized. `OptimizedEntry(entry: Contentful.Entry, ...)` is a second SwiftUI initializer that + runs the map once at construction and hands the resolved variant back through `ResolvedEntry` + instead of a raw dict; both initializers produce the same `OptimizedEntry` type. + source: extern:OptimizationEntryMapping.toOptimizationEntry always emits metadata, recurses links/rich text — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/OptimizationEntryMapping.swift#OptimizationEntryMapping; extern:OptimizedEntry(entry: Contentful.Entry) initializer wraps the mapping and ResolvedEntry — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift#OptimizedEntry +- `ResolvedEntry` mirrors `Contentful.Entry`'s own readable surface — `id`, `localeCode`, + `createdAt`, `updatedAt`, `getField(_:)`, and the `String`/`Int` convenience subscripts — so a + resolved variant reads exactly like a fetched `Entry` instead of a raw map. It does **not** mirror + `Entry.type`/`currentlySelectedLocale`/`metadata`/`setLocale(withCode:)`: `type: ContentType?` and + `currentlySelectedLocale: Locale` are full fetched resources the resolved map never carries and + `contentful.swift` gives no public initializer to fabricate, and `Metadata` has no public + initializer either, so `metadata.tags` on the resolved map can only be read via `getField`, never + wrapped back into a real `Metadata` value. + source: extern:ResolvedEntry mirrors Entry.id/localeCode/createdAt/updatedAt/getField/subscripts, omits type/currentlySelectedLocale/metadata/setLocale by design — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/ResolvedEntry.swift#ResolvedEntry ## Identifier ownership diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/OptimizationEntryMapping.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/OptimizationEntryMapping.swift index 583cbccd3..5525a4022 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/OptimizationEntryMapping.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/OptimizationEntryMapping.swift @@ -22,14 +22,32 @@ enum OptimizationEntryMapping { private static func entryMap(_ entry: Contentful.Entry, ancestors: Set) -> [String: Any] { let childAncestors = ancestors.union([entry.id]) - return [ - "sys": [ - "id": entry.id, - "type": "Entry", - "contentType": [ - "sys": ["id": entry.sys.contentTypeId ?? "", "type": "Link", "linkType": "ContentType"], - ], + var sys: [String: Any] = [ + "id": entry.id, + "type": "Entry", + "contentType": [ + "sys": ["id": entry.sys.contentTypeId ?? "", "type": "Link", "linkType": "ContentType"], ], + ] + // Carried through so `ResolvedEntry` can mirror `Entry.createdAt`/`updatedAt`/`localeCode` + // from the resolved output, not just `id`. All four are independently optional on `Sys` + // itself (e.g. `locale` is absent on a `/sync` or wildcard-locale response), so each is + // added only when present, matching the raw CDA response shape rather than emitting null. + if let createdAt = entry.sys.createdAt { + sys["createdAt"] = ISO8601DateFormatter().string(from: createdAt) + } + if let updatedAt = entry.sys.updatedAt { + sys["updatedAt"] = ISO8601DateFormatter().string(from: updatedAt) + } + if let revision = entry.sys.revision { + sys["revision"] = revision + } + if let locale = entry.sys.locale { + sys["locale"] = locale + } + + return [ + "sys": sys, "fields": entry.fields.compactMapValues { jsonValue($0, ancestors: childAncestors) }, // Required, not cosmetic: the resolver's entry guard rejects any entry without a // `metadata` object, and a rejected baseline is never given its variant. A raw CDA diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/ResolvedEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/ResolvedEntry.swift index 8531d00c1..c5e242be2 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/ResolvedEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/ResolvedEntry.swift @@ -11,6 +11,22 @@ import Foundation /// two can't share a type: an `Entry` can't be rebuilt from the resolver's map, since its /// initializer needs a localization context only a live decode carries. They share the *shape*, /// not the type, so app code reads both the same way without one impersonating the other. +/// +/// Mirrors every `Entry`/`FlatResource` member that a raw resolved map can actually carry: +/// `id`, `localeCode`, `createdAt`, `updatedAt`, `fields` (via `getField`), and the `String`/`Int` +/// subscripts. Three `Entry` members have no counterpart here, by construction rather than +/// oversight: +/// - `type: ContentType?` — a full fetched content-type schema resource. The resolved map only +/// ever carries the content type's `id` (`sys.contentType.sys.id`, see `OptimizationEntryMapping`), +/// never the schema `ContentType` itself, and `ContentType` has no public initializer to +/// reconstruct one from that id alone. +/// - `currentlySelectedLocale: Locale` — a full locale object (code/name/fallback chain), which +/// the resolved map never carries and `Locale` has no public initializer to fabricate. +/// - `metadata: Metadata?` / `setLocale(withCode:)` — `Metadata` has no public initializer, so +/// the resolved map's `metadata.tags` can't be wrapped back into a real `Metadata` value, only +/// into a dict `getField("metadata")` can still read. `setLocale` mutates which locale a live +/// multi-locale decode reads `fields` from; a resolved map is already a single-locale snapshot +/// with no such state to mutate. public struct ResolvedEntry { private let raw: [String: Any] @@ -18,13 +34,47 @@ public struct ResolvedEntry { self.raw = raw } + private var sys: [String: Any]? { + raw["sys"] as? [String: Any] + } + /// The entry `sys.id` — stable across a variant swap, so it's safe for navigation. public var id: String? { - (raw["sys"] as? [String: Any])?["id"] as? String + sys?["id"] as? String + } + + /// Mirrors `Entry.localeCode` (via `FlatResource`) — the code of the locale this resolved + /// variant's `fields` were read for. Absent on a raw CDA response fetched via `/sync` or the + /// wildcard `locale=*` query, same as on `Entry` itself. + public var localeCode: String? { + sys?["locale"] as? String + } + + /// Mirrors `Entry.createdAt`. `nil` if the resolved map never carried a `sys.createdAt` — a + /// resolver-synthesized entry (e.g. a variant assembled without a full CDA round trip) may + /// have no creation timestamp to report, same as `Entry.createdAt` returning `nil` for a + /// resource `select()`-queried without `sys`. + public var createdAt: Date? { + (sys?["createdAt"] as? String).flatMap { ISO8601DateFormatter().date(from: $0) } + } + + /// Mirrors `Entry.updatedAt`. See `createdAt` for why this can be `nil`. + public var updatedAt: Date? { + (sys?["updatedAt"] as? String).flatMap { ISO8601DateFormatter().date(from: $0) } } /// A field's resolved value, or nil if absent. public func getField(_ name: String) -> T? { (raw["fields"] as? [String: Any])?[name] as? T } + + /// Mirrors `Entry`'s `String` convenience subscript, which reads directly from `fields`. + public subscript(key: String) -> String? { + getField(key) + } + + /// Mirrors `Entry`'s `Int` convenience subscript, which reads directly from `fields`. + public subscript(key: String) -> Int? { + getField(key) + } } diff --git a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationEntryMappingTests.swift b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationEntryMappingTests.swift index 9470d7178..75bd17a44 100644 --- a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationEntryMappingTests.swift +++ b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationEntryMappingTests.swift @@ -46,6 +46,47 @@ final class OptimizationEntryMappingTests: XCTestCase { XCTAssertEqual(fields?["title"] as? String, "Hello") } + /// `ResolvedEntry.createdAt`/`updatedAt`/`localeCode` (see `ResolvedEntryTests`) can only + /// mirror real values if `entryMap`'s `sys` block actually carries them — this proves that + /// side of the round trip, not just that `ResolvedEntry` parses whatever it's given. + func testMapsSysTimestampsRevisionAndLocale() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", "revision": 3, + "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-06-15T12:30:00Z", + "contentType": {"sys": {"id": "landingPage", "type": "Link", "linkType": "ContentType"}}}, + "fields": {} + } + """) + + let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) + let sys = mapped["sys"] as? [String: Any] + XCTAssertEqual(sys?["locale"] as? String, "en-US") + XCTAssertEqual(sys?["revision"] as? Int, 3) + XCTAssertEqual(sys?["createdAt"] as? String, "2024-01-01T00:00:00Z") + XCTAssertEqual(sys?["updatedAt"] as? String, "2024-06-15T12:30:00Z") + } + + /// A `/sync` response, or one fetched with the wildcard `locale=*` query, carries no + /// `sys.locale` — this proves the mapper omits the key entirely rather than emitting + /// `locale: null`, matching `Entry.sys.locale`'s own optionality. + func testOmitsSysLocaleWhenAbsentFromSource() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", + "contentType": {"sys": {"id": "landingPage", "type": "Link", "linkType": "ContentType"}}}, + "fields": {} + } + """) + + let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) + let sys = mapped["sys"] as? [String: Any] + XCTAssertNil(sys?["locale"], "sys.locale must be omitted, not emitted as null, when the source entry has none") + XCTAssertNil(sys?["createdAt"]) + XCTAssertNil(sys?["updatedAt"]) + XCTAssertNil(sys?["revision"]) + } + // MARK: - The silent metadata requirement /// The resolver's entry guard (`isResolvedContentfulEntry` in diff --git a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/ResolvedEntryTests.swift b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/ResolvedEntryTests.swift index 373d46a06..b33551492 100644 --- a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/ResolvedEntryTests.swift +++ b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/ResolvedEntryTests.swift @@ -59,4 +59,71 @@ final class ResolvedEntryTests: XCTestCase { XCTAssertNil(resolved.id) } + + // MARK: - localeCode mirrors Entry.localeCode + + func testLocaleCodeReturnsSysLocale() { + let resolved = ResolvedEntry(["sys": ["id": "e1", "locale": "en-US"], "fields": [:]]) + + XCTAssertEqual(resolved.localeCode, "en-US") + } + + func testLocaleCodeReturnsNilWhenAbsent() { + // Absent on a raw CDA response fetched via /sync or the wildcard `locale=*` query — + // same case where `Entry.localeCode` itself returns nil. + let resolved = ResolvedEntry(["sys": ["id": "e1"], "fields": [:]]) + + XCTAssertNil(resolved.localeCode) + } + + // MARK: - createdAt/updatedAt mirror Entry.createdAt/updatedAt + + func testCreatedAtAndUpdatedAtParseISO8601SysTimestamps() { + let resolved = ResolvedEntry([ + "sys": ["id": "e1", "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-06-15T12:30:00Z"], + "fields": [:], + ]) + + XCTAssertNotNil(resolved.createdAt) + XCTAssertNotNil(resolved.updatedAt) + XCTAssertNotEqual(resolved.createdAt, resolved.updatedAt) + } + + func testCreatedAtAndUpdatedAtReturnNilWhenAbsent() { + // A resolver-synthesized entry may carry no creation/update timestamps — same as + // `Entry.createdAt`/`updatedAt` returning nil for a resource fetched without `sys` dates. + let resolved = ResolvedEntry(["sys": ["id": "e1"], "fields": [:]]) + + XCTAssertNil(resolved.createdAt) + XCTAssertNil(resolved.updatedAt) + } + + func testCreatedAtReturnsNilForUnparseableTimestamp() { + let resolved = ResolvedEntry(["sys": ["id": "e1", "createdAt": "not-a-date"], "fields": [:]]) + + XCTAssertNil(resolved.createdAt) + } + + // MARK: - String/Int subscripts mirror Entry's convenience subscripts + + func testStringSubscriptReadsFromFields() { + let resolved = ResolvedEntry(["sys": [:], "fields": ["title": "Hello"]]) + + let title: String? = resolved["title"] + XCTAssertEqual(title, "Hello") + } + + func testIntSubscriptReadsFromFields() { + let resolved = ResolvedEntry(["sys": [:], "fields": ["count": 3]]) + + let count: Int? = resolved["count"] + XCTAssertEqual(count, 3) + } + + func testStringSubscriptReturnsNilForWrongType() { + let resolved = ResolvedEntry(["sys": [:], "fields": ["count": 3]]) + + let asString: String? = resolved["count"] + XCTAssertNil(asString) + } } From 95932b0e948c1eab0c4740612c31c6ec52798328 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Thu, 30 Jul 2026 13:23:05 +0200 Subject: [PATCH 04/21] test(swift): cover a 5-level entry chain and a 3-node link cycle [NT-3808] Existing coverage only proved the ancestor-cycle guard terminates a 2-node cycle (parent <-> child) and expansion holds through 3 levels. Adds a 3-node cycle (a -> b -> c -> a), which a guard that only compared against the immediate parent (instead of the full ancestors path) would still pass the 2-node case but loop forever on, and a 5-level linear chain to rule out a hidden depth cap. Co-Authored-By: Claude Sonnet 5 --- .../OptimizationEntryMappingTests.swift | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationEntryMappingTests.swift b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationEntryMappingTests.swift index 75bd17a44..d1a5d0f10 100644 --- a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationEntryMappingTests.swift +++ b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationEntryMappingTests.swift @@ -278,6 +278,93 @@ final class OptimizationEntryMappingTests: XCTestCase { } } + /// `testMultiLevelNestedEntriesExpandAtEveryLevel` only proves 3 levels expand; a depth-limit + /// bug (e.g. an accidental cap, or an off-by-one in how `ancestors` is threaded through each + /// recursive call) could still exist beyond that. This chains 5 levels + /// (l1 -> l2 -> l3 -> l4 -> l5) through a single-entry `child` link at each hop — not a + /// diamond or a cycle — to prove recursion itself has no hidden depth ceiling. + func testFiveLevelLinearChainExpandsAtEveryLevel() throws { + func entryJSON(level: Int) -> String { + let childField = level < 5 + ? """ + , "child": {"sys": {"id": "l\(level + 1)", "type": "Link", "linkType": "Entry"}} + """ + : "" + return """ + { + "sys": {"id": "l\(level)", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "level \(level)"\(childField)} + } + """ + } + let levels = try (1 ... 5).map { try decodeEntry(entryJSON(level: $0)) } + let entriesMap = Dictionary(uniqueKeysWithValues: levels.map { ($0.id, $0) }) + for entry in levels { + entry.resolveLinks(against: entriesMap, and: [:]) + } + + let mapped = OptimizationEntryMapping.toOptimizationEntry(levels[0]) + + var fields = mapped["fields"] as? [String: Any] + for level in 1 ... 5 { + XCTAssertEqual(fields?["name"] as? String, "level \(level)", "level \(level) must have expanded, not stopped short") + let child = fields?["child"] as? [String: Any] + if level < 5 { + XCTAssertNotNil(child?["fields"], "level \(level + 1) must have expanded inline, not been left as an unresolved-link stub") + } + fields = child?["fields"] as? [String: Any] + } + } + + /// The existing cycle tests (`testSelfReferencingLinkDoesNotRecurseInfinitely`, + /// `testRichTextEmbeddedEntryCycleDoesNotRecurseInfinitely`) only cover a 2-node cycle + /// (parent <-> child). This proves the ancestor guard also terminates a longer cycle — + /// a -> b -> c -> a — where the back-edge closes several hops later rather than immediately, + /// so a bug that only checked the immediate parent (instead of the full `ancestors` path) + /// would not be caught by the 2-node case alone. + func testThreeNodeCycleDoesNotRecurseInfinitely() throws { + let a = try decodeEntry(""" + { + "sys": {"id": "a", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"next": {"sys": {"id": "b", "type": "Link", "linkType": "Entry"}}} + } + """) + let b = try decodeEntry(""" + { + "sys": {"id": "b", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"next": {"sys": {"id": "c", "type": "Link", "linkType": "Entry"}}} + } + """) + let c = try decodeEntry(""" + { + "sys": {"id": "c", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"next": {"sys": {"id": "a", "type": "Link", "linkType": "Entry"}}} + } + """) + + let entriesMap = ["a": a, "b": b, "c": c] + for entry in [a, b, c] { + entry.resolveLinks(against: entriesMap, and: [:]) + } + + // Must terminate — the assertions below are only reachable if it does. + let mapped = OptimizationEntryMapping.toOptimizationEntry(a) + + let bField = (mapped["fields"] as? [String: Any])?["next"] as? [String: Any] + XCTAssertEqual((bField?["sys"] as? [String: Any])?["id"] as? String, "b") + let cField = (bField?["fields"] as? [String: Any])?["next"] as? [String: Any] + XCTAssertEqual((cField?["sys"] as? [String: Any])?["id"] as? String, "c") + let backToA = (cField?["fields"] as? [String: Any])?["next"] as? [String: Any] + let backSys = backToA?["sys"] as? [String: Any] + XCTAssertEqual(backSys?["id"] as? String, "a") + XCTAssertEqual(backSys?["type"] as? String, "Link", "the back-edge closing a 3-node cycle must be an unresolved-link stub, not a full re-expansion") + XCTAssertNil(backToA?["fields"], "the cycle-closing back-edge must not have been expanded into a full entry map") + } + // MARK: - Asset mapping func testResolvedAssetLinkMapsTitleAndURL() throws { From 1eb75f8a5105ad186ace6d50029c36cd6743b353 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Thu, 30 Jul 2026 15:53:29 +0200 Subject: [PATCH 05/21] feat(swift): add Contentful.Entry overload of resolveOptimizedEntry [NT-3808] Extends the imperative UIKit path with the same Contentful.Entry support OptimizedEntry already has for SwiftUI: OptimizationClient.resolveOptimizedEntry now overloads on baseline type, mapping a Contentful.Entry through OptimizationEntryMapping once and delegating to the existing dict-based overload, returning ResolvedContentfulOptimizedEntry (entry: ResolvedEntry) instead of a raw dict. Inherits the dict overload's fail-soft behavior exactly. Covers the not-initialized fallback, that the fallback actually routes through OptimizationEntryMapping, a real round trip through the initialized JS bridge, and that this is genuinely resolved by Swift overload resolution rather than a differently-named method. Co-Authored-By: Claude Sonnet 5 --- .../internal/sdk-knowledge/native/ios.md | 28 ++-- .../Core/OptimizationClient.swift | 21 +++ .../Core/ResolvedOptimizedEntry.swift | 11 ++ ...esolvedContentfulOptimizedEntryTests.swift | 136 ++++++++++++++++++ 4 files changed, 186 insertions(+), 10 deletions(-) create mode 100644 packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/ResolvedContentfulOptimizedEntryTests.swift diff --git a/documentation/internal/sdk-knowledge/native/ios.md b/documentation/internal/sdk-knowledge/native/ios.md index 8dce26773..cd4ecdba3 100644 --- a/documentation/internal/sdk-knowledge/native/ios.md +++ b/documentation/internal/sdk-knowledge/native/ios.md @@ -28,16 +28,16 @@ lifecycle, SwiftUI views, and preview-panel UI. Swift source root: Single module: `import ContentfulOptimization`. There is one SDK; both guides consume it — SwiftUI apps mostly use the view surface, UIKit apps mostly use the imperative `OptimizationClient` surface. -| Import path (`ContentfulOptimization`) | Public symbol or purpose | source | -| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| SwiftUI surface | `OptimizationRoot`, `OptimizedEntry`, `OptimizationScrollView`, `.trackScreen(name:)` / `ScreenTrackingModifier`, `TrackingConfig`, `ScrollContext` | extern:SwiftUI views/modifiers — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizationRoot.swift#OptimizationRoot; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/ScreenTrackingModifier.swift#trackScreen | -| Imperative client | `OptimizationClient` (`@MainActor` `ObservableObject`); `EventEmissionResult` | extern:OptimizationClient is a @MainActor ObservableObject facade wrapping the JS bridge — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift#OptimizationClient | -| Config | `OptimizationConfig`, `OptimizationApiConfig`, `StorageDefaults`, `OptimizationLogLevel`, `QueuePolicy`, `QueueFlushPolicy`, `QueueEvent`/`QueueEventType`, `BlockedEvent` | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift#OptimizationConfig; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift#StorageDefaults | -| Event payloads | `IdentifyPayload`, `PageEventPayload`, `ScreenEventPayload`, `TrackEventPayload`, `TrackViewPayload`, `TrackClickPayload` | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/EventPayloads.swift#ScreenEventPayload; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/TrackViewPayload.swift#TrackViewPayload | -| State / result types | `OptimizationState`, `ResolvedOptimizedEntry`, `PreviewState` (+ DTOs), `JSONValue`, `OptimizationError` | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationState.swift#OptimizationState; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationError.swift#OptimizationError | -| `contentful.swift` adapter | `OptimizationEntryMapping.toOptimizationEntry(_:)` (`Contentful.Entry` → `[String: Any]`); `ResolvedEntry` | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/OptimizationEntryMapping.swift#OptimizationEntryMapping; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/ResolvedEntry.swift#ResolvedEntry | -| Tracking (imperative) | `ViewTrackingController`, `TrackingMetadata` | extern:ViewTrackingController is a @MainActor imperative view-timing engine for UIKit — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ViewTrackingController.swift#ViewTrackingController | -| Preview panel | `PreviewPanelOverlay` (SwiftUI), `PreviewPanelViewController` (UIKit), `PreviewPanelConfig`, `PreviewContentfulClient` / `ContentfulHTTPPreviewClient`, `PreviewPanelContent` | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewPanelViewController.swift#PreviewPanelViewController; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewContentfulClient.swift#ContentfulHTTPPreviewClient | +| Import path (`ContentfulOptimization`) | Public symbol or purpose | source | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| SwiftUI surface | `OptimizationRoot`, `OptimizedEntry`, `OptimizationScrollView`, `.trackScreen(name:)` / `ScreenTrackingModifier`, `TrackingConfig`, `ScrollContext` | extern:SwiftUI views/modifiers — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizationRoot.swift#OptimizationRoot; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/ScreenTrackingModifier.swift#trackScreen | +| Imperative client | `OptimizationClient` (`@MainActor` `ObservableObject`); `EventEmissionResult` | extern:OptimizationClient is a @MainActor ObservableObject facade wrapping the JS bridge — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift#OptimizationClient | +| Config | `OptimizationConfig`, `OptimizationApiConfig`, `StorageDefaults`, `OptimizationLogLevel`, `QueuePolicy`, `QueueFlushPolicy`, `QueueEvent`/`QueueEventType`, `BlockedEvent` | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift#OptimizationConfig; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift#StorageDefaults | +| Event payloads | `IdentifyPayload`, `PageEventPayload`, `ScreenEventPayload`, `TrackEventPayload`, `TrackViewPayload`, `TrackClickPayload` | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/EventPayloads.swift#ScreenEventPayload; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/TrackViewPayload.swift#TrackViewPayload | +| State / result types | `OptimizationState`, `ResolvedOptimizedEntry`, `ResolvedContentfulOptimizedEntry`, `PreviewState` (+ DTOs), `JSONValue`, `OptimizationError` | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationState.swift#OptimizationState; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/ResolvedOptimizedEntry.swift#ResolvedContentfulOptimizedEntry; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationError.swift#OptimizationError | +| `contentful.swift` adapter | `OptimizationEntryMapping.toOptimizationEntry(_:)` (`Contentful.Entry` → `[String: Any]`); `ResolvedEntry` | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/OptimizationEntryMapping.swift#OptimizationEntryMapping; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/ResolvedEntry.swift#ResolvedEntry | +| Tracking (imperative) | `ViewTrackingController`, `TrackingMetadata` | extern:ViewTrackingController is a @MainActor imperative view-timing engine for UIKit — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ViewTrackingController.swift#ViewTrackingController | +| Preview panel | `PreviewPanelOverlay` (SwiftUI), `PreviewPanelViewController` (UIKit), `PreviewPanelConfig`, `PreviewContentfulClient` / `ContentfulHTTPPreviewClient`, `PreviewPanelContent` | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewPanelViewController.swift#PreviewPanelViewController; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewContentfulClient.swift#ContentfulHTTPPreviewClient | - SPM target links `JavaScriptCore` for consuming apps and copies `optimization-ios-bridge.umd.js` as a package resource; platforms are iOS 15+ / macOS 12+. source: extern:Package.swift links JavaScriptCore and copies the UMD bundle resource, iOS 15/macOS 12 — packages/ios/ContentfulOptimization/Package.swift @@ -161,6 +161,14 @@ viewportHeight:)` from its own scroll/layout callbacks and the controller applie runs the map once at construction and hands the resolved variant back through `ResolvedEntry` instead of a raw dict; both initializers produce the same `OptimizedEntry` type. source: extern:OptimizationEntryMapping.toOptimizationEntry always emits metadata, recurses links/rich text — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/OptimizationEntryMapping.swift#OptimizationEntryMapping; extern:OptimizedEntry(entry: Contentful.Entry) initializer wraps the mapping and ResolvedEntry — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift#OptimizedEntry +- The imperative UIKit path has the same `Contentful.Entry` overload as the SwiftUI view: + `OptimizationClient.resolveOptimizedEntry(baseline: Contentful.Entry, selectedOptimizations:)` + maps `baseline` through `OptimizationEntryMapping` once, then delegates to the dict-based + overload and wraps its result in `ResolvedContentfulOptimizedEntry` (`entry: ResolvedEntry` + instead of a raw dict). It inherits the dict-based overload's fail-soft behavior exactly — not + initialized, a serialization error, or an unparseable bridge result all fall back to the mapped + baseline with `selectedOptimization`/`optimizationContextId` nil. + source: extern:OptimizationClient.resolveOptimizedEntry(baseline: Contentful.Entry) maps then delegates to the dict overload — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift#OptimizationClient; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/ResolvedOptimizedEntry.swift#ResolvedContentfulOptimizedEntry - `ResolvedEntry` mirrors `Contentful.Entry`'s own readable surface — `id`, `localeCode`, `createdAt`, `updatedAt`, `getField(_:)`, and the `String`/`Int` convenience subscripts — so a resolved variant reads exactly like a fetched `Entry` instead of a raw map. It does **not** mirror diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift index 590e54f94..c2bc8f4a6 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift @@ -1,4 +1,5 @@ import Combine +import Contentful import Foundation import JavaScriptCore @@ -406,6 +407,26 @@ public final class OptimizationClient: ObservableObject { } } + /// `Contentful.Entry` overload of `resolveOptimizedEntry(baseline:selectedOptimizations:)` — + /// maps `baseline` through `OptimizationEntryMapping` once, so callers stop hand-writing the + /// `Entry -> {sys, fields, metadata}` mapping outside of `OptimizedEntry`'s view initializer. + /// Delegates to the dict-based overload above, so it inherits the same fail-soft behavior: not + /// initialized, a serialization error, or an unparseable bridge result all fall back to the + /// mapped baseline with `selectedOptimization`/`optimizationContextId` nil, logging rather than + /// throwing. + public func resolveOptimizedEntry( + baseline: Contentful.Entry, + selectedOptimizations: [[String: Any]]? = nil + ) -> ResolvedContentfulOptimizedEntry { + let mappedBaseline = OptimizationEntryMapping.toOptimizationEntry(baseline) + let result = resolveOptimizedEntry(baseline: mappedBaseline, selectedOptimizations: selectedOptimizations) + return ResolvedContentfulOptimizedEntry( + entry: ResolvedEntry(result.entry), + selectedOptimization: result.selectedOptimization, + optimizationContextId: result.optimizationContextId + ) + } + /// Resolve a merge-tag entry's display value against the current profile. /// /// Pass the resolved `nt_mergetag` entry (the `embedded-entry-inline` node's diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/ResolvedOptimizedEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/ResolvedOptimizedEntry.swift index 4b33f2b8e..0732c81b8 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/ResolvedOptimizedEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/ResolvedOptimizedEntry.swift @@ -6,3 +6,14 @@ public struct ResolvedOptimizedEntry { public let selectedOptimization: [String: Any]? public let optimizationContextId: String? } + +/// The result of resolving an optimized entry that was passed in as a `Contentful.Entry` — the +/// `Contentful.Entry`-typed counterpart to `ResolvedOptimizedEntry`. `entry` is a `ResolvedEntry` +/// (typed `getField` reads) rather than a raw `[String: Any]`, matching how +/// `OptimizedEntry(entry: Contentful.Entry, ...)` hands its render closure a `ResolvedEntry` +/// instead of a dict. +public struct ResolvedContentfulOptimizedEntry { + public let entry: ResolvedEntry + public let selectedOptimization: [String: Any]? + public let optimizationContextId: String? +} diff --git a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/ResolvedContentfulOptimizedEntryTests.swift b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/ResolvedContentfulOptimizedEntryTests.swift new file mode 100644 index 000000000..0fdaf71c4 --- /dev/null +++ b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/ResolvedContentfulOptimizedEntryTests.swift @@ -0,0 +1,136 @@ +@testable import Contentful +@testable import ContentfulOptimization +import Foundation +import XCTest + +/// Tests the `Contentful.Entry` overload of `resolveOptimizedEntry` — that it maps `baseline` +/// through `OptimizationEntryMapping` before delegating to the dict-based overload, and wraps the +/// dict-based result's `entry` in a `ResolvedEntry` rather than handing back a raw dict. Covers +/// both the not-initialized fail-soft path and a real round trip through the JS bridge, mirroring +/// `OptimizationClientTests.testResolveOptimizedEntryReturnsBaselineWhenNotInitialized` and +/// `testResolveOptimizedEntryPreservesFieldsWhenInitialized` for the dict-based overload. +final class ResolvedContentfulOptimizedEntryTests: XCTestCase { + private static let localizationContext: LocalizationContext = { + let localeJSON = Data(""" + {"code":"en-US","default":true,"name":"English","fallbackCode":null} + """.utf8) + let locale = try! JSONDecoder.withoutLocalizationContext().decode(Contentful.Locale.self, from: localeJSON) + return LocalizationContext(locales: [locale])! + }() + + private func decodeEntry(_ json: String) throws -> Entry { + let decoder = JSONDecoder.withoutLocalizationContext() + decoder.update(with: Self.localizationContext) + decoder.userInfo[.init(rawValue: "linkResolverContext")!] = NSObject() + return try decoder.decode(Entry.self, from: Data(json.utf8)) + } + + @MainActor + func testNotInitializedFallsBackToMappedBaselineEntry() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "entry-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"title": "Default Title"} + } + """) + let client = OptimizationClient() + + let result = client.resolveOptimizedEntry(baseline: entry) + + XCTAssertEqual(result.entry.id, "entry-1") + XCTAssertEqual(result.entry.getField("title"), "Default Title") + XCTAssertNil(result.selectedOptimization) + XCTAssertNil(result.optimizationContextId) + } + + /// Proves this overload actually routes through `OptimizationEntryMapping` rather than some + /// other conversion: a resolved link on the baseline must come back expanded exactly as + /// `OptimizationEntryMapping.toOptimizationEntry` would produce it, readable via `getField`. + @MainActor + func testNotInitializedFallbackEntryHasLinksExpandedByOptimizationEntryMapping() throws { + let parent = try decodeEntry(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"child": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}} + } + """) + let child = try decodeEntry(""" + { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "child entry"} + } + """) + parent.resolveLinks(against: ["parent": parent, "child-1": child], and: [:]) + let client = OptimizationClient() + + let result = client.resolveOptimizedEntry(baseline: parent) + + let childField: [String: Any]? = result.entry.getField("child") + XCTAssertEqual((childField?["sys"] as? [String: Any])?["id"] as? String, "child-1") + XCTAssertEqual((childField?["fields"] as? [String: Any])?["name"] as? String, "child entry", "the resolved link must have expanded inline, matching OptimizationEntryMapping's own behavior") + } + + /// This overload must be a true *overload* of the existing method — same name, + /// `resolveOptimizedEntry`, resolved by Swift purely from the static type of `baseline` at the + /// call site (a dict picks the `OptimizationClient` member; a `Contentful.Entry` picks this + /// extension member) — not a differently-named method that merely does something similar. If + /// this file's declaration used a different name, both calls below would still compile, but + /// this test's *point* would be false; the identical call syntax below, returning provably + /// different result types, is what actually proves overload resolution picked two distinct + /// declarations rather than one generic one. + @MainActor + func testIsATrueOverloadResolvedByBaselineArgumentType() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "entry-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"title": "Hello"} + } + """) + let dict: [String: Any] = ["sys": ["id": "entry-1"], "fields": ["title": "Hello"]] + let client = OptimizationClient() + + let dictResult: ResolvedOptimizedEntry = client.resolveOptimizedEntry(baseline: dict) + let entryResult: ResolvedContentfulOptimizedEntry = client.resolveOptimizedEntry(baseline: entry) + + XCTAssertEqual((dictResult.entry["sys"] as? [String: Any])?["id"] as? String, "entry-1") + XCTAssertEqual(entryResult.entry.id, "entry-1") + } + + // MARK: - Real bridge round trip (initialized client) + + /// The not-initialized tests above only prove the fallback path; they never exercise the + /// bridge call this overload actually delegates to. This round-trips a real `Contentful.Entry` + /// through an initialized client's JS bridge (mirroring + /// `OptimizationClientTests.testResolveOptimizedEntryPreservesFieldsWhenInitialized`, the + /// dict-based overload's equivalent test) and confirms fields survive and are readable via + /// `getField` on the returned `ResolvedEntry` — not just that the mapping step alone works. + @MainActor + func testInitializedClientRoundTripsFieldsThroughRealBridge() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "entry1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "page", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"title": "Hello", "slug": "hello-world"} + } + """) + let client = OptimizationClient() + let config = OptimizationConfig( + clientId: "test-client", + environment: "master", + api: OptimizationApiConfig( + experienceBaseUrl: "http://localhost:8000/experience/", + insightsBaseUrl: "http://localhost:8000/insights/" + ) + ) + try client.initialize(config: config) + + let result = client.resolveOptimizedEntry(baseline: entry) + + XCTAssertEqual(result.entry.getField("title"), "Hello", "the entry must actually round-trip through the JS bridge, not just fall back to the pre-mapped baseline") + XCTAssertEqual(result.entry.getField("slug"), "hello-world") + } +} From 574a318e6249706db3539fa3979ac23ebbc1060a Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Fri, 31 Jul 2026 00:37:00 +0200 Subject: [PATCH 06/21] refactor(swift): replace OptimizationEntryMapping/ResolvedEntry with CTEntry [NT-3808] Consolidate the Contentful.Entry <-> JSON mapping and the resolved-entry reader into a single CTEntry type backed by JSONValue, with all Contentful-type dispatch/encoding moved into small Codable envelope structs under a private CDA namespace. Also merges ResolvedContentfulOptimizedEntry into ResolvedOptimizedEntry (entry is now CTEntry for both the dict and Contentful.Entry overloads of resolveOptimizedEntry) and consolidates the test files that covered these types (CTEntryTests, OptimizationClientTests) accordingly. Co-Authored-By: Claude Sonnet 5 --- .../Contentful/CTEntry.swift | 521 +++++++ .../Contentful/OptimizationEntryMapping.swift | 182 --- .../Contentful/ResolvedEntry.swift | 80 - .../Core/JSONValue.swift | 7 + .../Core/OptimizationClient.swift | 37 +- .../Core/ResolvedOptimizedEntry.swift | 17 +- .../Views/OptimizedEntry.swift | 18 +- .../CTEntryTests.swift | 1294 +++++++++++++++++ .../OptimizationClientTests.swift | 133 +- .../OptimizationEntryMappingTests.swift | 981 ------------- .../OptimizedEntryContentfulInitTests.swift | 60 +- ...esolvedContentfulOptimizedEntryTests.swift | 136 -- .../ResolvedEntryTests.swift | 129 -- 13 files changed, 2012 insertions(+), 1583 deletions(-) create mode 100644 packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift delete mode 100644 packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/OptimizationEntryMapping.swift delete mode 100644 packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/ResolvedEntry.swift create mode 100644 packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/CTEntryTests.swift delete mode 100644 packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationEntryMappingTests.swift delete mode 100644 packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/ResolvedContentfulOptimizedEntryTests.swift delete mode 100644 packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/ResolvedEntryTests.swift diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift new file mode 100644 index 000000000..bedc57f62 --- /dev/null +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift @@ -0,0 +1,521 @@ +import Contentful +import Foundation + +/// Both directions of the `Contentful.Entry <-> JSON` boundary `OptimizedEntry` and +/// `OptimizationClient.resolveOptimizedEntry` need, wrapping the package's existing `JSONValue` +/// AST rather than a hand-built `[String: Any]` dictionary read back with `as?` casts: +/// +/// - **Encode**: `CTEntry(_: Contentful.Entry)` builds the `{sys, fields, metadata}` tree a +/// `Contentful.Entry` maps to, reconstructing the resolved-link JSON shape a raw CDA response +/// carried before the Delivery SDK decoded it. Every fixed-shape piece (`Sys`, a content-type +/// link, `Metadata`, a link stub, an asset envelope, a Structured Text node) is a small +/// `Codable` struct (`CDA`, below the type) with its own `static func from(...)` +/// factory, converted to `JSONValue` with a real `JSONEncoder` round trip — not a hand-assembled +/// `.object([...])` dictionary literal. `toJSON()` serializes the whole tree the same way. +/// - **Decode**: `init(any:)` wraps the resolver's already-parsed `[String: Any]` bridge output; +/// `init(json:)` decodes a raw JSON string via `JSONValue`'s `Codable` conformance and +/// `JSONDecoder`. The reader surface below (`id`, `localeCode`, `createdAt`, `updatedAt`, +/// `getField`) mirrors `Contentful.Entry`'s own readable surface, so resolved content reads +/// like a fetched entry instead of a raw map dug through with `as?` casts. +/// +/// `JSONValue` itself is the plain, Contentful-agnostic JSON tree (already used by +/// `EventPayloads`/`PreviewState`/the bridge); this type is the higher-level, entry-specific layer +/// on top — it delegates all actual parsing/serialization to `JSONValue`'s existing `Codable` +/// conformance and `JSONEncoder`/`JSONDecoder`, rather than reimplementing either. +/// +/// Ported from the reference implementation's simulation of this exact gap: +/// `examples/apps/travel-guide-ios/Sources/OptimizationAdapter.swift` +/// (`Entry.optimizationMap` + `ResolvedEntry`). +/// +/// `JSONValue.number` has no separate `Int` case — an `Int` field (`sys.revision`, an asset's +/// `file.details.size`, a plain integer field) round-trips as a `Double`. `getField`/`as? Int` +/// on such a field does not match; read it as `Double` (or `Int` via `Int(exactly:)` on the +/// `Double`) instead. Accepted for reuse of the package's one shared JSON AST rather than +/// introducing a second, `Int`-preserving JSON value type solely for this file. +/// +/// An `Entry` can't be rebuilt from a resolved value — `Contentful.Entry.init(from:)` needs a +/// `LocalizationContext` in `decoder.userInfo` that only a live CDA decode carries. This type +/// shares the resolved *shape* with `Entry`, not the type, on purpose: the reader surface below is +/// as far as that mirroring can go. Three `Entry` members have no counterpart here, by +/// construction rather than oversight: +/// - `type: ContentType?` — a full fetched content-type schema resource. The resolved tree only +/// ever carries the content type's `id` (`sys.contentType.sys.id`), never the schema +/// `ContentType` itself, and `ContentType` has no public initializer to reconstruct one from +/// that id alone. +/// - `currentlySelectedLocale: Locale` — a full locale object (code/name/fallback chain), which +/// the resolved tree never carries and `Locale` has no public initializer to fabricate. +/// - `metadata: Metadata?` / `setLocale(withCode:)` — `Metadata` has no public initializer, so +/// the resolved tree's `metadata.tags` can't be wrapped back into a real `Metadata` value, only +/// read via `getField("metadata")`. `setLocale` mutates which locale a live multi-locale decode +/// reads `fields` from; a resolved tree is already a single-locale snapshot with no such state +/// to mutate. +public struct CTEntry { + private let json: JSONValue + + private init(_ json: JSONValue) { + self.json = json + } + + // MARK: - Parsing + + init(json: String) throws { + guard let data = json.data(using: .utf8) else { + throw OptimizationError.configError("JSON string is not valid UTF-8") + } + self.json = try JSONDecoder().decode(JSONValue.self, from: data) + } + + /// Wraps an already-decoded `Any` value (e.g. `JSONSerialization`'s output, or a hand-built + /// `[String: Any]` at a call site that hasn't adopted this type). Throws rather than silently + /// treating an unrecognized value as absent — a caller that got something wrong here should + /// see a parse error, not a value that quietly reads back as missing everywhere `getField`/the + /// subscript check it. + init(any: Any) throws { + json = try Self.parseValue(from: any) + } + + private static func parseValue(from any: Any) throws -> JSONValue { + switch any { + case is NSNull: + return .null + case let bool as Bool: + return .bool(bool) + case let number as Int: + return .number(Double(number)) + case let number as Double: + return .number(number) + case let string as String: + return .string(string) + case let array as [Any]: + return .array(try array.map { try parseValue(from: $0) }) + case let object as [String: Any]: + return .object(try object.mapValues { try parseValue(from: $0) }) + default: + throw OptimizationError.configError("Unsupported value of type \(Swift.type(of: any)) in CTEntry(any:)") + } + } + + // MARK: - Serializing + + /// Serializes via `JSONValue`'s `Codable` conformance and `JSONEncoder` — a real encoder, not + /// `JSONSerialization.data(withJSONObject:)` over a `toFoundation()`-produced `Any`. + func toJSON() throws -> String { + let data = try JSONEncoder().encode(json) + guard let string = String(data: data, encoding: .utf8) else { + throw OptimizationError.configError("Failed to encode CTEntry as UTF-8 JSON") + } + return string + } + + /// The Foundation type (`String`, `Int`/`Double`, `Bool`, `NSNull`, `[Any]`, `[String: Any]`) + /// call sites still on `[String: Any]` (`OptimizedEntry`'s dict-based initializer, + /// `resolveOptimizedEntry(baseline: [String: Any])`) expect. + func toFoundation() -> Any { + json.toFoundation() + } + + // MARK: - Reading a resolved entry + + private subscript(key: String) -> CTEntry? { + guard case let .object(dict) = json, let value = dict[key] else { return nil } + return CTEntry(value) + } + + private var stringValue: String? { + json.stringValue + } + + /// The entry `sys.id` — stable across a variant swap, so it's safe for navigation. + public var id: String? { + self["sys"]?["id"]?.stringValue + } + + /// Mirrors `Entry.localeCode` (via `FlatResource`) — the code of the locale this resolved + /// variant's `fields` were read for. Absent on a raw CDA response fetched via `/sync` or the + /// wildcard `locale=*` query, same as on `Entry` itself. + public var localeCode: String? { + self["sys"]?["locale"]?.stringValue + } + + /// Mirrors `Entry.createdAt`. `nil` if the resolved tree never carried a `sys.createdAt` — a + /// resolver-synthesized entry (e.g. a variant assembled without a full CDA round trip) may + /// have no creation timestamp to report, same as `Entry.createdAt` returning `nil` for a + /// resource `select()`-queried without `sys`. + public var createdAt: Date? { + self["sys"]?["createdAt"]?.stringValue.flatMap { ISO8601DateFormatter().date(from: $0) } + } + + /// Mirrors `Entry.updatedAt`. See `createdAt` for why this can be `nil`. + public var updatedAt: Date? { + self["sys"]?["updatedAt"]?.stringValue.flatMap { ISO8601DateFormatter().date(from: $0) } + } + + /// A field's resolved value, or nil if absent. + public func getField(_ name: String) -> T? { + self["fields"]?[name]?.toFoundation() as? T + } + + /// Mirrors `Entry`'s `String` convenience subscript, which reads directly from `fields`. + public subscript(field key: String) -> String? { + getField(key) + } + + // MARK: - Encoding a `Contentful.Entry` + + /// Encodes a `contentful.swift` `Entry` into the `{sys, fields, metadata}` tree + /// `OptimizedEntry`/`resolveOptimizedEntry` expect. + /// + /// `JSONValue.encoded` can fail only on a non-finite `Double` (`NaN`/`±infinity`) reaching a + /// `CDA` struct's `Double` field. `sys`'s own fields are never `Double`, so this call can't + /// fail that way — `try!` here is a real invariant, not a swallowed error. Every *nested* + /// value that could carry a non-finite `Double` (a field via `CDA.Field.from`, a link via + /// `CDA.LinkValue.from`) is already funneled through one of those two, both of which drop + /// the offending value with `try?` rather than let a failure propagate up into this call — + /// losing an unused field beats losing personalization on the entry that holds it, the + /// policy `CDA.Field.from` documents for its own `default` case. + public init(_ entry: Contentful.Entry) { + json = try! JSONValue.encoded(CDA.EntryEnvelope.from(entry, ancestors: [])) + } +} + +// MARK: - Codable envelopes for the raw CDA response shapes + +/// Small `Codable` structs mirroring the fixed parts of a raw CDA response — `sys`, a +/// content-type link, `metadata`, an unresolved-link stub, an asset, a Structured Text node. Each +/// has a `static func from(...)` factory building it from the corresponding `contentful.swift` +/// type, and converts to `JSONValue` via `JSONValue.encoded(_:)` (a real `JSONEncoder` round trip +/// through `JSONValue`'s own `Codable` conformance) — never a hand-assembled dictionary literal. +private enum CDA { + /// The `{sys: {id, type: "Link", linkType}}` shape a back-edge or an unresolved link has in a + /// raw CDA response — the one stub shape every unresolved case (`Link.unresolved`, a back-edge + /// entry, an untyped `EntryDecodable`) emits. + struct LinkStub: Codable { + let sys: Sys + struct Sys: Codable { + let id: String + let type: String + let linkType: String + } + + init(id: String, linkType: String) { + sys = Sys(id: id, type: "Link", linkType: linkType) + } + } + + /// A link field's resolved value, one step before it becomes `JSONValue` — every case still + /// holds its own `Codable` envelope, encoded on demand via `encoded()`. + enum LinkValue { + case entry(EntryEnvelope) + case asset(AssetEnvelope) + case stub(LinkStub) + + func encoded() throws -> JSONValue { + switch self { + case let .entry(envelope): return try JSONValue.encoded(envelope) + case let .asset(envelope): return try JSONValue.encoded(envelope) + case let .stub(envelope): return try JSONValue.encoded(envelope) + } + } + + /// A link field, expanded into the linked resource when the Delivery SDK resolved it. + /// `ancestors` is the set of entry ids on the path from the root to here — see + /// `EntryEnvelope.from` for why a back-edge becomes `.stub` instead of recursing. + static func from(_ link: Contentful.Link, ancestors: Set) -> LinkValue { + switch link { + case let .entry(entry) where !ancestors.contains(entry.id): + return .entry(.from(entry, ancestors: ancestors)) + case let .asset(asset): + return .asset(.from(asset)) + case let .unresolved(sys): + return .stub(.init(id: sys.id, linkType: sys.linkType)) + // A back-edge, or a typed `EntryDecodable` this mapper never registers: emit the + // stub an unresolved link has in a raw CDA response. + case .entry, .entryDecodable: + return .stub(.init(id: link.id, linkType: "Entry")) + } + } + } + + /// One field value's resolved shape, one step before it becomes `JSONValue` — mirrors + /// `LinkValue` above: `Field.from` dispatches on the field's runtime type into one of these + /// cases with a plain type-checked `switch`; whether that particular value can actually + /// become `JSONValue` (a non-finite `Double` is the only failure mode anywhere in this tree) + /// is decided once, in `encoded()`, not per case at the dispatch site. + enum Field { + /// A leaf or already-recursed container `JSONValue` — `nil` for a value `from` has no + /// case for (dropped, per the type's documented "lose the field, not the entry" policy) + /// or a non-finite `Double`/`Location` coordinate. + case value(JSONValue?) + case link(LinkValue) + case richText(RichTextNodeEnvelope) + case fileMetadata(FileMetadataEnvelope) + case location(LocationEnvelope) + + /// `nil` if this value can't become `JSONValue` — a `.value(nil)` case, or a `Codable` + /// envelope whose encode failed on a non-finite `Double`. Every caller drops the field on + /// `nil` rather than losing the whole entry. + func encoded() -> JSONValue? { + switch self { + case let .value(value): return value + case let .link(linkValue): return try? linkValue.encoded() + case let .richText(envelope): return try? JSONValue.encoded(envelope) + case let .fileMetadata(envelope): return try? JSONValue.encoded(envelope) + case let .location(envelope): return try? JSONValue.encoded(envelope) + } + } + + /// One field value, reduced to something the bridge accepts — the resolver serializes + /// the whole tree before handing it to its JS bridge, and one illegal value fails the + /// entry outright (it falls back to baseline, logging rather than throwing). Anything + /// not listed here is dropped rather than risking that: losing an unused field beats + /// losing personalization on the entry that holds it. + static func from(_ value: Any, ancestors: Set) -> Field { + switch value { + case let link as Contentful.Link: + return .link(.from(link, ancestors: ancestors)) + case let richText as Contentful.RichTextDocument: + return .richText(.from(richText, ancestors: ancestors)) + // A field of Contentful type "Object" shaped exactly like a file metadata blob + // (`{fileName, contentType, url, details: {size, image: {width, height}}}`) decodes + // to this type — the generic `[String: Any]` decoder (`Decodable.swift`) tries it + // before falling back to a plain dictionary. Reuses `FileMetadataEnvelope.from`, the + // same factory a resolved asset link's `file` field goes through. + case let file as Contentful.Asset.FileMetadata: + return .fileMetadata(.from(file)) + case let array as [Any]: + return .value(.array(array.compactMap { from($0, ancestors: ancestors).encoded() })) + case let dictionary as [String: Any]: + return .value(.object(dictionary.compactMapValues { from($0, ancestors: ancestors).encoded() })) + case let location as Contentful.Location: + return .location(.from(location)) + case let date as Date: + return .value(.string(ISO8601DateFormatter().string(from: date))) + case let string as String: + return .value(.string(string)) + case let int as Int: + return .value(.number(Double(int))) + case let double as Double: + return .value(double.isFinite ? .number(double) : nil) + case let bool as Bool: + return .value(.bool(bool)) + default: + return .value(nil) + } + } + } + + struct Sys: Codable { + let id: String + let type: String + let contentType: ContentTypeLink + let createdAt: String? + let updatedAt: String? + let revision: Int? + let locale: String? + + struct ContentTypeLink: Codable { + let sys: LinkStub.Sys + } + + /// All of `createdAt`/`updatedAt`/`revision`/`locale` are independently optional on + /// `Contentful.Sys` itself (e.g. `locale` is absent on a `/sync` or wildcard-locale + /// response); `Codable`'s default `encodeIfPresent` behavior for `nil` optionals then + /// omits the key, matching the raw CDA response shape rather than emitting null. + static func from(_ sys: Contentful.Sys) -> Sys { + Sys( + id: sys.id, + type: "Entry", + contentType: .init(sys: .init(id: sys.contentTypeId ?? "", type: "Link", linkType: "ContentType")), + createdAt: sys.createdAt.map { ISO8601DateFormatter().string(from: $0) }, + updatedAt: sys.updatedAt.map { ISO8601DateFormatter().string(from: $0) }, + revision: sys.revision, + locale: sys.locale + ) + } + } + + struct EntryEnvelope: Codable { + let sys: Sys + let fields: [String: JSONValue] + let metadata: Metadata + + /// `ancestors` is the set of entry ids on the path from the root to here. The Delivery + /// SDK resolves links into shared object references, so a variant that links back to + /// its baseline is a real cycle in the object graph; recursing an entry already on the + /// current path would loop forever. Re-linking an ancestor emits an unresolved link stub + /// instead — the shape a back-edge has in a raw CDA response. Scoping to the current + /// path (not a global visited set) still expands diamonds: an entry reached by two + /// sibling branches expands fully in both. + static func from(_ entry: Contentful.Entry, ancestors: Set) -> EntryEnvelope { + let childAncestors = ancestors.union([entry.id]) + + let sys = Sys.from(entry.sys) + let fields = entry.fields.compactMapValues { Field.from($0, ancestors: childAncestors).encoded() } + + // Required, not cosmetic: the resolver's entry guard rejects any entry without a + // `metadata` object, and a rejected baseline is never given its variant. A raw CDA + // response carries it on every entry; `Entry` keeps it out of `fields`, so this has + // to put it back. `concepts` is always empty — `contentful.swift`'s `Metadata` + // models only `tags`, so the SDK gives us nothing else to forward. + let metadata = Metadata( + tags: (entry.metadata?.tags ?? []).compactMap { try? LinkValue.from($0, ancestors: childAncestors).encoded() }, + concepts: [] + ) + + return EntryEnvelope(sys: sys, fields: fields, metadata: metadata) + } + } + + struct Metadata: Codable { + let tags: [JSONValue] + let concepts: [JSONValue] + } + + struct AssetEnvelope: Codable { + let sys: AssetSys + let fields: AssetFields + + struct AssetSys: Codable { + let id: String + let type: String + } + + struct AssetFields: Codable { + let title: String + let description: String? + let file: FileMetadataEnvelope + } + + static func from(_ asset: Contentful.Asset) -> AssetEnvelope { + AssetEnvelope( + sys: .init(id: asset.id, type: "Asset"), + fields: .init( + title: asset.title ?? "", + description: asset.description, + file: asset.file.map(FileMetadataEnvelope.from) ?? FileMetadataEnvelope( + fileName: nil, contentType: nil, details: nil, url: asset.urlString ?? "" + ) + ) + ) + } + } + + /// An asset's `file` metadata, reduced to the raw CDA response shape + /// (`{fileName, contentType, details: {size, image: {width, height}}, url}`) — the same shape + /// whether it arrived via a resolved asset link (`AssetEnvelope.from`) or as a directly + /// decoded field value (`jsonValue`'s `Asset.FileMetadata` case). `details.image` is only + /// present for image files. + struct FileMetadataEnvelope: Codable { + let fileName: String? + let contentType: String? + let details: Details? + let url: String + + struct Details: Codable { + let size: Int + let image: ImageInfo? + + struct ImageInfo: Codable { + let width: Double + let height: Double + } + } + + static func from(_ file: Contentful.Asset.FileMetadata) -> FileMetadataEnvelope { + FileMetadataEnvelope( + fileName: file.fileName, + contentType: file.contentType, + details: .init( + size: file.details?.size ?? 0, + image: file.details?.imageInfo.map { .init(width: $0.width, height: $0.height) } + ), + url: file.url?.absoluteString ?? "" + ) + } + } + + /// A `Location` field, reduced to the raw CDA response shape (`{lat, lon}`). + struct LocationEnvelope: Codable { + let lat: Double + let lon: Double + + static func from(_ location: Contentful.Location) -> LocationEnvelope { + LocationEnvelope(lat: location.latitude, lon: location.longitude) + } + } + + /// One Structured Text node, reduced to the same `{nodeType, data, content}` shape a raw CDA + /// response carries. + struct RichTextNodeEnvelope: Codable { + let nodeType: String + var value: String? + var marks: [Mark]? + var data: NodeData + var content: [RichTextNodeEnvelope]? + + struct Mark: Codable { let type: String } + + struct NodeData: Codable { + var uri: String? + var target: JSONValue? + + init(uri: String? = nil, target: JSONValue? = nil) { + self.uri = uri + self.target = target + } + } + + init(nodeType: String, value: String? = nil, marks: [Mark]? = nil, data: NodeData = NodeData(), content: [RichTextNodeEnvelope]? = nil) { + self.nodeType = nodeType + self.value = value + self.marks = marks + self.data = data + self.content = content + } + + /// `ResourceLinkBlock`/`ResourceLinkInline` (embedded entries and assets — both `-block` + /// and `-inline` variants share these two Swift types across all five + /// `embedded-*`/`*-hyperlink` node types) must be matched before the generic + /// `RecursiveNode` case, since both conform to it; falling through to the generic case + /// would silently drop the embedded resource's resolved-or-unresolved link entirely; + /// ordering matters here. + static func from(_ node: Contentful.Node, ancestors: Set) -> RichTextNodeEnvelope { + switch node { + case let resourceLink as Contentful.ResourceLinkBlock: + return RichTextNodeEnvelope( + nodeType: resourceLink.nodeType.rawValue, + data: .init(target: try? LinkValue.from(resourceLink.data.target, ancestors: ancestors).encoded()), + content: resourceLink.content.map { from($0, ancestors: ancestors) } + ) + case let resourceLink as Contentful.ResourceLinkInline: + return RichTextNodeEnvelope( + nodeType: resourceLink.nodeType.rawValue, + data: .init(target: try? LinkValue.from(resourceLink.data.target, ancestors: ancestors).encoded()), + content: resourceLink.content.map { from($0, ancestors: ancestors) } + ) + case let hyperlink as Contentful.Hyperlink: + return RichTextNodeEnvelope( + nodeType: hyperlink.nodeType.rawValue, + data: .init(uri: hyperlink.data.uri), + content: hyperlink.content.map { from($0, ancestors: ancestors) } + ) + case let text as Contentful.Text: + return RichTextNodeEnvelope( + nodeType: text.nodeType.rawValue, + value: text.value, + marks: text.marks.map { .init(type: $0.type.rawValue) } + ) + // Table/TableRow/TableRowHeaderCell/TableRowCell/Paragraph/Heading/BlockQuote/ + // HorizontalRule/OrderedList/UnorderedList/ListItem, and the top-level + // RichTextDocument itself — all plain containers with no data beyond their children. + case let recursive as Contentful.RecursiveNode: + return RichTextNodeEnvelope( + nodeType: recursive.nodeType.rawValue, + content: recursive.content.map { from($0, ancestors: ancestors) } + ) + default: + return RichTextNodeEnvelope(nodeType: node.nodeType.rawValue) + } + } + } +} diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/OptimizationEntryMapping.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/OptimizationEntryMapping.swift deleted file mode 100644 index 5525a4022..000000000 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/OptimizationEntryMapping.swift +++ /dev/null @@ -1,182 +0,0 @@ -import Contentful -import Foundation - -/// Maps a `contentful.swift` `Entry` into the `{sys, fields, metadata}` map -/// `OptimizedEntry` expects, reconstructing the resolved-link JSON shape the raw CDA response -/// carried before the Delivery SDK decoded it. -/// -/// Ported from the reference implementation's in-app simulation of this exact gap: -/// `examples/apps/travel-guide-ios/Sources/OptimizationAdapter.swift` (`Entry.optimizationMap`). -enum OptimizationEntryMapping { - static func toOptimizationEntry(_ entry: Contentful.Entry) -> [String: Any] { - entryMap(entry, ancestors: []) - } - - /// `ancestors` is the set of entry ids on the path from the root to here. The Delivery SDK - /// resolves links into shared object references, so a variant that links back to its - /// baseline is a real cycle in the object graph; recursing an entry already on the current - /// path would loop forever. Re-linking an ancestor emits an unresolved link stub instead — - /// the shape a back-edge has in a raw CDA response. Scoping to the current path (not a - /// global visited set) still expands diamonds: an entry reached by two sibling branches - /// expands fully in both. - private static func entryMap(_ entry: Contentful.Entry, ancestors: Set) -> [String: Any] { - let childAncestors = ancestors.union([entry.id]) - - var sys: [String: Any] = [ - "id": entry.id, - "type": "Entry", - "contentType": [ - "sys": ["id": entry.sys.contentTypeId ?? "", "type": "Link", "linkType": "ContentType"], - ], - ] - // Carried through so `ResolvedEntry` can mirror `Entry.createdAt`/`updatedAt`/`localeCode` - // from the resolved output, not just `id`. All four are independently optional on `Sys` - // itself (e.g. `locale` is absent on a `/sync` or wildcard-locale response), so each is - // added only when present, matching the raw CDA response shape rather than emitting null. - if let createdAt = entry.sys.createdAt { - sys["createdAt"] = ISO8601DateFormatter().string(from: createdAt) - } - if let updatedAt = entry.sys.updatedAt { - sys["updatedAt"] = ISO8601DateFormatter().string(from: updatedAt) - } - if let revision = entry.sys.revision { - sys["revision"] = revision - } - if let locale = entry.sys.locale { - sys["locale"] = locale - } - - return [ - "sys": sys, - "fields": entry.fields.compactMapValues { jsonValue($0, ancestors: childAncestors) }, - // Required, not cosmetic: the resolver's entry guard rejects any entry without a - // `metadata` object, and a rejected baseline is never given its variant. A raw CDA - // response carries it on every entry; `Entry` keeps it out of `fields`, so the - // mapper has to put it back. `concepts` is always empty — `contentful.swift`'s - // `Metadata` models only `tags`, so the SDK gives us nothing else to forward. - "metadata": [ - "tags": (entry.metadata?.tags ?? []).map { jsonLink($0, ancestors: childAncestors) }, - "concepts": [], - ], - ] - } - - /// One field value, reduced to something `JSONSerialization` accepts — the resolver - /// serializes the whole map before handing it to its JS bridge, and one illegal value fails - /// the entry outright (it falls back to baseline, logging rather than throwing). Anything - /// not listed here is dropped rather than risking that: losing an unused field beats losing - /// personalization on the entry that holds it. - private static func jsonValue(_ value: Any, ancestors: Set) -> Any? { - switch value { - case let link as Contentful.Link: - return jsonLink(link, ancestors: ancestors) - case let richText as Contentful.RichTextDocument: - return jsonNode(richText, ancestors: ancestors) - // A field of Contentful type "Object" shaped exactly like a file metadata blob - // (`{fileName, contentType, url, details: {size, image: {width, height}}}`) decodes to - // this type — the generic `[String: Any]` decoder (`Decodable.swift`) tries it before - // falling back to a plain dictionary. Reuses `jsonFileMetadata`, the same helper a - // resolved asset link's `file` field goes through. - case let file as Contentful.Asset.FileMetadata: - return jsonFileMetadata(file) - case let array as [Any]: - return array.compactMap { jsonValue($0, ancestors: ancestors) } - case let dictionary as [String: Any]: - return dictionary.compactMapValues { jsonValue($0, ancestors: ancestors) } - case let location as Contentful.Location: - return ["lat": location.latitude, "lon": location.longitude] - case let date as Date: - return ISO8601DateFormatter().string(from: date) - case is String, is Int, is Double, is Bool: - return value - default: - return nil - } - } - - /// One Structured Text node, reduced to the same `{nodeType, data, content}` shape a raw CDA - /// response carries. `ResourceLinkBlock`/`ResourceLinkInline` (embedded entries and assets — - /// both `-block` and `-inline` variants share these two Swift types across all five - /// `embedded-*`/`*-hyperlink` node types) must be matched before the generic `RecursiveNode` - /// case, since both conform to it; falling through to the generic case would silently drop - /// the embedded resource's resolved-or-unresolved link entirely; ordering matters here. - private static func jsonNode(_ node: Contentful.Node, ancestors: Set) -> [String: Any] { - switch node { - case let resourceLink as Contentful.ResourceLinkBlock: - return [ - "nodeType": resourceLink.nodeType.rawValue, - "data": ["target": jsonLink(resourceLink.data.target, ancestors: ancestors)], - "content": resourceLink.content.map { jsonNode($0, ancestors: ancestors) }, - ] - case let resourceLink as Contentful.ResourceLinkInline: - return [ - "nodeType": resourceLink.nodeType.rawValue, - "data": ["target": jsonLink(resourceLink.data.target, ancestors: ancestors)], - "content": resourceLink.content.map { jsonNode($0, ancestors: ancestors) }, - ] - case let hyperlink as Contentful.Hyperlink: - return [ - "nodeType": hyperlink.nodeType.rawValue, - "data": ["uri": hyperlink.data.uri], - "content": hyperlink.content.map { jsonNode($0, ancestors: ancestors) }, - ] - case let text as Contentful.Text: - return [ - "nodeType": text.nodeType.rawValue, - "value": text.value, - "marks": text.marks.map { ["type": $0.type.rawValue] }, - "data": [String: Any](), - ] - // Table/TableRow/TableRowHeaderCell/TableRowCell/Paragraph/Heading/BlockQuote/ - // HorizontalRule/OrderedList/UnorderedList/ListItem, and the top-level - // RichTextDocument itself — all plain containers with no data beyond their children. - case let recursive as Contentful.RecursiveNode: - return [ - "nodeType": recursive.nodeType.rawValue, - "data": [String: Any](), - "content": recursive.content.map { jsonNode($0, ancestors: ancestors) }, - ] - default: - return ["nodeType": node.nodeType.rawValue, "data": [String: Any](), "content": [Any]()] - } - } - - /// A link field, expanded into the linked resource when the Delivery SDK resolved it. - private static func jsonLink(_ link: Contentful.Link, ancestors: Set) -> [String: Any] { - switch link { - case let .entry(entry) where !ancestors.contains(entry.id): - return entryMap(entry, ancestors: ancestors) - case let .asset(asset): - var fields: [String: Any] = ["title": asset.title ?? ""] - if let description = asset.description { - fields["description"] = description - } - fields["file"] = asset.file.map(jsonFileMetadata) ?? ["url": asset.urlString ?? ""] - return ["sys": ["id": asset.id, "type": "Asset"], "fields": fields] - case let .unresolved(sys): - return ["sys": ["id": sys.id, "type": sys.type, "linkType": sys.linkType]] - // A back-edge, or a typed `EntryDecodable` this mapper never registers: emit the stub an - // unresolved link has in a raw CDA response. - case .entry, .entryDecodable: - return ["sys": ["id": link.id, "type": "Link", "linkType": "Entry"]] - } - } - - /// An asset's `file` metadata, reduced to the raw CDA response shape - /// (`{fileName, contentType, details: {size, image: {width, height}}, url}`) — the same shape - /// whether it arrived via a resolved asset link (`jsonLink`'s `.asset` case) or as a directly - /// decoded field value (`jsonValue`'s `Asset.FileMetadata` case, for a custom "Object" field - /// shaped like one). `details.image` is only present for image files. - private static func jsonFileMetadata(_ file: Contentful.Asset.FileMetadata) -> [String: Any] { - var details: [String: Any] = ["size": file.details?.size ?? 0] - if let imageInfo = file.details?.imageInfo { - details["image"] = ["width": imageInfo.width, "height": imageInfo.height] - } - return [ - "fileName": file.fileName, - "contentType": file.contentType, - "details": details, - "url": file.url?.absoluteString ?? "", - ] - } -} diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/ResolvedEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/ResolvedEntry.swift deleted file mode 100644 index c5e242be2..000000000 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/ResolvedEntry.swift +++ /dev/null @@ -1,80 +0,0 @@ -import Foundation - -/// The resolver's output read through the surface a fetched `Contentful.Entry` already has — -/// `getField` mirrors `ContentfulClient.getField`. A resolved variant reads like a fetched entry -/// instead of a raw `{sys, fields}` map to dig through by hand with `as?` casts. -/// -/// Ported from the reference implementation's showcase of this gap: -/// `examples/apps/travel-guide-ios/Sources/OptimizationAdapter.swift` (`ResolvedEntry`). -/// -/// Only the resolver side needs this — a fetched `Contentful.Entry` is already read this way. The -/// two can't share a type: an `Entry` can't be rebuilt from the resolver's map, since its -/// initializer needs a localization context only a live decode carries. They share the *shape*, -/// not the type, so app code reads both the same way without one impersonating the other. -/// -/// Mirrors every `Entry`/`FlatResource` member that a raw resolved map can actually carry: -/// `id`, `localeCode`, `createdAt`, `updatedAt`, `fields` (via `getField`), and the `String`/`Int` -/// subscripts. Three `Entry` members have no counterpart here, by construction rather than -/// oversight: -/// - `type: ContentType?` — a full fetched content-type schema resource. The resolved map only -/// ever carries the content type's `id` (`sys.contentType.sys.id`, see `OptimizationEntryMapping`), -/// never the schema `ContentType` itself, and `ContentType` has no public initializer to -/// reconstruct one from that id alone. -/// - `currentlySelectedLocale: Locale` — a full locale object (code/name/fallback chain), which -/// the resolved map never carries and `Locale` has no public initializer to fabricate. -/// - `metadata: Metadata?` / `setLocale(withCode:)` — `Metadata` has no public initializer, so -/// the resolved map's `metadata.tags` can't be wrapped back into a real `Metadata` value, only -/// into a dict `getField("metadata")` can still read. `setLocale` mutates which locale a live -/// multi-locale decode reads `fields` from; a resolved map is already a single-locale snapshot -/// with no such state to mutate. -public struct ResolvedEntry { - private let raw: [String: Any] - - public init(_ raw: [String: Any]) { - self.raw = raw - } - - private var sys: [String: Any]? { - raw["sys"] as? [String: Any] - } - - /// The entry `sys.id` — stable across a variant swap, so it's safe for navigation. - public var id: String? { - sys?["id"] as? String - } - - /// Mirrors `Entry.localeCode` (via `FlatResource`) — the code of the locale this resolved - /// variant's `fields` were read for. Absent on a raw CDA response fetched via `/sync` or the - /// wildcard `locale=*` query, same as on `Entry` itself. - public var localeCode: String? { - sys?["locale"] as? String - } - - /// Mirrors `Entry.createdAt`. `nil` if the resolved map never carried a `sys.createdAt` — a - /// resolver-synthesized entry (e.g. a variant assembled without a full CDA round trip) may - /// have no creation timestamp to report, same as `Entry.createdAt` returning `nil` for a - /// resource `select()`-queried without `sys`. - public var createdAt: Date? { - (sys?["createdAt"] as? String).flatMap { ISO8601DateFormatter().date(from: $0) } - } - - /// Mirrors `Entry.updatedAt`. See `createdAt` for why this can be `nil`. - public var updatedAt: Date? { - (sys?["updatedAt"] as? String).flatMap { ISO8601DateFormatter().date(from: $0) } - } - - /// A field's resolved value, or nil if absent. - public func getField(_ name: String) -> T? { - (raw["fields"] as? [String: Any])?[name] as? T - } - - /// Mirrors `Entry`'s `String` convenience subscript, which reads directly from `fields`. - public subscript(key: String) -> String? { - getField(key) - } - - /// Mirrors `Entry`'s `Int` convenience subscript, which reads directly from `fields`. - public subscript(key: String) -> Int? { - getField(key) - } -} diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/JSONValue.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/JSONValue.swift index 73ac68693..cdf990f58 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/JSONValue.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/JSONValue.swift @@ -50,6 +50,13 @@ extension JSONValue: Codable { case .object(let v): try container.encode(v) } } + + /// Encodes any `Encodable` value into `JSONValue` via a real `JSONEncoder` -> `JSONDecoder` + /// round trip, rather than a hand-assembled dictionary literal. + public static func encoded(_ value: some Encodable) throws -> JSONValue { + let data = try JSONEncoder().encode(value) + return try JSONDecoder().decode(JSONValue.self, from: data) + } } // MARK: - Accessors diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift index c2bc8f4a6..6716f170b 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift @@ -357,9 +357,14 @@ public final class OptimizationClient: ObservableObject { baseline: [String: Any], selectedOptimizations: [[String: Any]]? = nil ) -> ResolvedOptimizedEntry { + // `baseline` is caller-supplied and not guaranteed JSON-safe, so `CTEntry(any:)` can + // itself throw on a value it doesn't recognize; every fallback path below returns + // `baseline` unchanged and falls back further, to an empty entry, if even that fails. + let baselineEntry = (try? CTEntry(any: baseline)) ?? (try! CTEntry(any: [String: Any]())) + guard isInitialized else { return ResolvedOptimizedEntry( - entry: baseline, + entry: baselineEntry, selectedOptimization: nil, optimizationContextId: nil ) @@ -382,7 +387,7 @@ public final class OptimizationClient: ObservableObject { let entryId = (baseline["sys"] as? [String: Any])?["id"] as? String ?? "unknown" log.warning("[resolveOptimizedEntry] Failed to parse bridge result for entry \(entryId)") return ResolvedOptimizedEntry( - entry: baseline, + entry: baselineEntry, selectedOptimization: nil, optimizationContextId: nil ) @@ -392,7 +397,7 @@ public final class OptimizationClient: ObservableObject { let selectedOptimization = dict["selectedOptimization"] as? [String: Any] let optimizationContextId = dict["optimizationContextId"] as? String return ResolvedOptimizedEntry( - entry: entry, + entry: (try? CTEntry(any: entry)) ?? (baselineEntry), selectedOptimization: selectedOptimization, optimizationContextId: optimizationContextId ) @@ -400,7 +405,7 @@ public final class OptimizationClient: ObservableObject { let entryId = (baseline["sys"] as? [String: Any])?["id"] as? String ?? "unknown" log.error("[resolveOptimizedEntry] Serialization error for entry \(entryId): \(error.localizedDescription)") return ResolvedOptimizedEntry( - entry: baseline, + entry: baselineEntry, selectedOptimization: nil, optimizationContextId: nil ) @@ -408,23 +413,19 @@ public final class OptimizationClient: ObservableObject { } /// `Contentful.Entry` overload of `resolveOptimizedEntry(baseline:selectedOptimizations:)` — - /// maps `baseline` through `OptimizationEntryMapping` once, so callers stop hand-writing the - /// `Entry -> {sys, fields, metadata}` mapping outside of `OptimizedEntry`'s view initializer. - /// Delegates to the dict-based overload above, so it inherits the same fail-soft behavior: not - /// initialized, a serialization error, or an unparseable bridge result all fall back to the - /// mapped baseline with `selectedOptimization`/`optimizationContextId` nil, logging rather than - /// throwing. + /// encodes `baseline` through `CTEntry(_: Contentful.Entry)` once, so callers stop + /// hand-writing the `Entry -> {sys, fields, metadata}` mapping outside of `OptimizedEntry`'s + /// view initializer. Delegates to the dict-based overload above (via `toFoundation()`), so it + /// inherits the same fail-soft behavior: not initialized, a serialization error, or an + /// unparseable bridge result all fall back to the mapped baseline with + /// `selectedOptimization`/`optimizationContextId` nil, logging rather than throwing. public func resolveOptimizedEntry( baseline: Contentful.Entry, selectedOptimizations: [[String: Any]]? = nil - ) -> ResolvedContentfulOptimizedEntry { - let mappedBaseline = OptimizationEntryMapping.toOptimizationEntry(baseline) - let result = resolveOptimizedEntry(baseline: mappedBaseline, selectedOptimizations: selectedOptimizations) - return ResolvedContentfulOptimizedEntry( - entry: ResolvedEntry(result.entry), - selectedOptimization: result.selectedOptimization, - optimizationContextId: result.optimizationContextId - ) + ) -> ResolvedOptimizedEntry { + let mappedBaseline = CTEntry(baseline) + let dictBaseline = mappedBaseline.toFoundation() as? [String: Any] ?? [:] + return resolveOptimizedEntry(baseline: dictBaseline, selectedOptimizations: selectedOptimizations) } /// Resolve a merge-tag entry's display value against the current profile. diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/ResolvedOptimizedEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/ResolvedOptimizedEntry.swift index 0732c81b8..6ad7c5c29 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/ResolvedOptimizedEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/ResolvedOptimizedEntry.swift @@ -1,19 +1,10 @@ import Foundation -/// The result of resolving an optimized entry. +/// The result of resolving an optimized entry. `entry` is a `CTEntry` — `getField`, not `as?` +/// casts on a raw map — regardless of whether the baseline passed to `resolveOptimizedEntry` was +/// a raw `[String: Any]` or a `Contentful.Entry`; both overloads wrap their result the same way. public struct ResolvedOptimizedEntry { - public let entry: [String: Any] - public let selectedOptimization: [String: Any]? - public let optimizationContextId: String? -} - -/// The result of resolving an optimized entry that was passed in as a `Contentful.Entry` — the -/// `Contentful.Entry`-typed counterpart to `ResolvedOptimizedEntry`. `entry` is a `ResolvedEntry` -/// (typed `getField` reads) rather than a raw `[String: Any]`, matching how -/// `OptimizedEntry(entry: Contentful.Entry, ...)` hands its render closure a `ResolvedEntry` -/// instead of a dict. -public struct ResolvedContentfulOptimizedEntry { - public let entry: ResolvedEntry + public let entry: CTEntry public let selectedOptimization: [String: Any]? public let optimizationContextId: String? } diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift index 743c2108f..25b040ee2 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift @@ -59,10 +59,10 @@ public struct OptimizedEntry: View { self.content = content } - /// Accepts a `contentful.swift` `Entry` directly, mapping it to the `{sys, fields, metadata}` - /// shape the resolver expects (see `OptimizationEntryMapping`) and handing the resolved - /// variant back through `ResolvedEntry` — `getField`, not `as?` casts on a raw map. The - /// wrapping happens once, here, at construction — `content` itself stays dict-shaped + /// Accepts a `contentful.swift` `Entry` directly, encoding it to the `{sys, fields, metadata}` + /// shape the resolver expects (see `CTEntry(_: Contentful.Entry)`) and handing the resolved + /// variant back through `CTEntry` — `getField`, not `as?` casts on a raw map. + /// The encoding happens once, here, at construction — `content` itself stays dict-shaped /// internally so `body` doesn't need to know which initializer built this instance. public init( entry: Contentful.Entry, @@ -74,9 +74,9 @@ public struct OptimizedEntry: View { trackTaps: Bool? = nil, accessibilityIdentifier: String? = nil, onTap: (([String: Any]) -> Void)? = nil, - @ViewBuilder content: @escaping (ResolvedEntry) -> Content + @ViewBuilder content: @escaping (CTEntry) -> Content ) { - self.entry = OptimizationEntryMapping.toOptimizationEntry(entry) + self.entry = CTEntry(entry).toFoundation() as? [String: Any] ?? [:] self.dwellTimeMs = dwellTimeMs self.minVisibleRatio = minVisibleRatio self.viewDurationUpdateIntervalMs = viewDurationUpdateIntervalMs @@ -85,7 +85,7 @@ public struct OptimizedEntry: View { self.trackTaps = trackTaps self.accessibilityIdentifier = accessibilityIdentifier self.onTap = onTap - self.content = { raw in content(ResolvedEntry(raw)) } + self.content = { raw in content((try? CTEntry(any: raw)) ?? CTEntry(entry)) } } private var isOptimized: Bool { @@ -125,14 +125,14 @@ public struct OptimizedEntry: View { ) } else { return ResolvedOptimizedEntry( - entry: entry, + entry: (try? CTEntry(any: entry)) ?? (try! CTEntry(any: [String: Any]())), selectedOptimization: nil, optimizationContextId: nil ) } }() - content(result.entry) + content(result.entry.toFoundation() as? [String: Any] ?? [:]) .modifier(ViewTrackingModifier( entry: entry, optimizationContextId: result.optimizationContextId, diff --git a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/CTEntryTests.swift b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/CTEntryTests.swift new file mode 100644 index 000000000..207cba334 --- /dev/null +++ b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/CTEntryTests.swift @@ -0,0 +1,1294 @@ +@testable import Contentful +@testable import ContentfulOptimization +import Foundation +import XCTest + +/// Compares two JSON strings by their parsed `JSONValue` tree, not raw text — so +/// whitespace/key-order differences don't cause a false mismatch. +func assertJSONEqual( + _ lhs: String, + _ rhs: String, + file: StaticString = #filePath, + line: UInt = #line +) throws { + XCTAssertEqual( + try JSONDecoder().decode(JSONValue.self, from: Data(lhs.utf8)), + try JSONDecoder().decode(JSONValue.self, from: Data(rhs.utf8)), + file: file, + line: line + ) +} + +/// Direct unit tests for `CTEntry` — both directions of the `Contentful.Entry <-> JSON` +/// boundary it owns, in one file since both test the same type: +/// +/// - **Encoding** (`init(_: Contentful.Entry)`): verified against real `contentful.swift` +/// decodes — not fabricated dicts — so the encoding is checked against actual SDK object +/// shapes rather than assumptions about them. Mirrors the scenarios `OptimizationAdapter.swift` +/// (`examples/apps/travel-guide-ios`) exists to cover: link resolution, the metadata +/// requirement, asset mapping, and the ancestor-cycle guard. Every test compares whole +/// `JSONValue` trees via `assertJSONEqual`, never a field-by-field `as?` dig — that catches an +/// unexpected extra/missing key anywhere in the tree, not just on the fields a test happens to +/// name. Where the mapper is the identity on its input (no links, no rich text, nothing to +/// expand), the test has one JSON literal and asserts the mapped output equals it unchanged. +/// Where the mapper transforms its input (a link expands, a URL gets a scheme, a stub emits), +/// the test has an `input` literal and a separately written `expected` literal, and asserts the +/// mapped output equals `expected` — never the identity of `input`. +/// - **Reading** (the `getField`/`id`/`localeCode`/`createdAt`/`updatedAt`/subscript surface): +/// the happy path is already exercised indirectly by every encoding test above (each reads the +/// mapped `CTEntry` back via `toJSON`); the tests near the bottom of this file cover the +/// absent/wrong-type cases (a resolver output missing `sys`/`fields`, or a field read back as +/// the wrong type) that the encoding tests have no reason to exercise. +final class CTEntryTests: XCTestCase { + private static let localizationContext: LocalizationContext = { + let localeJSON = Data(""" + {"code":"en-US","default":true,"name":"English","fallbackCode":null} + """.utf8) + let locale = try! JSONDecoder.withoutLocalizationContext().decode(Contentful.Locale.self, from: localeJSON) + return LocalizationContext(locales: [locale])! + }() + + private func decodeEntry(_ json: String) throws -> Entry { + let decoder = JSONDecoder.withoutLocalizationContext() + decoder.update(with: Self.localizationContext) + decoder.userInfo[.init(rawValue: "linkResolverContext")!] = NSObject() + return try decoder.decode(Entry.self, from: Data(json.utf8)) + } + + /// The common shape for a test where the mapper is the identity on its input: decode + /// `input`, map it, and assert the mapped output equals `input` unchanged (parsed-JSON + /// comparison, not raw text — see `assertJSONEqual`). + private func assertIdentity(_ input: String, file: StaticString = #filePath, line: UInt = #line) throws { + let entry = try decodeEntry(input) + let result = try CTEntry(entry).toJSON() + try assertJSONEqual(input, result, file: file, line: line) + } + + // MARK: - Baseline shape + + /// Identity: a baseline entry with no links/rich text/assets maps to the same *parsed JSON* + /// as the raw CDA response that produced it — `metadata` included in the literal itself, + /// since `from(_:)` always adds it (see `testAlwaysIncludesMetadataEvenWithNoTags` below, + /// where that's the transformation under test). + func testMapsSysAndContentType() throws { + try assertIdentity(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "landingPage", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"title": "Hello"}, + "metadata": {"tags": [], "concepts": []} + } + """) + } + + /// Identity: proves `sys.createdAt`/`updatedAt`/`revision`/`locale` all survive the round + /// trip unchanged — `ResolvedEntry.createdAt`/`updatedAt`/`localeCode` (see + /// `ResolvedEntryTests`) can only mirror real values if `entryMap`'s `sys` block actually + /// carries them. + func testMapsSysTimestampsRevisionAndLocale() throws { + try assertIdentity(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", "revision": 3, + "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-06-15T12:30:00Z", + "contentType": {"sys": {"id": "landingPage", "type": "Link", "linkType": "ContentType"}}}, + "fields": {}, + "metadata": {"tags": [], "concepts": []} + } + """) + } + + /// Identity: a `/sync` response, or one fetched with the wildcard `locale=*` query, carries + /// no `sys.locale` — this proves the mapper omits the key entirely rather than emitting + /// `locale: null`, matching `Entry.sys.locale`'s own optionality. + func testOmitsSysLocaleWhenAbsentFromSource() throws { + try assertIdentity(""" + { + "sys": {"id": "e1", "type": "Entry", + "contentType": {"sys": {"id": "landingPage", "type": "Link", "linkType": "ContentType"}}}, + "fields": {}, + "metadata": {"tags": [], "concepts": []} + } + """) + } + + // MARK: - The silent metadata requirement + + /// Transformation: the resolver's entry guard (`isResolvedContentfulEntry` in + /// `packages/universal/api-schemas/src/contentful/typeGuards.ts`) rejects any entry without a + /// `metadata` object — silently, no error, the entry is just treated as non-optimized. + /// `Entry` keeps `metadata` off `fields` (it's a sys-level sibling), so an entry with zero + /// tags still needs an explicit empty `metadata.tags`/`concepts` added by the mapper, not an + /// absent key — the raw `input` here has no `metadata` key at all. + func testAlwaysIncludesMetadataEvenWithNoTags() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "landingPage", "type": "Link", "linkType": "ContentType"}}}, + "fields": {} + } + """) + + let result = try CTEntry(entry).toJSON() + + try assertJSONEqual(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "landingPage", "type": "Link", "linkType": "ContentType"}}}, + "fields": {}, + "metadata": {"tags": [], "concepts": []} + } + """, result) + } + + /// Identity: an existing tag link round-trips into `metadata.tags` unchanged. + func testMapsMetadataTags() throws { + try assertIdentity(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "landingPage", "type": "Link", "linkType": "ContentType"}}}, + "fields": {}, + "metadata": {"tags": [{"sys": {"id": "tag1", "linkType": "Tag", "type": "Link"}}], "concepts": []} + } + """) + } + + // MARK: - Link resolution + + /// Identity: an entry link the Delivery SDK could not resolve stays exactly the unresolved + /// stub shape it arrived as. + func testUnresolvedLinkEmitsStub() throws { + try assertIdentity(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "landingPage", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"related": {"sys": {"id": "e2", "type": "Link", "linkType": "Entry"}}}, + "metadata": {"tags": [], "concepts": []} + } + """) + } + + /// Transformation: a resolved entry link expands inline into the full nested entry + /// (`sys`/`fields`/`metadata`) instead of staying a link stub. + func testResolvedEntryLinkExpandsInline() throws { + let parent = try decodeEntry(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"child": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}} + } + """) + let child = try decodeEntry(""" + { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "child entry"} + } + """) + + let entriesMap = ["parent": parent, "child-1": child] + parent.resolveLinks(against: entriesMap, and: [:]) + + let result = try CTEntry(parent).toJSON() + + // A resolved entry link's metadata must also be present, for the same reason as the root. + try assertJSONEqual(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"child": { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "child entry"}, + "metadata": {"tags": [], "concepts": []} + }}, + "metadata": {"tags": [], "concepts": []} + } + """, result) + } + + /// Transformation: the Delivery SDK resolves links into shared object references, so a + /// variant that links back to its baseline is a real cycle in the object graph, not just a + /// data shape to defend against defensively. Recursing an already-visited entry would loop + /// forever; the mapper emits an unresolved-link stub for the back-edge instead — the whole + /// tree is asserted, not just the back-edge, so the expansion up to that point is checked too. + func testSelfReferencingLinkDoesNotRecurseInfinitely() throws { + let parent = try decodeEntry(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"child": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}} + } + """) + let child = try decodeEntry(""" + { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"backToParent": {"sys": {"id": "parent", "type": "Link", "linkType": "Entry"}}} + } + """) + + let entriesMap = ["parent": parent, "child-1": child] + parent.resolveLinks(against: entriesMap, and: [:]) + child.resolveLinks(against: entriesMap, and: [:]) + + // Must terminate — the assertion below is only reachable if it does. + let result = try CTEntry(parent).toJSON() + + // The back-edge to "parent" is an unresolved-link stub (no "fields"/"metadata"), not a + // full re-expansion — everything up to it has expanded normally. + try assertJSONEqual(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"child": { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"backToParent": {"sys": {"id": "parent", "type": "Link", "linkType": "Entry"}}}, + "metadata": {"tags": [], "concepts": []} + }}, + "metadata": {"tags": [], "concepts": []} + } + """, result) + } + + /// Transformation: `NestedContentEntryView.swift` (`examples/apps/travel-guide-ios`, and the + /// ios-sdk implementation's `NestedContentEntryView`) recurses `OptimizedEntry` through a + /// "nested" array field, multiple levels deep — not just one level, and not just a single + /// linear chain. `testResolvedEntryLinkExpandsInline` above only covers one level; this + /// covers a three-level chain (grandparent -> parent -> child) plus a diamond (two siblings + /// at the middle level both linking to the same leaf), matching the shape the reference + /// app's recursive view actually walks — asserting the whole tree proves the diamond expands + /// fully under both siblings, not just the first. + func testMultiLevelNestedEntriesExpandAtEveryLevel() throws { + let grandparent = try decodeEntry(""" + { + "sys": {"id": "grandparent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"nested": [ + {"sys": {"id": "sibling-a", "type": "Link", "linkType": "Entry"}}, + {"sys": {"id": "sibling-b", "type": "Link", "linkType": "Entry"}} + ]} + } + """) + let siblingA = try decodeEntry(""" + { + "sys": {"id": "sibling-a", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "sibling A", "nested": [{"sys": {"id": "leaf", "type": "Link", "linkType": "Entry"}}]} + } + """) + let siblingB = try decodeEntry(""" + { + "sys": {"id": "sibling-b", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "sibling B", "nested": [{"sys": {"id": "leaf", "type": "Link", "linkType": "Entry"}}]} + } + """) + let leaf = try decodeEntry(""" + { + "sys": {"id": "leaf", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "leaf entry"} + } + """) + + let entriesMap = [ + "grandparent": grandparent, "sibling-a": siblingA, "sibling-b": siblingB, "leaf": leaf, + ] + for entry in [grandparent, siblingA, siblingB, leaf] { + entry.resolveLinks(against: entriesMap, and: [:]) + } + + let result = try CTEntry(grandparent).toJSON() + + try assertJSONEqual(""" + { + "sys": {"id": "grandparent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"nested": [ + { + "sys": {"id": "sibling-a", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "sibling A", "nested": [ + { + "sys": {"id": "leaf", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "leaf entry"}, + "metadata": {"tags": [], "concepts": []} + } + ]}, + "metadata": {"tags": [], "concepts": []} + }, + { + "sys": {"id": "sibling-b", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "sibling B", "nested": [ + { + "sys": {"id": "leaf", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "leaf entry"}, + "metadata": {"tags": [], "concepts": []} + } + ]}, + "metadata": {"tags": [], "concepts": []} + } + ]}, + "metadata": {"tags": [], "concepts": []} + } + """, result) + } + + /// Transformation: `testMultiLevelNestedEntriesExpandAtEveryLevel` only proves 3 levels + /// expand; a depth-limit bug (e.g. an accidental cap, or an off-by-one in how `ancestors` is + /// threaded through each recursive call) could still exist beyond that. This chains 5 levels + /// (l1 -> l2 -> l3 -> l4 -> l5) through a single-entry `child` link at each hop — not a + /// diamond or a cycle — and asserts the whole 5-deep tree, to prove recursion itself has no + /// hidden depth ceiling. + func testFiveLevelLinearChainExpandsAtEveryLevel() throws { + func entryJSON(level: Int) -> String { + let childField = level < 5 + ? """ + , "child": {"sys": {"id": "l\(level + 1)", "type": "Link", "linkType": "Entry"}} + """ + : "" + return """ + { + "sys": {"id": "l\(level)", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "level \(level)"\(childField)} + } + """ + } + let levels = try (1 ... 5).map { try decodeEntry(entryJSON(level: $0)) } + let entriesMap = Dictionary(uniqueKeysWithValues: levels.map { ($0.id, $0) }) + for entry in levels { + entry.resolveLinks(against: entriesMap, and: [:]) + } + + let result = try CTEntry(levels[0]).toJSON() + + func expectedJSON(level: Int) -> String { + let childField = level < 5 + ? """ + , "child": \(expectedJSON(level: level + 1)) + """ + : "" + return """ + { + "sys": {"id": "l\(level)", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "level \(level)"\(childField)}, + "metadata": {"tags": [], "concepts": []} + } + """ + } + + try assertJSONEqual(expectedJSON(level: 1), result) + } + + /// Transformation: the existing cycle tests (`testSelfReferencingLinkDoesNotRecurseInfinitely`, + /// `testRichTextEmbeddedEntryCycleDoesNotRecurseInfinitely`) only cover a 2-node cycle + /// (parent <-> child). This proves the ancestor guard also terminates a longer cycle — + /// a -> b -> c -> a — where the back-edge closes several hops later rather than immediately, + /// so a bug that only checked the immediate parent (instead of the full `ancestors` path) + /// would not be caught by the 2-node case alone. + func testThreeNodeCycleDoesNotRecurseInfinitely() throws { + let a = try decodeEntry(""" + { + "sys": {"id": "a", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"next": {"sys": {"id": "b", "type": "Link", "linkType": "Entry"}}} + } + """) + let b = try decodeEntry(""" + { + "sys": {"id": "b", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"next": {"sys": {"id": "c", "type": "Link", "linkType": "Entry"}}} + } + """) + let c = try decodeEntry(""" + { + "sys": {"id": "c", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"next": {"sys": {"id": "a", "type": "Link", "linkType": "Entry"}}} + } + """) + + let entriesMap = ["a": a, "b": b, "c": c] + for entry in [a, b, c] { + entry.resolveLinks(against: entriesMap, and: [:]) + } + + // Must terminate — the assertion below is only reachable if it does. + let result = try CTEntry(a).toJSON() + + // "a" -> "b" -> "c" all expand fully; "c"'s "next" closing the cycle back to "a" is an + // unresolved-link stub, not a full re-expansion. + try assertJSONEqual(""" + { + "sys": {"id": "a", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"next": { + "sys": {"id": "b", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"next": { + "sys": {"id": "c", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"next": {"sys": {"id": "a", "type": "Link", "linkType": "Entry"}}}, + "metadata": {"tags": [], "concepts": []} + }}, + "metadata": {"tags": [], "concepts": []} + }}, + "metadata": {"tags": [], "concepts": []} + } + """, result) + } + + // MARK: - Asset mapping + + /// Transformation: a resolved asset link expands into `{sys, fields: {title, file}}`, and + /// the CDA-style protocol-relative `//` URL gets an explicit `https:` scheme. + func testResolvedAssetLinkMapsTitleAndURL() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"image": {"sys": {"id": "asset-1", "type": "Link", "linkType": "Asset"}}} + } + """) + let assetDecoder = JSONDecoder.withoutLocalizationContext() + assetDecoder.update(with: Self.localizationContext) + assetDecoder.userInfo[.init(rawValue: "linkResolverContext")!] = NSObject() + let asset = try assetDecoder.decode(Asset.self, from: Data(""" + { + "sys": {"id": "asset-1", "type": "Asset", "locale": "en-US"}, + "fields": {"title": "A photo", "file": {"fileName": "a.jpg", "contentType": "image/jpeg", + "details": {"size": 10}, "url": "//images.ctfassets.net/a.jpg"}} + } + """.utf8)) + + entry.resolveLinks(against: [:], and: ["asset-1": asset]) + + let result = try CTEntry(entry).toJSON() + + try assertJSONEqual(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"image": { + "sys": {"id": "asset-1", "type": "Asset"}, + "fields": { + "title": "A photo", + "file": {"fileName": "a.jpg", "contentType": "image/jpeg", + "details": {"size": 10}, "url": "https://images.ctfassets.net/a.jpg"} + } + }}, + "metadata": {"tags": [], "concepts": []} + } + """, result) + } + + /// Transformation: `Asset` exposes `description`, `file.contentType`, and + /// `file.details.{size,image}` beyond `title`/`file.url` — the previous mapping dropped all + /// of them. This proves the full asset shape survives, not just the two fields the minimal + /// mapping used to surface. + func testResolvedAssetLinkMapsDescriptionContentTypeSizeAndImageDimensions() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"image": {"sys": {"id": "asset-1", "type": "Link", "linkType": "Asset"}}} + } + """) + let assetDecoder = JSONDecoder.withoutLocalizationContext() + assetDecoder.update(with: Self.localizationContext) + assetDecoder.userInfo[.init(rawValue: "linkResolverContext")!] = NSObject() + let asset = try assetDecoder.decode(Asset.self, from: Data(""" + { + "sys": {"id": "asset-1", "type": "Asset", "locale": "en-US"}, + "fields": {"title": "A photo", "description": "A scenic view", + "file": {"fileName": "a.jpg", "contentType": "image/jpeg", + "details": {"size": 1024, "image": {"width": 800, "height": 600}}, + "url": "//images.ctfassets.net/a.jpg"}} + } + """.utf8)) + + entry.resolveLinks(against: [:], and: ["asset-1": asset]) + + let result = try CTEntry(entry).toJSON() + + try assertJSONEqual(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"image": { + "sys": {"id": "asset-1", "type": "Asset"}, + "fields": { + "title": "A photo", + "description": "A scenic view", + "file": {"fileName": "a.jpg", "contentType": "image/jpeg", + "details": {"size": 1024, "image": {"width": 800, "height": 600}}, + "url": "https://images.ctfassets.net/a.jpg"} + } + }}, + "metadata": {"tags": [], "concepts": []} + } + """, result) + } + + /// Transformation: a non-image asset's `file.details` has no `image` key at all in a raw CDA + /// response — this proves the mapper omits the key rather than emitting `image: null` or a + /// zeroed dimension, and that a missing `description` is omitted rather than emitted empty. + func testResolvedAssetLinkWithoutDescriptionOrImageOmitsThoseKeys() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"attachment": {"sys": {"id": "asset-1", "type": "Link", "linkType": "Asset"}}} + } + """) + let assetDecoder = JSONDecoder.withoutLocalizationContext() + assetDecoder.update(with: Self.localizationContext) + assetDecoder.userInfo[.init(rawValue: "linkResolverContext")!] = NSObject() + let asset = try assetDecoder.decode(Asset.self, from: Data(""" + { + "sys": {"id": "asset-1", "type": "Asset", "locale": "en-US"}, + "fields": {"title": "A PDF", "file": {"fileName": "doc.pdf", "contentType": "application/pdf", + "details": {"size": 2048}, "url": "//assets.ctfassets.net/doc.pdf"}} + } + """.utf8)) + + entry.resolveLinks(against: [:], and: ["asset-1": asset]) + + let result = try CTEntry(entry).toJSON() + + // No "description" key and no "details.image" key — omitted, not null. + try assertJSONEqual(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"attachment": { + "sys": {"id": "asset-1", "type": "Asset"}, + "fields": { + "title": "A PDF", + "file": {"fileName": "doc.pdf", "contentType": "application/pdf", + "details": {"size": 2048}, "url": "https://assets.ctfassets.net/doc.pdf"} + } + }}, + "metadata": {"tags": [], "concepts": []} + } + """, result) + } + + /// Transformation: `Asset.file` is `nil` when a `select()` query excludes it, or the media + /// is still processing after upload — a raw CDA response's `fields` in that case carries no + /// `file` key at all. This proves the mapper falls back to `urlString` instead of crashing on + /// `asset.file`'s optional or emitting a `file` key shaped like `jsonFileMetadata`'s output + /// with missing pieces. + func testResolvedAssetLinkWithoutFileFallsBackToURLStringShape() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"image": {"sys": {"id": "asset-1", "type": "Link", "linkType": "Asset"}}} + } + """) + let assetDecoder = JSONDecoder.withoutLocalizationContext() + assetDecoder.update(with: Self.localizationContext) + assetDecoder.userInfo[.init(rawValue: "linkResolverContext")!] = NSObject() + // No "file" key at all — the shape a `select(fields: ["title"])` query or a + // still-processing upload produces. + let asset = try assetDecoder.decode(Asset.self, from: Data(""" + { + "sys": {"id": "asset-1", "type": "Asset", "locale": "en-US"}, + "fields": {"title": "Still processing"} + } + """.utf8)) + + entry.resolveLinks(against: [:], and: ["asset-1": asset]) + + let result = try CTEntry(entry).toJSON() + + // No "fileName" key claimed — just the fallback "file.url" (empty), matching the + // pre-existing fallback shape. + try assertJSONEqual(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"image": { + "sys": {"id": "asset-1", "type": "Asset"}, + "fields": {"title": "Still processing", "file": {"url": ""}} + }}, + "metadata": {"tags": [], "concepts": []} + } + """, result) + } + + // MARK: - Location + + /// Identity: a `Location` field round-trips as `{lat, lon}` unchanged. + func testLocationFieldMapsToLatLon() throws { + try assertIdentity(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"place": {"lat": 51.5, "lon": -0.12}}, + "metadata": {"tags": [], "concepts": []} + } + """) + } + + // MARK: - Rich text + + /// Identity: plain Structured Text nodes (paragraph, text-with-marks, hyperlink) round-trip + /// through the mapper unchanged, not just links/assets. If `RichTextDocument` had no case in + /// `jsonValue`, the entire field would silently vanish — this is the regression that case + /// closes. + func testRichTextPlainNodesMapToNodeTree() throws { + try assertIdentity(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "paragraph", "data": {}, "content": [ + {"nodeType": "text", "value": "Hello ", "marks": [], "data": {}}, + {"nodeType": "text", "value": "world", "marks": [{"type": "bold"}], "data": {}} + ]}, + {"nodeType": "hyperlink", "data": {"uri": "https://example.com"}, "content": [ + {"nodeType": "text", "value": "click", "marks": [], "data": {}} + ]} + ] + }}, + "metadata": {"tags": [], "concepts": []} + } + """) + } + + /// Transformation: the one case this whole addition exists for — an embedded entry inside + /// rich text that the Delivery SDK *did* resolve expands inline, same as a top-level resolved + /// link, instead of disappearing. + func testResolvedEmbeddedEntryBlockExpandsInline() throws { + let parent = try decodeEntry(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "embedded-entry-block", + "data": {"target": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}}, + "content": []} + ] + }} + } + """) + let child = try decodeEntry(""" + { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "embedded child"} + } + """) + + parent.resolveLinks(against: ["parent": parent, "child-1": child], and: [:]) + + let result = try CTEntry(parent).toJSON() + + // A resolved embedded entry must expand inline, carrying metadata same as any other + // expanded entry. + try assertJSONEqual(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "embedded-entry-block", + "data": {"target": { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "embedded child"}, + "metadata": {"tags": [], "concepts": []} + }}, + "content": []} + ] + }}, + "metadata": {"tags": [], "concepts": []} + } + """, result) + } + + /// Identity: the other case that must not be dropped — an embedded entry the Delivery SDK + /// could *not* resolve (e.g. unpublished, or outside the query's `include` depth) still + /// surfaces as exactly the unresolved-link stub it arrived as — not vanished, and not + /// confused with the resolved case above. + func testUnresolvedEmbeddedEntryBlockEmitsStubNotOmission() throws { + // Deliberately not calling resolveLinks — no candidate entries were ever supplied, the + // shape a query with insufficient `include` depth or an unpublished target produces. + try assertIdentity(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "embedded-entry-block", + "data": {"target": {"sys": {"id": "missing-1", "type": "Link", "linkType": "Entry"}}}, + "content": []} + ] + }}, + "metadata": {"tags": [], "concepts": []} + } + """) + } + + /// Transformation: same resolved/unresolved distinction, but for an embedded *asset* rather + /// than an entry — a separate code path (`.asset` vs `.entry`/`.unresolved` in `jsonLink`) + /// that must not be conflated with the entry case above. + func testResolvedEmbeddedAssetBlockExpandsWithTitleAndURL() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "embedded-asset-block", + "data": {"target": {"sys": {"id": "asset-1", "type": "Link", "linkType": "Asset"}}}, + "content": []} + ] + }} + } + """) + let assetDecoder = JSONDecoder.withoutLocalizationContext() + assetDecoder.update(with: Self.localizationContext) + assetDecoder.userInfo[.init(rawValue: "linkResolverContext")!] = NSObject() + let asset = try assetDecoder.decode(Asset.self, from: Data(""" + { + "sys": {"id": "asset-1", "type": "Asset", "locale": "en-US"}, + "fields": {"title": "An image", "file": {"fileName": "b.png", "contentType": "image/png", + "details": {"size": 20}, "url": "//images.ctfassets.net/b.png"}} + } + """.utf8)) + + entry.resolveLinks(against: [:], and: ["asset-1": asset]) + + let result = try CTEntry(entry).toJSON() + + try assertJSONEqual(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "embedded-asset-block", + "data": {"target": { + "sys": {"id": "asset-1", "type": "Asset"}, + "fields": { + "title": "An image", + "file": {"fileName": "b.png", "contentType": "image/png", + "details": {"size": 20}, "url": "https://images.ctfassets.net/b.png"} + } + }}, + "content": []} + ] + }}, + "metadata": {"tags": [], "concepts": []} + } + """, result) + } + + /// Transformation: embedded-entry-*inline* (a different Swift type, `ResourceLinkInline`, + /// from the block variant tested above) also expands a resolved target, proving the inline + /// node-type branch isn't just a copy-paste of the block branch that happens to compile. + func testResolvedEmbeddedEntryInlineExpandsInline() throws { + let parent = try decodeEntry(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "paragraph", "data": {}, "content": [ + {"nodeType": "embedded-entry-inline", + "data": {"target": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}}, + "content": []} + ]} + ] + }} + } + """) + let child = try decodeEntry(""" + { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "inline child"} + } + """) + + parent.resolveLinks(against: ["parent": parent, "child-1": child], and: [:]) + + let result = try CTEntry(parent).toJSON() + + try assertJSONEqual(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "paragraph", "data": {}, "content": [ + {"nodeType": "embedded-entry-inline", + "data": {"target": { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "inline child"}, + "metadata": {"tags": [], "concepts": []} + }}, + "content": []} + ]} + ] + }}, + "metadata": {"tags": [], "concepts": []} + } + """, result) + } + + /// Transformation: `entry-hyperlink` and `asset-hyperlink` decode to the same + /// `ResourceLinkInline` Swift type as `embedded-entry-inline` (confirmed against a real + /// decode — `NodeType.type` maps all three to `ResourceLinkInline.self`), so `jsonNode`'s + /// type-based switch already covers them without a dedicated case. This test proves that's + /// actually true for `entry-hyperlink` specifically, not just architecturally plausible — a + /// hyperlink-to-an-entry is a distinct authoring action from an embedded block, and CDA + /// gives it a different `nodeType` string. + func testEntryHyperlinkExpandsResolvedTargetInline() throws { + let parent = try decodeEntry(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "paragraph", "data": {}, "content": [ + {"nodeType": "entry-hyperlink", + "data": {"target": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}}, + "content": [{"nodeType": "text", "value": "link text", "marks": [], "data": {}}]} + ]} + ] + }} + } + """) + let child = try decodeEntry(""" + { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "linked child"} + } + """) + + parent.resolveLinks(against: ["parent": parent, "child-1": child], and: [:]) + + let result = try CTEntry(parent).toJSON() + + try assertJSONEqual(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "paragraph", "data": {}, "content": [ + {"nodeType": "entry-hyperlink", + "data": {"target": { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "linked child"}, + "metadata": {"tags": [], "concepts": []} + }}, + "content": [{"nodeType": "text", "value": "link text", "marks": [], "data": {}}]} + ]} + ] + }}, + "metadata": {"tags": [], "concepts": []} + } + """, result) + } + + /// Identity: `asset-hyperlink` — same `ResourceLinkInline` type, distinct `nodeType`, + /// unresolved this time (mirrors the unresolved-embedded-entry test's point: neither + /// hyperlink variant should be assumed resolved) — the target stub round-trips unchanged. + func testAssetHyperlinkEmitsUnresolvedStubWhenNotResolved() throws { + let input = """ + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "paragraph", "data": {}, "content": [ + {"nodeType": "asset-hyperlink", + "data": {"target": {"sys": {"id": "asset-1", "type": "Link", "linkType": "Asset"}}}, + "content": [{"nodeType": "text", "value": "asset link", "marks": [], "data": {}}]} + ]} + ] + }}, + "metadata": {"tags": [], "concepts": []} + } + """ + // Not calling resolveLinks — no asset candidates supplied. + try assertIdentity(input) + } + + /// Transformation: confirmed via a real decode (scratch probe, since removed) that a rich + /// text field embedding an entry which itself has a rich text field is a real, reachable + /// shape — not hypothetical. This proves the mapper's field-recursion and node-recursion + /// compose across that boundary: an embedded entry's own rich text field expands, not just + /// its plain fields (already covered by `testResolvedEmbeddedEntryBlockExpandsInline`). + func testRichTextInsideEmbeddedEntryFieldsAlsoExpands() throws { + let parent = try decodeEntry(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "embedded-entry-block", + "data": {"target": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}}, + "content": []} + ] + }} + } + """) + let child = try decodeEntry(""" + { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"nestedBody": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "paragraph", "data": {}, "content": [ + {"nodeType": "text", "value": "nested rich text", "marks": [], "data": {}} + ]} + ] + }} + } + """) + + parent.resolveLinks(against: ["parent": parent, "child-1": child], and: [:]) + + let result = try CTEntry(parent).toJSON() + + try assertJSONEqual(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "embedded-entry-block", + "data": {"target": { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"nestedBody": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "paragraph", "data": {}, "content": [ + {"nodeType": "text", "value": "nested rich text", "marks": [], "data": {}} + ]} + ] + }}, + "metadata": {"tags": [], "concepts": []} + }}, + "content": []} + ] + }}, + "metadata": {"tags": [], "concepts": []} + } + """, result) + } + + /// Transformation: confirmed via a real decode (scratch probe, since removed) that this is a + /// genuine object graph cycle, not a hypothetical one — after `resolveLinks`, the child's + /// back-reference to the parent inside rich text resolves to `.entry(parent)`, an actual + /// `Entry` reference; recursing it without the ancestor guard would loop forever. This is + /// the rich-text counterpart to `testSelfReferencingLinkDoesNotRecurseInfinitely` (which + /// only covers a plain top-level field link), proving the same guard also holds across the + /// field-recursion/node-recursion boundary rich text introduces. + func testRichTextEmbeddedEntryCycleDoesNotRecurseInfinitely() throws { + let parent = try decodeEntry(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "embedded-entry-block", + "data": {"target": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}}, + "content": []} + ] + }} + } + """) + let child = try decodeEntry(""" + { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "embedded-entry-block", + "data": {"target": {"sys": {"id": "parent", "type": "Link", "linkType": "Entry"}}}, + "content": []} + ] + }} + } + """) + + let entriesMap = ["parent": parent, "child-1": child] + parent.resolveLinks(against: entriesMap, and: [:]) + child.resolveLinks(against: entriesMap, and: [:]) + + // Must terminate — the assertion below is only reachable if it does. + let result = try CTEntry(parent).toJSON() + + // "child-1" expands fully, including its own rich text field; the back-edge inside that + // rich text closing the cycle to "parent" is an unresolved-link stub, not a + // re-expansion. + try assertJSONEqual(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "embedded-entry-block", + "data": {"target": { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"body": { + "nodeType": "document", "data": {}, + "content": [ + {"nodeType": "embedded-entry-block", + "data": {"target": {"sys": {"id": "parent", "type": "Link", "linkType": "Entry"}}}, + "content": []} + ] + }}, + "metadata": {"tags": [], "concepts": []} + }}, + "content": []} + ] + }}, + "metadata": {"tags": [], "concepts": []} + } + """, result) + } + + // MARK: - Date fields + + /// Identity: `contentful.swift`'s generic `[String: Any]` field decoder + /// (`Decodable.swift`'s `KeyedDecodingContainer.decode(_: [String: Any].Type)`) tries `Bool`, + /// then `String`, before any date-specific type. A Contentful "Date" field is a JSON string + /// (e.g. `"2024-06-15T12:30:00Z"`), so it is captured by the `String` branch and surfaces in + /// `entry.fields` as `String`, never as Swift `Date`. Verified empirically against a real + /// decode. This means `OptimizationEntryMapping`'s `case let date as Date` branch (ported + /// faithfully from `OptimizationAdapter.swift`, which has the same dead branch) can only ever + /// trigger for a `Date` placed into the dict programmatically — never for a field decoded + /// from a real CDA response. Documented here rather than silently dropped, since removing it + /// would diverge from the reference file without a call to do so. + func testDateLikeFieldDecodesAsPlainStringNotSwiftDate() throws { + let input = """ + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"publishDate": "2024-06-15T12:30:00Z"}, + "metadata": {"tags": [], "concepts": []} + } + """ + let entry = try decodeEntry(input) + + XCTAssertTrue(entry.fields["publishDate"] is String) + XCTAssertFalse(entry.fields["publishDate"] is Date) + + let result = try CTEntry(entry).toJSON() + + try assertJSONEqual(input, result) + } + + // MARK: - Unsupported values are dropped, not thrown + + /// Identity: a plain unsupported-but-still-encodable value (`Int`) round-trips unchanged + /// alongside a supported one — proving nothing is dropped or thrown for ordinary field types. + func testUnsupportedFieldTypeIsDroppedNotThrown() throws { + try assertIdentity(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"title": "kept", "count": 3}, + "metadata": {"tags": [], "concepts": []} + } + """) + } + + // MARK: - Asset.FileMetadata decoded directly as a field value + + /// Transformation: a field of Contentful type "Object" shaped exactly like a file metadata + /// blob decodes to `Asset.FileMetadata` directly — no `Asset`/`Link` wrapper at all + /// (contentful.swift's generic `[String: Any]` field decoder tries `Asset.FileMetadata` + /// before falling back to a plain dictionary), and its protocol-relative URL gets an + /// explicit scheme, same as a resolved asset link's `file` field. This is distinct from + /// `testResolvedAssetLinkMapsDescriptionContentTypeSizeAndImageDimensions`, which covers the + /// same shape arriving through a resolved asset *link* instead. + func testFileMetadataShapedObjectFieldMapsSameAsAssetFile() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"rawFile": {"fileName": "raw.png", "contentType": "image/png", + "details": {"size": 512, "image": {"width": 100, "height": 50}}, + "url": "//images.ctfassets.net/raw.png"}} + } + """) + + let result = try CTEntry(entry).toJSON() + + try assertJSONEqual(""" + { + "sys": {"id": "e1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"rawFile": {"fileName": "raw.png", "contentType": "image/png", + "details": {"size": 512, "image": {"width": 100, "height": 50}}, + "url": "https://images.ctfassets.net/raw.png"}}, + "metadata": {"tags": [], "concepts": []} + } + """, result) + } + + // MARK: - Reading a resolved entry + + func testGetFieldReturnsValueForMatchingType() throws { + let resolved = try CTEntry(any: [ + "sys": ["id": "e1"], + "fields": ["title": "Hello", "count": 3.0, "isFeatured": true], + ]) + + XCTAssertEqual(resolved.getField("title"), "Hello") + // `JSONValue.number` has no separate Int case — an Int field round-trips as Double. + XCTAssertEqual(resolved.getField("count"), 3.0) + XCTAssertEqual(resolved.getField("isFeatured"), true) + } + + func testGetFieldReturnsNilForWrongRequestedType() throws { + // "count" is a Double in the raw map; requesting it as String must fail the `as?` cast + // and return nil, not crash or coerce. + let resolved = try CTEntry(any: ["sys": [:], "fields": ["count": 3.0]]) + + let asString: String? = resolved.getField("count") + XCTAssertNil(asString) + } + + func testGetFieldReturnsNilForAbsentField() throws { + let resolved = try CTEntry(any: ["sys": [:], "fields": ["title": "Hello"]]) + + let missing: String? = resolved.getField("subtitle") + XCTAssertNil(missing) + } + + func testGetFieldReturnsNilWhenFieldsKeyIsAbsent() throws { + // No "fields" key at all — e.g. a malformed or partial resolver output. + let resolved = try CTEntry(any: ["sys": ["id": "e1"]]) + + let value: String? = resolved.getField("title") + XCTAssertNil(value) + } + + func testIdReturnsSysId() throws { + let resolved = try CTEntry(any: ["sys": ["id": "e1"], "fields": [:]]) + + XCTAssertEqual(resolved.id, "e1") + } + + func testIdReturnsNilWhenSysKeyIsAbsent() throws { + let resolved = try CTEntry(any: ["fields": ["title": "Hello"]]) + + XCTAssertNil(resolved.id) + } + + func testIdReturnsNilWhenSysIdIsWrongType() throws { + // "id" present but not a String — e.g. accidentally passed a number. + let resolved = try CTEntry(any: ["sys": ["id": 123.0], "fields": [:]]) + + XCTAssertNil(resolved.id) + } + + // MARK: - localeCode mirrors Entry.localeCode + + func testLocaleCodeReturnsSysLocale() throws { + let resolved = try CTEntry(any: ["sys": ["id": "e1", "locale": "en-US"], "fields": [:]]) + + XCTAssertEqual(resolved.localeCode, "en-US") + } + + func testLocaleCodeReturnsNilWhenAbsent() throws { + // Absent on a raw CDA response fetched via /sync or the wildcard `locale=*` query — + // same case where `Entry.localeCode` itself returns nil. + let resolved = try CTEntry(any: ["sys": ["id": "e1"], "fields": [:]]) + + XCTAssertNil(resolved.localeCode) + } + + // MARK: - createdAt/updatedAt mirror Entry.createdAt/updatedAt + + func testCreatedAtAndUpdatedAtParseISO8601SysTimestamps() throws { + let resolved = try CTEntry(any: [ + "sys": ["id": "e1", "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-06-15T12:30:00Z"], + "fields": [:], + ]) + + XCTAssertNotNil(resolved.createdAt) + XCTAssertNotNil(resolved.updatedAt) + XCTAssertNotEqual(resolved.createdAt, resolved.updatedAt) + } + + func testCreatedAtAndUpdatedAtReturnNilWhenAbsent() throws { + // A resolver-synthesized entry may carry no creation/update timestamps — same as + // `Entry.createdAt`/`updatedAt` returning nil for a resource fetched without `sys` dates. + let resolved = try CTEntry(any: ["sys": ["id": "e1"], "fields": [:]]) + + XCTAssertNil(resolved.createdAt) + XCTAssertNil(resolved.updatedAt) + } + + func testCreatedAtReturnsNilForUnparseableTimestamp() throws { + let resolved = try CTEntry(any: ["sys": ["id": "e1", "createdAt": "not-a-date"], "fields": [:]]) + + XCTAssertNil(resolved.createdAt) + } + + // MARK: - String field subscript mirrors Entry's convenience subscript + + func testStringFieldSubscriptReadsFromFields() throws { + let resolved = try CTEntry(any: ["sys": [:], "fields": ["title": "Hello"]]) + + let title: String? = resolved[field: "title"] + XCTAssertEqual(title, "Hello") + } + + func testStringFieldSubscriptReturnsNilForWrongType() throws { + let resolved = try CTEntry(any: ["sys": [:], "fields": ["count": 3.0]]) + + let asString: String? = resolved[field: "count"] + XCTAssertNil(asString) + } + + // MARK: - init(any:) rejects unsupported Foundation types + + /// `Date`/`Data`/other non-JSON-safe Foundation values have no case in `init(any:)` — a + /// caller passing one gets a thrown error, not a value that quietly reads back as absent. + func testInitAnyThrowsForUnsupportedType() { + XCTAssertThrowsError(try CTEntry(any: ["fields": ["publishedAt": Date()]])) + } +} diff --git a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationClientTests.swift b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationClientTests.swift index 299347456..48ae5f60c 100644 --- a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationClientTests.swift +++ b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationClientTests.swift @@ -1,7 +1,9 @@ import Combine +@testable import Contentful +@testable import ContentfulOptimization +import Foundation import JavaScriptCore import XCTest -@testable import ContentfulOptimization final class OptimizationClientTests: XCTestCase { @@ -1009,7 +1011,7 @@ final class OptimizationClientTests: XCTestCase { let baseline: [String: Any] = ["sys": ["id": "entry1"], "fields": ["title": "Hello"]] let result = client.resolveOptimizedEntry(baseline: baseline) - XCTAssertEqual(result.entry["fields"] as? [String: String], ["title": "Hello"]) + XCTAssertEqual(result.entry.getField("title"), "Hello") XCTAssertNil(result.selectedOptimization) } @@ -1094,9 +1096,128 @@ final class OptimizationClientTests: XCTestCase { // resolveOptimizedEntry should round-trip the entry through JS and back // without losing fields (i.e. the JS bridge should actually process it) let result = client.resolveOptimizedEntry(baseline: baseline) - let fields = result.entry["fields"] as? [String: Any] - XCTAssertEqual(fields?["title"] as? String, "Hello") - XCTAssertEqual(fields?["slug"] as? String, "hello-world") + XCTAssertEqual(result.entry.getField("title"), "Hello") + XCTAssertEqual(result.entry.getField("slug"), "hello-world") + } + + // MARK: - Phase 2: resolveOptimizedEntry(baseline: Contentful.Entry) Tests + + private static let localizationContext: LocalizationContext = { + let localeJSON = Data(""" + {"code":"en-US","default":true,"name":"English","fallbackCode":null} + """.utf8) + let locale = try! JSONDecoder.withoutLocalizationContext().decode(Contentful.Locale.self, from: localeJSON) + return LocalizationContext(locales: [locale])! + }() + + private func decodeEntry(_ json: String) throws -> Entry { + let decoder = JSONDecoder.withoutLocalizationContext() + decoder.update(with: Self.localizationContext) + decoder.userInfo[.init(rawValue: "linkResolverContext")!] = NSObject() + return try decoder.decode(Entry.self, from: Data(json.utf8)) + } + + @MainActor + func testResolveOptimizedEntryContentfulOverloadFallsBackToMappedBaselineEntry() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "entry-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"title": "Default Title"} + } + """) + let client = OptimizationClient() + + let result = client.resolveOptimizedEntry(baseline: entry) + + XCTAssertEqual(result.entry.id, "entry-1") + XCTAssertEqual(result.entry.getField("title"), "Default Title") + XCTAssertNil(result.selectedOptimization) + XCTAssertNil(result.optimizationContextId) + } + + /// Proves this overload actually routes through `CTEntry(_: Contentful.Entry)` rather than some + /// other conversion: a resolved link on the baseline must come back expanded exactly as + /// `CTEntry(_: Contentful.Entry)` would produce it, readable via `getField`. + @MainActor + func testResolveOptimizedEntryContentfulOverloadFallbackEntryHasLinksExpanded() throws { + let parent = try decodeEntry(""" + { + "sys": {"id": "parent", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"child": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}} + } + """) + let child = try decodeEntry(""" + { + "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"name": "child entry"} + } + """) + parent.resolveLinks(against: ["parent": parent, "child-1": child], and: [:]) + let client = OptimizationClient() + + let result = client.resolveOptimizedEntry(baseline: parent) + + let childField: [String: Any]? = result.entry.getField("child") + XCTAssertEqual((childField?["sys"] as? [String: Any])?["id"] as? String, "child-1") + XCTAssertEqual((childField?["fields"] as? [String: Any])?["name"] as? String, "child entry", "the resolved link must have expanded inline, matching CTEntry's own behavior") + } + + /// This overload must be a true *overload* of the existing method — same name, + /// `resolveOptimizedEntry`, resolved by Swift purely from the static type of `baseline` at the + /// call site (a dict picks the `[String: Any]` overload; a `Contentful.Entry` picks this one) — + /// not a differently-named method that merely does something similar. The identical call + /// syntax below, against two differently-typed `baseline` arguments, is what actually proves + /// overload resolution picked two distinct declarations rather than one generic one. + @MainActor + func testResolveOptimizedEntryIsATrueOverloadResolvedByBaselineArgumentType() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "entry-1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"title": "Hello"} + } + """) + let dict: [String: Any] = ["sys": ["id": "entry-1"], "fields": ["title": "Hello"]] + let client = OptimizationClient() + + let dictResult = client.resolveOptimizedEntry(baseline: dict) + let entryResult = client.resolveOptimizedEntry(baseline: entry) + + XCTAssertEqual(dictResult.entry.id, "entry-1") + XCTAssertEqual(entryResult.entry.id, "entry-1") + } + + /// The fallback tests above only prove the fallback path; this round-trips a real + /// `Contentful.Entry` through an initialized client's JS bridge (mirroring + /// `testResolveOptimizedEntryPreservesFieldsWhenInitialized`, the dict-based overload's + /// equivalent test) and confirms fields survive and are readable via `getField`. + @MainActor + func testResolveOptimizedEntryContentfulOverloadRoundTripsFieldsThroughRealBridge() throws { + let entry = try decodeEntry(""" + { + "sys": {"id": "entry1", "type": "Entry", "locale": "en-US", + "contentType": {"sys": {"id": "page", "type": "Link", "linkType": "ContentType"}}}, + "fields": {"title": "Hello", "slug": "hello-world"} + } + """) + let client = OptimizationClient() + let config = OptimizationConfig( + clientId: "test-client", + environment: "master", + api: OptimizationApiConfig( + experienceBaseUrl: "http://localhost:8000/experience/", + insightsBaseUrl: "http://localhost:8000/insights/" + ) + ) + try client.initialize(config: config) + + let result = client.resolveOptimizedEntry(baseline: entry) + + XCTAssertEqual(result.entry.getField("title"), "Hello", "the entry must actually round-trip through the JS bridge, not just fall back to the pre-mapped baseline") + XCTAssertEqual(result.entry.getField("slug"), "hello-world") } // MARK: - Phase 2: Payload Serialization Tests @@ -1732,7 +1853,7 @@ final class OptimizationClientTests: XCTestCase { // Without initialization, resolveOptimizedEntry returns baseline let result = client.resolveOptimizedEntry(baseline: baseline) - XCTAssertEqual(result.entry["sys"] as? [String: String], ["id": "4ib0hsHWoSOnCVdDkizE8d"]) + XCTAssertEqual(result.entry.id, "4ib0hsHWoSOnCVdDkizE8d") XCTAssertNil(result.selectedOptimization) XCTAssertNil(result.optimizationContextId) } diff --git a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationEntryMappingTests.swift b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationEntryMappingTests.swift deleted file mode 100644 index d1a5d0f10..000000000 --- a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationEntryMappingTests.swift +++ /dev/null @@ -1,981 +0,0 @@ -@testable import Contentful -@testable import ContentfulOptimization -import Foundation -import XCTest - -/// Verifies `OptimizationEntryMapping.toOptimizationEntry` against real `contentful.swift` decodes -/// — not fabricated dicts — so the mapping is checked against actual SDK object shapes rather -/// than assumptions about them. Mirrors the scenarios `OptimizationAdapter.swift` -/// (`examples/apps/travel-guide-ios`) exists to cover: link resolution, the metadata requirement, -/// asset mapping, and the ancestor-cycle guard. -final class OptimizationEntryMappingTests: XCTestCase { - private static let localizationContext: LocalizationContext = { - let localeJSON = Data(""" - {"code":"en-US","default":true,"name":"English","fallbackCode":null} - """.utf8) - let locale = try! JSONDecoder.withoutLocalizationContext().decode(Contentful.Locale.self, from: localeJSON) - return LocalizationContext(locales: [locale])! - }() - - private func decodeEntry(_ json: String) throws -> Entry { - let decoder = JSONDecoder.withoutLocalizationContext() - decoder.update(with: Self.localizationContext) - decoder.userInfo[.init(rawValue: "linkResolverContext")!] = NSObject() - return try decoder.decode(Entry.self, from: Data(json.utf8)) - } - - // MARK: - Baseline shape - - func testMapsSysAndContentType() throws { - let entry = try decodeEntry(""" - { - "sys": {"id": "e1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "landingPage", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"title": "Hello"} - } - """) - - let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) - let sys = mapped["sys"] as? [String: Any] - XCTAssertEqual(sys?["id"] as? String, "e1") - XCTAssertEqual(sys?["type"] as? String, "Entry") - let contentType = (sys?["contentType"] as? [String: Any])?["sys"] as? [String: Any] - XCTAssertEqual(contentType?["id"] as? String, "landingPage") - - let fields = mapped["fields"] as? [String: Any] - XCTAssertEqual(fields?["title"] as? String, "Hello") - } - - /// `ResolvedEntry.createdAt`/`updatedAt`/`localeCode` (see `ResolvedEntryTests`) can only - /// mirror real values if `entryMap`'s `sys` block actually carries them — this proves that - /// side of the round trip, not just that `ResolvedEntry` parses whatever it's given. - func testMapsSysTimestampsRevisionAndLocale() throws { - let entry = try decodeEntry(""" - { - "sys": {"id": "e1", "type": "Entry", "locale": "en-US", "revision": 3, - "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-06-15T12:30:00Z", - "contentType": {"sys": {"id": "landingPage", "type": "Link", "linkType": "ContentType"}}}, - "fields": {} - } - """) - - let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) - let sys = mapped["sys"] as? [String: Any] - XCTAssertEqual(sys?["locale"] as? String, "en-US") - XCTAssertEqual(sys?["revision"] as? Int, 3) - XCTAssertEqual(sys?["createdAt"] as? String, "2024-01-01T00:00:00Z") - XCTAssertEqual(sys?["updatedAt"] as? String, "2024-06-15T12:30:00Z") - } - - /// A `/sync` response, or one fetched with the wildcard `locale=*` query, carries no - /// `sys.locale` — this proves the mapper omits the key entirely rather than emitting - /// `locale: null`, matching `Entry.sys.locale`'s own optionality. - func testOmitsSysLocaleWhenAbsentFromSource() throws { - let entry = try decodeEntry(""" - { - "sys": {"id": "e1", "type": "Entry", - "contentType": {"sys": {"id": "landingPage", "type": "Link", "linkType": "ContentType"}}}, - "fields": {} - } - """) - - let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) - let sys = mapped["sys"] as? [String: Any] - XCTAssertNil(sys?["locale"], "sys.locale must be omitted, not emitted as null, when the source entry has none") - XCTAssertNil(sys?["createdAt"]) - XCTAssertNil(sys?["updatedAt"]) - XCTAssertNil(sys?["revision"]) - } - - // MARK: - The silent metadata requirement - - /// The resolver's entry guard (`isResolvedContentfulEntry` in - /// `packages/universal/api-schemas/src/contentful/typeGuards.ts`) rejects any entry without a - /// `metadata` object — silently, no error, the entry is just treated as non-optimized. `Entry` - /// keeps `metadata` off `fields` (it's a sys-level sibling), so an entry with zero tags still - /// needs an explicit empty `metadata.tags`/`concepts`, not an absent key. - func testAlwaysIncludesMetadataEvenWithNoTags() throws { - let entry = try decodeEntry(""" - { - "sys": {"id": "e1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "landingPage", "type": "Link", "linkType": "ContentType"}}}, - "fields": {} - } - """) - - let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) - let metadata = mapped["metadata"] as? [String: Any] - XCTAssertNotNil(metadata, "metadata must always be present or the resolver silently treats the entry as non-optimized") - XCTAssertEqual((metadata?["tags"] as? [Any])?.count, 0) - XCTAssertEqual((metadata?["concepts"] as? [Any])?.count, 0) - } - - func testMapsMetadataTags() throws { - let entry = try decodeEntry(""" - { - "sys": {"id": "e1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "landingPage", "type": "Link", "linkType": "ContentType"}}}, - "fields": {}, - "metadata": {"tags": [{"sys": {"id": "tag1", "linkType": "Tag", "type": "Link"}}]} - } - """) - - let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) - let tags = (mapped["metadata"] as? [String: Any])?["tags"] as? [[String: Any]] - XCTAssertEqual(tags?.count, 1) - let tagSys = tags?.first?["sys"] as? [String: Any] - XCTAssertEqual(tagSys?["id"] as? String, "tag1") - } - - // MARK: - Link resolution - - func testUnresolvedLinkEmitsStub() throws { - let entry = try decodeEntry(""" - { - "sys": {"id": "e1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "landingPage", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"related": {"sys": {"id": "e2", "type": "Link", "linkType": "Entry"}}} - } - """) - - let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) - let fields = mapped["fields"] as? [String: Any] - let related = fields?["related"] as? [String: Any] - let sys = related?["sys"] as? [String: Any] - XCTAssertEqual(sys?["id"] as? String, "e2") - XCTAssertEqual(sys?["type"] as? String, "Link") - XCTAssertEqual(sys?["linkType"] as? String, "Entry") - // Unresolved: no "fields" key from a nested entryMap expansion. - XCTAssertNil(related?["fields"]) - } - - func testResolvedEntryLinkExpandsInline() throws { - let parent = try decodeEntry(""" - { - "sys": {"id": "parent", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"child": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}} - } - """) - let child = try decodeEntry(""" - { - "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"name": "child entry"} - } - """) - - let entriesMap = ["parent": parent, "child-1": child] - parent.resolveLinks(against: entriesMap, and: [:]) - - let mapped = OptimizationEntryMapping.toOptimizationEntry(parent) - let childField = (mapped["fields"] as? [String: Any])?["child"] as? [String: Any] - XCTAssertEqual((childField?["sys"] as? [String: Any])?["id"] as? String, "child-1") - XCTAssertEqual((childField?["fields"] as? [String: Any])?["name"] as? String, "child entry") - // A resolved entry link's metadata must also be present, for the same reason as the root. - XCTAssertNotNil(childField?["metadata"]) - } - - /// The Delivery SDK resolves links into shared object references, so a variant linking back - /// to its baseline is a real cycle in the object graph, not just a data shape to defend - /// against defensively. Recursing an already-visited entry would loop forever; the mapper - /// must emit an unresolved-link stub for the back-edge instead. - func testSelfReferencingLinkDoesNotRecurseInfinitely() throws { - let parent = try decodeEntry(""" - { - "sys": {"id": "parent", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"child": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}} - } - """) - let child = try decodeEntry(""" - { - "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"backToParent": {"sys": {"id": "parent", "type": "Link", "linkType": "Entry"}}} - } - """) - - let entriesMap = ["parent": parent, "child-1": child] - parent.resolveLinks(against: entriesMap, and: [:]) - child.resolveLinks(against: entriesMap, and: [:]) - - // Must terminate — the assertions below are only reachable if it does. - let mapped = OptimizationEntryMapping.toOptimizationEntry(parent) - - let childField = (mapped["fields"] as? [String: Any])?["child"] as? [String: Any] - let backLink = (childField?["fields"] as? [String: Any])?["backToParent"] as? [String: Any] - let backSys = backLink?["sys"] as? [String: Any] - XCTAssertEqual(backSys?["id"] as? String, "parent") - XCTAssertEqual(backSys?["type"] as? String, "Link", "a back-edge to an ancestor must be an unresolved-link stub, not a full expansion") - XCTAssertNil(backLink?["fields"], "the back-edge must not have been expanded into a full entry map") - } - - /// `NestedContentEntryView.swift` (`examples/apps/travel-guide-ios`, and the ios-sdk - /// implementation's `NestedContentEntryView`) recurses `OptimizedEntry` through a "nested" - /// array field, multiple levels deep — not just one level, and not just a single linear - /// chain. `testResolvedEntryLinkExpandsInline` above only covers one level; this covers a - /// three-level chain (grandparent -> parent -> child) plus a diamond (two siblings at the - /// middle level both linking to the same leaf), matching the shape the reference app's - /// recursive view actually walks. - func testMultiLevelNestedEntriesExpandAtEveryLevel() throws { - let grandparent = try decodeEntry(""" - { - "sys": {"id": "grandparent", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"nested": [ - {"sys": {"id": "sibling-a", "type": "Link", "linkType": "Entry"}}, - {"sys": {"id": "sibling-b", "type": "Link", "linkType": "Entry"}} - ]} - } - """) - let siblingA = try decodeEntry(""" - { - "sys": {"id": "sibling-a", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"name": "sibling A", "nested": [{"sys": {"id": "leaf", "type": "Link", "linkType": "Entry"}}]} - } - """) - let siblingB = try decodeEntry(""" - { - "sys": {"id": "sibling-b", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"name": "sibling B", "nested": [{"sys": {"id": "leaf", "type": "Link", "linkType": "Entry"}}]} - } - """) - let leaf = try decodeEntry(""" - { - "sys": {"id": "leaf", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"name": "leaf entry"} - } - """) - - let entriesMap = [ - "grandparent": grandparent, "sibling-a": siblingA, "sibling-b": siblingB, "leaf": leaf, - ] - for entry in [grandparent, siblingA, siblingB, leaf] { - entry.resolveLinks(against: entriesMap, and: [:]) - } - - let mapped = OptimizationEntryMapping.toOptimizationEntry(grandparent) - let nested = (mapped["fields"] as? [String: Any])?["nested"] as? [[String: Any]] - XCTAssertEqual(nested?.count, 2) - - for (index, expectedId, expectedName) in [(0, "sibling-a", "sibling A"), (1, "sibling-b", "sibling B")] { - let sibling = nested?[index] - XCTAssertEqual((sibling?["sys"] as? [String: Any])?["id"] as? String, expectedId) - let siblingFields = sibling?["fields"] as? [String: Any] - XCTAssertEqual(siblingFields?["name"] as? String, expectedName) - - // The diamond: both siblings link to the same leaf. Scoping the ancestor guard to - // the current path (not a global visited set) must let the leaf expand fully under - // both, rather than treating the second sibling's reach to it as a cycle. - let siblingLeaf = (siblingFields?["nested"] as? [[String: Any]])?.first - XCTAssertEqual((siblingLeaf?["sys"] as? [String: Any])?["id"] as? String, "leaf") - let leafFields = siblingLeaf?["fields"] as? [String: Any] - XCTAssertEqual(leafFields?["name"] as? String, "leaf entry", "the shared leaf must fully expand under both siblings, not just the first") - } - } - - /// `testMultiLevelNestedEntriesExpandAtEveryLevel` only proves 3 levels expand; a depth-limit - /// bug (e.g. an accidental cap, or an off-by-one in how `ancestors` is threaded through each - /// recursive call) could still exist beyond that. This chains 5 levels - /// (l1 -> l2 -> l3 -> l4 -> l5) through a single-entry `child` link at each hop — not a - /// diamond or a cycle — to prove recursion itself has no hidden depth ceiling. - func testFiveLevelLinearChainExpandsAtEveryLevel() throws { - func entryJSON(level: Int) -> String { - let childField = level < 5 - ? """ - , "child": {"sys": {"id": "l\(level + 1)", "type": "Link", "linkType": "Entry"}} - """ - : "" - return """ - { - "sys": {"id": "l\(level)", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"name": "level \(level)"\(childField)} - } - """ - } - let levels = try (1 ... 5).map { try decodeEntry(entryJSON(level: $0)) } - let entriesMap = Dictionary(uniqueKeysWithValues: levels.map { ($0.id, $0) }) - for entry in levels { - entry.resolveLinks(against: entriesMap, and: [:]) - } - - let mapped = OptimizationEntryMapping.toOptimizationEntry(levels[0]) - - var fields = mapped["fields"] as? [String: Any] - for level in 1 ... 5 { - XCTAssertEqual(fields?["name"] as? String, "level \(level)", "level \(level) must have expanded, not stopped short") - let child = fields?["child"] as? [String: Any] - if level < 5 { - XCTAssertNotNil(child?["fields"], "level \(level + 1) must have expanded inline, not been left as an unresolved-link stub") - } - fields = child?["fields"] as? [String: Any] - } - } - - /// The existing cycle tests (`testSelfReferencingLinkDoesNotRecurseInfinitely`, - /// `testRichTextEmbeddedEntryCycleDoesNotRecurseInfinitely`) only cover a 2-node cycle - /// (parent <-> child). This proves the ancestor guard also terminates a longer cycle — - /// a -> b -> c -> a — where the back-edge closes several hops later rather than immediately, - /// so a bug that only checked the immediate parent (instead of the full `ancestors` path) - /// would not be caught by the 2-node case alone. - func testThreeNodeCycleDoesNotRecurseInfinitely() throws { - let a = try decodeEntry(""" - { - "sys": {"id": "a", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"next": {"sys": {"id": "b", "type": "Link", "linkType": "Entry"}}} - } - """) - let b = try decodeEntry(""" - { - "sys": {"id": "b", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"next": {"sys": {"id": "c", "type": "Link", "linkType": "Entry"}}} - } - """) - let c = try decodeEntry(""" - { - "sys": {"id": "c", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"next": {"sys": {"id": "a", "type": "Link", "linkType": "Entry"}}} - } - """) - - let entriesMap = ["a": a, "b": b, "c": c] - for entry in [a, b, c] { - entry.resolveLinks(against: entriesMap, and: [:]) - } - - // Must terminate — the assertions below are only reachable if it does. - let mapped = OptimizationEntryMapping.toOptimizationEntry(a) - - let bField = (mapped["fields"] as? [String: Any])?["next"] as? [String: Any] - XCTAssertEqual((bField?["sys"] as? [String: Any])?["id"] as? String, "b") - let cField = (bField?["fields"] as? [String: Any])?["next"] as? [String: Any] - XCTAssertEqual((cField?["sys"] as? [String: Any])?["id"] as? String, "c") - let backToA = (cField?["fields"] as? [String: Any])?["next"] as? [String: Any] - let backSys = backToA?["sys"] as? [String: Any] - XCTAssertEqual(backSys?["id"] as? String, "a") - XCTAssertEqual(backSys?["type"] as? String, "Link", "the back-edge closing a 3-node cycle must be an unresolved-link stub, not a full re-expansion") - XCTAssertNil(backToA?["fields"], "the cycle-closing back-edge must not have been expanded into a full entry map") - } - - // MARK: - Asset mapping - - func testResolvedAssetLinkMapsTitleAndURL() throws { - let entry = try decodeEntry(""" - { - "sys": {"id": "e1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"image": {"sys": {"id": "asset-1", "type": "Link", "linkType": "Asset"}}} - } - """) - let assetDecoder = JSONDecoder.withoutLocalizationContext() - assetDecoder.update(with: Self.localizationContext) - assetDecoder.userInfo[.init(rawValue: "linkResolverContext")!] = NSObject() - let asset = try assetDecoder.decode(Asset.self, from: Data(""" - { - "sys": {"id": "asset-1", "type": "Asset", "locale": "en-US"}, - "fields": {"title": "A photo", "file": {"fileName": "a.jpg", "contentType": "image/jpeg", - "details": {"size": 10}, "url": "//images.ctfassets.net/a.jpg"}} - } - """.utf8)) - - entry.resolveLinks(against: [:], and: ["asset-1": asset]) - - let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) - let image = (mapped["fields"] as? [String: Any])?["image"] as? [String: Any] - XCTAssertEqual((image?["sys"] as? [String: Any])?["id"] as? String, "asset-1") - let imageFields = image?["fields"] as? [String: Any] - XCTAssertEqual(imageFields?["title"] as? String, "A photo") - let file = imageFields?["file"] as? [String: Any] - XCTAssertEqual(file?["url"] as? String, "https://images.ctfassets.net/a.jpg") - } - - /// `Asset` exposes `description`, `file.contentType`, and `file.details.{size,image}` beyond - /// `title`/`file.url` — the previous mapping dropped all of them. This proves the full asset - /// shape survives, not just the two fields the minimal mapping used to surface. - func testResolvedAssetLinkMapsDescriptionContentTypeSizeAndImageDimensions() throws { - let entry = try decodeEntry(""" - { - "sys": {"id": "e1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"image": {"sys": {"id": "asset-1", "type": "Link", "linkType": "Asset"}}} - } - """) - let assetDecoder = JSONDecoder.withoutLocalizationContext() - assetDecoder.update(with: Self.localizationContext) - assetDecoder.userInfo[.init(rawValue: "linkResolverContext")!] = NSObject() - let asset = try assetDecoder.decode(Asset.self, from: Data(""" - { - "sys": {"id": "asset-1", "type": "Asset", "locale": "en-US"}, - "fields": {"title": "A photo", "description": "A scenic view", - "file": {"fileName": "a.jpg", "contentType": "image/jpeg", - "details": {"size": 1024, "image": {"width": 800, "height": 600}}, - "url": "//images.ctfassets.net/a.jpg"}} - } - """.utf8)) - - entry.resolveLinks(against: [:], and: ["asset-1": asset]) - - let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) - let imageFields = ((mapped["fields"] as? [String: Any])?["image"] as? [String: Any])?["fields"] as? [String: Any] - XCTAssertEqual(imageFields?["description"] as? String, "A scenic view") - let file = imageFields?["file"] as? [String: Any] - XCTAssertEqual(file?["fileName"] as? String, "a.jpg") - XCTAssertEqual(file?["contentType"] as? String, "image/jpeg") - let details = file?["details"] as? [String: Any] - XCTAssertEqual(details?["size"] as? Int, 1024) - let image = details?["image"] as? [String: Any] - XCTAssertEqual(image?["width"] as? Double, 800) - XCTAssertEqual(image?["height"] as? Double, 600) - } - - /// A non-image asset's `file.details` has no `image` key at all in a raw CDA response — this - /// proves the mapper omits the key rather than emitting `image: null` or a zeroed dimension. - func testResolvedAssetLinkWithoutDescriptionOrImageOmitsThoseKeys() throws { - let entry = try decodeEntry(""" - { - "sys": {"id": "e1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"attachment": {"sys": {"id": "asset-1", "type": "Link", "linkType": "Asset"}}} - } - """) - let assetDecoder = JSONDecoder.withoutLocalizationContext() - assetDecoder.update(with: Self.localizationContext) - assetDecoder.userInfo[.init(rawValue: "linkResolverContext")!] = NSObject() - let asset = try assetDecoder.decode(Asset.self, from: Data(""" - { - "sys": {"id": "asset-1", "type": "Asset", "locale": "en-US"}, - "fields": {"title": "A PDF", "file": {"fileName": "doc.pdf", "contentType": "application/pdf", - "details": {"size": 2048}, "url": "//assets.ctfassets.net/doc.pdf"}} - } - """.utf8)) - - entry.resolveLinks(against: [:], and: ["asset-1": asset]) - - let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) - let attachmentFields = ((mapped["fields"] as? [String: Any])?["attachment"] as? [String: Any])?["fields"] as? [String: Any] - XCTAssertNil(attachmentFields?["description"], "an asset with no description must omit the key, not emit an empty string or null") - let details = (attachmentFields?["file"] as? [String: Any])?["details"] as? [String: Any] - XCTAssertNil(details?["image"], "a non-image asset's details must omit the image key entirely") - XCTAssertEqual(details?["size"] as? Int, 2048) - } - - /// `Asset.file` is `nil` when a `select()` query excludes it, or the media is still - /// processing after upload — a raw CDA response's `fields` in that case carries no `file` key - /// at all. This proves the mapper falls back to `urlString` instead of crashing on - /// `asset.file`'s optional or emitting a `file` key shaped like `jsonFileMetadata`'s output - /// with missing pieces. - func testResolvedAssetLinkWithoutFileFallsBackToURLStringShape() throws { - let entry = try decodeEntry(""" - { - "sys": {"id": "e1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"image": {"sys": {"id": "asset-1", "type": "Link", "linkType": "Asset"}}} - } - """) - let assetDecoder = JSONDecoder.withoutLocalizationContext() - assetDecoder.update(with: Self.localizationContext) - assetDecoder.userInfo[.init(rawValue: "linkResolverContext")!] = NSObject() - // No "file" key at all — the shape a `select(fields: ["title"])` query or a - // still-processing upload produces. - let asset = try assetDecoder.decode(Asset.self, from: Data(""" - { - "sys": {"id": "asset-1", "type": "Asset", "locale": "en-US"}, - "fields": {"title": "Still processing"} - } - """.utf8)) - - entry.resolveLinks(against: [:], and: ["asset-1": asset]) - - let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) - let imageFields = ((mapped["fields"] as? [String: Any])?["image"] as? [String: Any])?["fields"] as? [String: Any] - XCTAssertEqual(imageFields?["title"] as? String, "Still processing") - let file = imageFields?["file"] as? [String: Any] - XCTAssertEqual(file?["url"] as? String, "", "with no file metadata, the mapper must still emit a file.url key (empty), matching the pre-existing fallback shape") - XCTAssertNil(file?["fileName"], "the fallback shape must not claim fileName/contentType/details it doesn't have") - } - - // MARK: - Location - - func testLocationFieldMapsToLatLon() throws { - let entry = try decodeEntry(""" - { - "sys": {"id": "e1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"place": {"lat": 51.5, "lon": -0.12}} - } - """) - - let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) - let place = (mapped["fields"] as? [String: Any])?["place"] as? [String: Any] - XCTAssertEqual(place?["lat"] as? Double, 51.5) - XCTAssertEqual(place?["lon"] as? Double, -0.12) - } - - // MARK: - Rich text - - /// Plain Structured Text nodes (paragraph, text-with-marks, hyperlink) must round-trip - /// through the mapper, not just links/assets. If `RichTextDocument` had no case in - /// `jsonValue`, the entire field would silently vanish — this is the regression that case - /// closes. - func testRichTextPlainNodesMapToNodeTree() throws { - let entry = try decodeEntry(""" - { - "sys": {"id": "e1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"body": { - "nodeType": "document", "data": {}, - "content": [ - {"nodeType": "paragraph", "data": {}, "content": [ - {"nodeType": "text", "value": "Hello ", "marks": [], "data": {}}, - {"nodeType": "text", "value": "world", "marks": [{"type": "bold"}], "data": {}} - ]}, - {"nodeType": "hyperlink", "data": {"uri": "https://example.com"}, "content": [ - {"nodeType": "text", "value": "click", "marks": [], "data": {}} - ]} - ] - }} - } - """) - - let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) - let body = (mapped["fields"] as? [String: Any])?["body"] as? [String: Any] - XCTAssertEqual(body?["nodeType"] as? String, "document") - - let content = body?["content"] as? [[String: Any]] - XCTAssertEqual(content?.count, 2) - - let paragraph = content?[0] - XCTAssertEqual(paragraph?["nodeType"] as? String, "paragraph") - let paragraphContent = paragraph?["content"] as? [[String: Any]] - XCTAssertEqual(paragraphContent?[0]["value"] as? String, "Hello ") - XCTAssertEqual(paragraphContent?[1]["value"] as? String, "world") - let marks = paragraphContent?[1]["marks"] as? [[String: Any]] - XCTAssertEqual(marks?.first?["type"] as? String, "bold") - - let hyperlink = content?[1] - XCTAssertEqual(hyperlink?["nodeType"] as? String, "hyperlink") - XCTAssertEqual((hyperlink?["data"] as? [String: Any])?["uri"] as? String, "https://example.com") - let hyperlinkContent = hyperlink?["content"] as? [[String: Any]] - XCTAssertEqual(hyperlinkContent?.first?["value"] as? String, "click") - } - - /// The one case this whole addition exists for: an embedded entry inside rich text that the - /// Delivery SDK *did* resolve must expand inline — same as a top-level resolved link — not - /// disappear. - func testResolvedEmbeddedEntryBlockExpandsInline() throws { - let parent = try decodeEntry(""" - { - "sys": {"id": "parent", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"body": { - "nodeType": "document", "data": {}, - "content": [ - {"nodeType": "embedded-entry-block", - "data": {"target": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}}, - "content": []} - ] - }} - } - """) - let child = try decodeEntry(""" - { - "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"name": "embedded child"} - } - """) - - parent.resolveLinks(against: ["parent": parent, "child-1": child], and: [:]) - - let mapped = OptimizationEntryMapping.toOptimizationEntry(parent) - let body = (mapped["fields"] as? [String: Any])?["body"] as? [String: Any] - let embeddedBlock = (body?["content"] as? [[String: Any]])?.first - XCTAssertEqual(embeddedBlock?["nodeType"] as? String, "embedded-entry-block") - - let target = (embeddedBlock?["data"] as? [String: Any])?["target"] as? [String: Any] - XCTAssertEqual((target?["sys"] as? [String: Any])?["id"] as? String, "child-1") - let targetFields = target?["fields"] as? [String: Any] - XCTAssertEqual(targetFields?["name"] as? String, "embedded child", "a resolved embedded entry must expand inline, matching a top-level resolved link") - XCTAssertNotNil(target?["metadata"], "an expanded embedded entry must carry metadata, same as any other expanded entry") - } - - /// The other case that must not be dropped: an embedded entry the Delivery SDK could *not* - /// resolve (e.g. unpublished, or outside the query's `include` depth) must still surface as - /// an unresolved-link stub — not vanish, and not be confused with the resolved case above. - func testUnresolvedEmbeddedEntryBlockEmitsStubNotOmission() throws { - let entry = try decodeEntry(""" - { - "sys": {"id": "e1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"body": { - "nodeType": "document", "data": {}, - "content": [ - {"nodeType": "embedded-entry-block", - "data": {"target": {"sys": {"id": "missing-1", "type": "Link", "linkType": "Entry"}}}, - "content": []} - ] - }} - } - """) - // Deliberately not calling resolveLinks — no candidate entries were ever supplied, the - // shape a query with insufficient `include` depth or an unpublished target produces. - - let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) - let body = (mapped["fields"] as? [String: Any])?["body"] as? [String: Any] - let embeddedBlock = (body?["content"] as? [[String: Any]])?.first - XCTAssertNotNil(embeddedBlock, "an unresolved embedded entry must still appear as a node — not be silently dropped from content") - XCTAssertEqual(embeddedBlock?["nodeType"] as? String, "embedded-entry-block") - - let target = (embeddedBlock?["data"] as? [String: Any])?["target"] as? [String: Any] - XCTAssertEqual((target?["sys"] as? [String: Any])?["id"] as? String, "missing-1") - XCTAssertEqual((target?["sys"] as? [String: Any])?["linkType"] as? String, "Entry") - XCTAssertNil(target?["fields"], "an unresolved target must be a link stub, not an expanded entry") - } - - /// Same resolved/unresolved distinction, but for an embedded *asset* rather than an entry — - /// a separate code path (`.asset` vs `.entry`/`.unresolved` in `jsonLink`) that must not be - /// conflated with the entry case above. - func testResolvedEmbeddedAssetBlockExpandsWithTitleAndURL() throws { - let entry = try decodeEntry(""" - { - "sys": {"id": "e1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"body": { - "nodeType": "document", "data": {}, - "content": [ - {"nodeType": "embedded-asset-block", - "data": {"target": {"sys": {"id": "asset-1", "type": "Link", "linkType": "Asset"}}}, - "content": []} - ] - }} - } - """) - let assetDecoder = JSONDecoder.withoutLocalizationContext() - assetDecoder.update(with: Self.localizationContext) - assetDecoder.userInfo[.init(rawValue: "linkResolverContext")!] = NSObject() - let asset = try assetDecoder.decode(Asset.self, from: Data(""" - { - "sys": {"id": "asset-1", "type": "Asset", "locale": "en-US"}, - "fields": {"title": "An image", "file": {"fileName": "b.png", "contentType": "image/png", - "details": {"size": 20}, "url": "//images.ctfassets.net/b.png"}} - } - """.utf8)) - - entry.resolveLinks(against: [:], and: ["asset-1": asset]) - - let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) - let body = (mapped["fields"] as? [String: Any])?["body"] as? [String: Any] - let embeddedBlock = (body?["content"] as? [[String: Any]])?.first - let target = (embeddedBlock?["data"] as? [String: Any])?["target"] as? [String: Any] - XCTAssertEqual((target?["sys"] as? [String: Any])?["id"] as? String, "asset-1") - let targetFields = target?["fields"] as? [String: Any] - XCTAssertEqual(targetFields?["title"] as? String, "An image") - let file = targetFields?["file"] as? [String: Any] - XCTAssertEqual(file?["url"] as? String, "https://images.ctfassets.net/b.png") - } - - /// Embedded-entry-*inline* (a different Swift type, `ResourceLinkInline`, from the block - /// variant tested above) must also expand a resolved target, proving the inline node-type - /// branch isn't just a copy-paste of the block branch that happens to compile. - func testResolvedEmbeddedEntryInlineExpandsInline() throws { - let parent = try decodeEntry(""" - { - "sys": {"id": "parent", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"body": { - "nodeType": "document", "data": {}, - "content": [ - {"nodeType": "paragraph", "data": {}, "content": [ - {"nodeType": "embedded-entry-inline", - "data": {"target": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}}, - "content": []} - ]} - ] - }} - } - """) - let child = try decodeEntry(""" - { - "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"name": "inline child"} - } - """) - - parent.resolveLinks(against: ["parent": parent, "child-1": child], and: [:]) - - let mapped = OptimizationEntryMapping.toOptimizationEntry(parent) - let body = (mapped["fields"] as? [String: Any])?["body"] as? [String: Any] - let paragraph = (body?["content"] as? [[String: Any]])?.first - let inlineNode = (paragraph?["content"] as? [[String: Any]])?.first - XCTAssertEqual(inlineNode?["nodeType"] as? String, "embedded-entry-inline") - let target = (inlineNode?["data"] as? [String: Any])?["target"] as? [String: Any] - XCTAssertEqual((target?["fields"] as? [String: Any])?["name"] as? String, "inline child") - } - - /// `entry-hyperlink` and `asset-hyperlink` decode to the same `ResourceLinkInline` Swift type - /// as `embedded-entry-inline` (confirmed against a real decode — `NodeType.type` maps all - /// three to `ResourceLinkInline.self`), so `jsonNode`'s type-based switch already covers them - /// without a dedicated case. This test proves that's actually true for `entry-hyperlink` - /// specifically, not just architecturally plausible — a hyperlink-to-an-entry is a distinct - /// authoring action from an embedded block, and CDA gives it a different `nodeType` string. - func testEntryHyperlinkExpandsResolvedTargetInline() throws { - let parent = try decodeEntry(""" - { - "sys": {"id": "parent", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"body": { - "nodeType": "document", "data": {}, - "content": [ - {"nodeType": "paragraph", "data": {}, "content": [ - {"nodeType": "entry-hyperlink", - "data": {"target": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}}, - "content": [{"nodeType": "text", "value": "link text", "marks": [], "data": {}}]} - ]} - ] - }} - } - """) - let child = try decodeEntry(""" - { - "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"name": "linked child"} - } - """) - - parent.resolveLinks(against: ["parent": parent, "child-1": child], and: [:]) - - let mapped = OptimizationEntryMapping.toOptimizationEntry(parent) - let body = (mapped["fields"] as? [String: Any])?["body"] as? [String: Any] - let paragraph = (body?["content"] as? [[String: Any]])?.first - let hyperlinkNode = (paragraph?["content"] as? [[String: Any]])?.first - XCTAssertEqual(hyperlinkNode?["nodeType"] as? String, "entry-hyperlink") - let target = (hyperlinkNode?["data"] as? [String: Any])?["target"] as? [String: Any] - XCTAssertEqual((target?["fields"] as? [String: Any])?["name"] as? String, "linked child") - let hyperlinkContent = hyperlinkNode?["content"] as? [[String: Any]] - XCTAssertEqual(hyperlinkContent?.first?["value"] as? String, "link text") - } - - /// `asset-hyperlink` — same `ResourceLinkInline` type, distinct `nodeType`, unresolved this - /// time (mirrors the unresolved-embedded-entry test's point: neither hyperlink variant should - /// be assumed resolved). - func testAssetHyperlinkEmitsUnresolvedStubWhenNotResolved() throws { - let entry = try decodeEntry(""" - { - "sys": {"id": "e1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"body": { - "nodeType": "document", "data": {}, - "content": [ - {"nodeType": "paragraph", "data": {}, "content": [ - {"nodeType": "asset-hyperlink", - "data": {"target": {"sys": {"id": "asset-1", "type": "Link", "linkType": "Asset"}}}, - "content": [{"nodeType": "text", "value": "asset link", "marks": [], "data": {}}]} - ]} - ] - }} - } - """) - // Not calling resolveLinks — no asset candidates supplied. - - let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) - let body = (mapped["fields"] as? [String: Any])?["body"] as? [String: Any] - let paragraph = (body?["content"] as? [[String: Any]])?.first - let hyperlinkNode = (paragraph?["content"] as? [[String: Any]])?.first - XCTAssertEqual(hyperlinkNode?["nodeType"] as? String, "asset-hyperlink") - let target = (hyperlinkNode?["data"] as? [String: Any])?["target"] as? [String: Any] - XCTAssertEqual((target?["sys"] as? [String: Any])?["id"] as? String, "asset-1") - XCTAssertNil(target?["fields"], "an unresolved asset-hyperlink target must be a stub, not an expanded asset") - } - - /// Confirmed via a real decode (scratch probe, since removed) that a rich text field - /// embedding an entry which itself has a rich text field is a real, reachable shape — not - /// hypothetical. This proves the mapper's field-recursion and node-recursion compose across - /// that boundary: an embedded entry's own rich text field must expand, not just its plain - /// fields (already covered by `testResolvedEmbeddedEntryBlockExpandsInline`). - func testRichTextInsideEmbeddedEntryFieldsAlsoExpands() throws { - let parent = try decodeEntry(""" - { - "sys": {"id": "parent", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"body": { - "nodeType": "document", "data": {}, - "content": [ - {"nodeType": "embedded-entry-block", - "data": {"target": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}}, - "content": []} - ] - }} - } - """) - let child = try decodeEntry(""" - { - "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"nestedBody": { - "nodeType": "document", "data": {}, - "content": [ - {"nodeType": "paragraph", "data": {}, "content": [ - {"nodeType": "text", "value": "nested rich text", "marks": [], "data": {}} - ]} - ] - }} - } - """) - - parent.resolveLinks(against: ["parent": parent, "child-1": child], and: [:]) - - let mapped = OptimizationEntryMapping.toOptimizationEntry(parent) - let body = (mapped["fields"] as? [String: Any])?["body"] as? [String: Any] - let embeddedBlock = (body?["content"] as? [[String: Any]])?.first - let target = (embeddedBlock?["data"] as? [String: Any])?["target"] as? [String: Any] - let targetFields = target?["fields"] as? [String: Any] - - let nestedBody = targetFields?["nestedBody"] as? [String: Any] - XCTAssertEqual(nestedBody?["nodeType"] as? String, "document", "an embedded entry's own rich text field must also expand, not just its plain fields") - let nestedParagraph = (nestedBody?["content"] as? [[String: Any]])?.first - let nestedText = (nestedParagraph?["content"] as? [[String: Any]])?.first - XCTAssertEqual(nestedText?["value"] as? String, "nested rich text") - } - - /// Confirmed via a real decode (scratch probe, since removed) that this is a genuine object - /// graph cycle, not a hypothetical one: after `resolveLinks`, the child's back-reference to - /// the parent inside rich text resolves to `.entry(parent)`, an actual `Entry` reference — - /// recursing it without the ancestor guard would loop forever. This is the rich-text - /// counterpart to `testSelfReferencingLinkDoesNotRecurseInfinitely` (which only covers a - /// plain top-level field link), proving the same guard also holds across the - /// field-recursion/node-recursion boundary rich text introduces. - func testRichTextEmbeddedEntryCycleDoesNotRecurseInfinitely() throws { - let parent = try decodeEntry(""" - { - "sys": {"id": "parent", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"body": { - "nodeType": "document", "data": {}, - "content": [ - {"nodeType": "embedded-entry-block", - "data": {"target": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}}, - "content": []} - ] - }} - } - """) - let child = try decodeEntry(""" - { - "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"body": { - "nodeType": "document", "data": {}, - "content": [ - {"nodeType": "embedded-entry-block", - "data": {"target": {"sys": {"id": "parent", "type": "Link", "linkType": "Entry"}}}, - "content": []} - ] - }} - } - """) - - let entriesMap = ["parent": parent, "child-1": child] - parent.resolveLinks(against: entriesMap, and: [:]) - child.resolveLinks(against: entriesMap, and: [:]) - - // Must terminate — the assertions below are only reachable if it does. - let mapped = OptimizationEntryMapping.toOptimizationEntry(parent) - - let parentBody = (mapped["fields"] as? [String: Any])?["body"] as? [String: Any] - let embeddedChildBlock = (parentBody?["content"] as? [[String: Any]])?.first - let childTarget = (embeddedChildBlock?["data"] as? [String: Any])?["target"] as? [String: Any] - XCTAssertEqual((childTarget?["sys"] as? [String: Any])?["id"] as? String, "child-1") - - let childBody = (childTarget?["fields"] as? [String: Any])?["body"] as? [String: Any] - let embeddedBackBlock = (childBody?["content"] as? [[String: Any]])?.first - let backTarget = (embeddedBackBlock?["data"] as? [String: Any])?["target"] as? [String: Any] - XCTAssertEqual((backTarget?["sys"] as? [String: Any])?["id"] as? String, "parent") - XCTAssertEqual((backTarget?["sys"] as? [String: Any])?["type"] as? String, "Link", "the back-edge inside rich text must be an unresolved-link stub, not a full re-expansion") - XCTAssertNil(backTarget?["fields"], "the rich-text back-edge must not have been expanded into a full entry map") - } - - // MARK: - Date fields - - /// `contentful.swift`'s generic `[String: Any]` field decoder - /// (`Decodable.swift`'s `KeyedDecodingContainer.decode(_: [String: Any].Type)`) tries `Bool`, - /// then `String`, before any date-specific type. A Contentful "Date" field is a JSON string - /// (e.g. `"2024-06-15T12:30:00Z"`), so it is captured by the `String` branch and surfaces in - /// `entry.fields` as `String`, never as Swift `Date`. Verified empirically against a real - /// decode. This means `OptimizationEntryMapping`'s `case let date as Date` branch (ported - /// faithfully from `OptimizationAdapter.swift`, which has the same dead branch) can only ever - /// trigger for a `Date` placed into the dict programmatically — never for a field decoded - /// from a real CDA response. Documented here rather than silently dropped, since removing it - /// would diverge from the reference file without a call to do so. - func testDateLikeFieldDecodesAsPlainStringNotSwiftDate() throws { - let entry = try decodeEntry(""" - { - "sys": {"id": "e1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"publishDate": "2024-06-15T12:30:00Z"} - } - """) - - XCTAssertTrue(entry.fields["publishDate"] is String) - XCTAssertFalse(entry.fields["publishDate"] is Date) - - let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) - XCTAssertEqual((mapped["fields"] as? [String: Any])?["publishDate"] as? String, "2024-06-15T12:30:00Z") - } - - // MARK: - Unsupported values are dropped, not thrown - - func testUnsupportedFieldTypeIsDroppedNotThrown() throws { - let entry = try decodeEntry(""" - { - "sys": {"id": "e1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"title": "kept", "count": 3} - } - """) - - let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) - let fields = mapped["fields"] as? [String: Any] - XCTAssertEqual(fields?["title"] as? String, "kept") - XCTAssertEqual(fields?["count"] as? Int, 3) - } - - // MARK: - Asset.FileMetadata decoded directly as a field value - - /// A field of Contentful type "Object" shaped exactly like a file metadata blob decodes to - /// `Asset.FileMetadata` directly — no `Asset`/`Link` wrapper at all (contentful.swift's - /// generic `[String: Any]` field decoder tries `Asset.FileMetadata` before falling back to a - /// plain dictionary). This is distinct from `testResolvedAssetLinkMapsDescriptionContentTypeSizeAndImageDimensions`, - /// which covers the same shape arriving through a resolved asset *link* instead. - func testFileMetadataShapedObjectFieldMapsSameAsAssetFile() throws { - let entry = try decodeEntry(""" - { - "sys": {"id": "e1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"rawFile": {"fileName": "raw.png", "contentType": "image/png", - "details": {"size": 512, "image": {"width": 100, "height": 50}}, - "url": "//images.ctfassets.net/raw.png"}} - } - """) - - let mapped = OptimizationEntryMapping.toOptimizationEntry(entry) - let rawFile = (mapped["fields"] as? [String: Any])?["rawFile"] as? [String: Any] - XCTAssertEqual(rawFile?["fileName"] as? String, "raw.png") - XCTAssertEqual(rawFile?["contentType"] as? String, "image/png") - XCTAssertEqual(rawFile?["url"] as? String, "https://images.ctfassets.net/raw.png") - let details = rawFile?["details"] as? [String: Any] - XCTAssertEqual(details?["size"] as? Int, 512) - let image = details?["image"] as? [String: Any] - XCTAssertEqual(image?["width"] as? Double, 100) - XCTAssertEqual(image?["height"] as? Double, 50) - } -} diff --git a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizedEntryContentfulInitTests.swift b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizedEntryContentfulInitTests.swift index 2014fbe64..ff533505c 100644 --- a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizedEntryContentfulInitTests.swift +++ b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizedEntryContentfulInitTests.swift @@ -5,11 +5,12 @@ import SwiftUI import XCTest /// Tests `OptimizedEntry` itself at the `Contentful.Entry` initializer boundary — not the -/// standalone `OptimizationEntryMapping` function, but that the initializer actually wires the -/// mapped dict into the stored `entry` property and wraps the caller's `(ResolvedEntry) -> -/// Content` closure into the stored `([String: Any]) -> Content` shape `body` calls. Both -/// `entry` and `content` are internal (no access modifier), so `@testable import` reaches them -/// directly — no rendering harness or `OptimizationClient` environment needed for this layer. +/// standalone `CTEntry(_: Contentful.Entry)` function, but that the initializer actually wires +/// the mapped dict into the stored `entry` property and wraps the caller's +/// `(CTEntry) -> Content` closure into the stored `([String: Any]) -> Content` +/// shape `body` calls. Both `entry` and `content` are internal (no access modifier), so +/// `@testable import` reaches them directly — no rendering harness or `OptimizationClient` +/// environment needed for this layer. final class OptimizedEntryContentfulInitTests: XCTestCase { private static let localizationContext: LocalizationContext = { let localeJSON = Data(""" @@ -53,11 +54,11 @@ final class OptimizedEntryContentfulInitTests: XCTestCase { // MARK: - The initializer stores the mapped dict, not the raw Entry func testContentfulInitializerStoresMappedDictAsEntry() throws { - let sut = OptimizedEntry(entry: entry) { (_: ResolvedEntry) in EmptyView() } + let sut = OptimizedEntry(entry: entry) { (_: CTEntry) in EmptyView() } XCTAssertEqual( NSDictionary(dictionary: sut.entry), - NSDictionary(dictionary: OptimizationEntryMapping.toOptimizationEntry(entry)) + NSDictionary(dictionary: CTEntry(entry).toFoundation() as? [String: Any] ?? [:]) ) XCTAssertEqual((sut.entry["sys"] as? [String: Any])?["id"] as? String, "e1") XCTAssertNotNil(sut.entry["metadata"], "the always-present metadata guarantee must hold through the initializer, not just the mapper") @@ -66,9 +67,9 @@ final class OptimizedEntryContentfulInitTests: XCTestCase { // MARK: - The stored `content` closure forwards its actual argument, not the captured baseline entry func testStoredContentClosureForwardsResolvedVariantNotCapturedBaselineEntry() throws { - var received: ResolvedEntry? + var received: CTEntry? - func makeContent(for resolved: ResolvedEntry) -> SwiftUI.Text { + func makeContent(for resolved: CTEntry) -> SwiftUI.Text { received = resolved return SwiftUI.Text("rendered") } @@ -76,14 +77,14 @@ final class OptimizedEntryContentfulInitTests: XCTestCase { let sut = OptimizedEntry(entry: entry, content: makeContent) // Deliberately distinct from `entry`'s own id/title ("e1"/"Hello"), and fed through - // `sut.content` rather than reused from `OptimizationEntryMapping.toOptimizationEntry`. - // At runtime `body` calls the stored `content` with `result.entry` — the *resolved - // variant* a live OptimizationClient hands back, which for a personalized entry can - // genuinely differ from the baseline stored in `sut.entry`. A distinguishing value here - // is what actually proves the closure forwards its argument: if the wrapping closure had - // a bug like `{ _ in content(ResolvedEntry(OptimizationEntryMapping.toOptimizationEntry(entry))) }` - // — ignoring its parameter and re-deriving from the captured baseline entry instead — a - // same-shaped stand-in would pass by coincidence and this bug would go undetected. + // `sut.content` rather than reused from `CTEntry(_: Contentful.Entry)`. At runtime + // `body` calls the stored `content` with `result.entry` — the *resolved variant* a live + // OptimizationClient hands back, which for a personalized entry can genuinely differ from + // the baseline stored in `sut.entry`. A distinguishing value here is what actually proves + // the closure forwards its argument: if the wrapping closure had a bug like + // `{ _ in content(CTEntry(entry)) }` — ignoring its parameter and + // re-deriving from the captured baseline entry instead — a same-shaped stand-in would pass + // by coincidence and this bug would go undetected. let resolverOutput: [String: Any] = [ "sys": ["id": "resolved-1"], "fields": ["title": "Resolved Title"], @@ -103,7 +104,7 @@ final class OptimizedEntryContentfulInitTests: XCTestCase { let fromDict: OptimizedEntry = OptimizedEntry(entry: ["sys": ["id": "x"], "fields": [:]]) { _ in SwiftUI.Text("dict") } - let fromEntry: OptimizedEntry = OptimizedEntry(entry: entry) { (_: ResolvedEntry) in + let fromEntry: OptimizedEntry = OptimizedEntry(entry: entry) { (_: CTEntry) in SwiftUI.Text("entry") } @@ -118,7 +119,7 @@ final class OptimizedEntryContentfulInitTests: XCTestCase { // `body` takes the non-optimized branch, but that's an OptimizationClient-dependent path. // What's testable without rendering is that the mapped dict itself carries no // `nt_experiences` key, which is the input `isOptimized` reads. - let sut = OptimizedEntry(entry: entry) { (_: ResolvedEntry) in EmptyView() } + let sut = OptimizedEntry(entry: entry) { (_: CTEntry) in EmptyView() } let fields = sut.entry["fields"] as? [String: Any] XCTAssertNil(fields?["nt_experiences"]) @@ -127,22 +128,23 @@ final class OptimizedEntryContentfulInitTests: XCTestCase { // MARK: - onTap stays dict-typed on both initializers — by design, not by oversight /// `onTap` on the `Contentful.Entry` initializer is `(([String: Any]) -> Void)?` — the same - /// raw-dict shape as the dict-based initializer's `onTap`, *not* `((ResolvedEntry) -> Void)?` - /// like `content`. This looks like an asymmetry against `content`'s typed wrapping, but it - /// isn't one: `TapTrackingModifier.body(content:)` (`Tracking/TapTrackingModifier.swift`) - /// calls `onTap?(entry)` with the view's *baseline* `entry` — never `result.entry`, the - /// resolved variant `content` receives — on both initializers equally. `onTap` reports which - /// baseline entry was tapped, for tracking; `content` renders the resolved variant, for - /// display. Different roles, so no `ResolvedEntry` wrapping applies to `onTap` on either - /// initializer. This test pins that down so a future change to `onTap`'s type is a deliberate - /// decision, not a silent regression. + /// raw-dict shape as the dict-based initializer's `onTap`, *not* + /// `((CTEntry) -> Void)?` like `content`. This looks like an asymmetry against + /// `content`'s typed wrapping, but it isn't one: `TapTrackingModifier.body(content:)` + /// (`Tracking/TapTrackingModifier.swift`) calls `onTap?(entry)` with the view's *baseline* + /// `entry` — never `result.entry`, the resolved variant `content` receives — on both + /// initializers equally. `onTap` reports which baseline entry was tapped, for tracking; + /// `content` renders the resolved variant, for display. Different roles, so no + /// `CTEntry` wrapping applies to `onTap` on either initializer. This test pins + /// that down so a future change to `onTap`'s type is a deliberate decision, not a silent + /// regression. func testOnTapReceivesBaselineDictOnContentfulInitializerNotResolvedEntry() throws { var receivedOnTapArgument: [String: Any]? let sut = OptimizedEntry( entry: entry, onTap: { raw in receivedOnTapArgument = raw }, - content: { (_: ResolvedEntry) in EmptyView() } + content: { (_: CTEntry) in EmptyView() } ) // Exercises the same call `TapTrackingModifier` makes: `onTap?(entry)`, with the view's diff --git a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/ResolvedContentfulOptimizedEntryTests.swift b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/ResolvedContentfulOptimizedEntryTests.swift deleted file mode 100644 index 0fdaf71c4..000000000 --- a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/ResolvedContentfulOptimizedEntryTests.swift +++ /dev/null @@ -1,136 +0,0 @@ -@testable import Contentful -@testable import ContentfulOptimization -import Foundation -import XCTest - -/// Tests the `Contentful.Entry` overload of `resolveOptimizedEntry` — that it maps `baseline` -/// through `OptimizationEntryMapping` before delegating to the dict-based overload, and wraps the -/// dict-based result's `entry` in a `ResolvedEntry` rather than handing back a raw dict. Covers -/// both the not-initialized fail-soft path and a real round trip through the JS bridge, mirroring -/// `OptimizationClientTests.testResolveOptimizedEntryReturnsBaselineWhenNotInitialized` and -/// `testResolveOptimizedEntryPreservesFieldsWhenInitialized` for the dict-based overload. -final class ResolvedContentfulOptimizedEntryTests: XCTestCase { - private static let localizationContext: LocalizationContext = { - let localeJSON = Data(""" - {"code":"en-US","default":true,"name":"English","fallbackCode":null} - """.utf8) - let locale = try! JSONDecoder.withoutLocalizationContext().decode(Contentful.Locale.self, from: localeJSON) - return LocalizationContext(locales: [locale])! - }() - - private func decodeEntry(_ json: String) throws -> Entry { - let decoder = JSONDecoder.withoutLocalizationContext() - decoder.update(with: Self.localizationContext) - decoder.userInfo[.init(rawValue: "linkResolverContext")!] = NSObject() - return try decoder.decode(Entry.self, from: Data(json.utf8)) - } - - @MainActor - func testNotInitializedFallsBackToMappedBaselineEntry() throws { - let entry = try decodeEntry(""" - { - "sys": {"id": "entry-1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"title": "Default Title"} - } - """) - let client = OptimizationClient() - - let result = client.resolveOptimizedEntry(baseline: entry) - - XCTAssertEqual(result.entry.id, "entry-1") - XCTAssertEqual(result.entry.getField("title"), "Default Title") - XCTAssertNil(result.selectedOptimization) - XCTAssertNil(result.optimizationContextId) - } - - /// Proves this overload actually routes through `OptimizationEntryMapping` rather than some - /// other conversion: a resolved link on the baseline must come back expanded exactly as - /// `OptimizationEntryMapping.toOptimizationEntry` would produce it, readable via `getField`. - @MainActor - func testNotInitializedFallbackEntryHasLinksExpandedByOptimizationEntryMapping() throws { - let parent = try decodeEntry(""" - { - "sys": {"id": "parent", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"child": {"sys": {"id": "child-1", "type": "Link", "linkType": "Entry"}}} - } - """) - let child = try decodeEntry(""" - { - "sys": {"id": "child-1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"name": "child entry"} - } - """) - parent.resolveLinks(against: ["parent": parent, "child-1": child], and: [:]) - let client = OptimizationClient() - - let result = client.resolveOptimizedEntry(baseline: parent) - - let childField: [String: Any]? = result.entry.getField("child") - XCTAssertEqual((childField?["sys"] as? [String: Any])?["id"] as? String, "child-1") - XCTAssertEqual((childField?["fields"] as? [String: Any])?["name"] as? String, "child entry", "the resolved link must have expanded inline, matching OptimizationEntryMapping's own behavior") - } - - /// This overload must be a true *overload* of the existing method — same name, - /// `resolveOptimizedEntry`, resolved by Swift purely from the static type of `baseline` at the - /// call site (a dict picks the `OptimizationClient` member; a `Contentful.Entry` picks this - /// extension member) — not a differently-named method that merely does something similar. If - /// this file's declaration used a different name, both calls below would still compile, but - /// this test's *point* would be false; the identical call syntax below, returning provably - /// different result types, is what actually proves overload resolution picked two distinct - /// declarations rather than one generic one. - @MainActor - func testIsATrueOverloadResolvedByBaselineArgumentType() throws { - let entry = try decodeEntry(""" - { - "sys": {"id": "entry-1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "test", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"title": "Hello"} - } - """) - let dict: [String: Any] = ["sys": ["id": "entry-1"], "fields": ["title": "Hello"]] - let client = OptimizationClient() - - let dictResult: ResolvedOptimizedEntry = client.resolveOptimizedEntry(baseline: dict) - let entryResult: ResolvedContentfulOptimizedEntry = client.resolveOptimizedEntry(baseline: entry) - - XCTAssertEqual((dictResult.entry["sys"] as? [String: Any])?["id"] as? String, "entry-1") - XCTAssertEqual(entryResult.entry.id, "entry-1") - } - - // MARK: - Real bridge round trip (initialized client) - - /// The not-initialized tests above only prove the fallback path; they never exercise the - /// bridge call this overload actually delegates to. This round-trips a real `Contentful.Entry` - /// through an initialized client's JS bridge (mirroring - /// `OptimizationClientTests.testResolveOptimizedEntryPreservesFieldsWhenInitialized`, the - /// dict-based overload's equivalent test) and confirms fields survive and are readable via - /// `getField` on the returned `ResolvedEntry` — not just that the mapping step alone works. - @MainActor - func testInitializedClientRoundTripsFieldsThroughRealBridge() throws { - let entry = try decodeEntry(""" - { - "sys": {"id": "entry1", "type": "Entry", "locale": "en-US", - "contentType": {"sys": {"id": "page", "type": "Link", "linkType": "ContentType"}}}, - "fields": {"title": "Hello", "slug": "hello-world"} - } - """) - let client = OptimizationClient() - let config = OptimizationConfig( - clientId: "test-client", - environment: "master", - api: OptimizationApiConfig( - experienceBaseUrl: "http://localhost:8000/experience/", - insightsBaseUrl: "http://localhost:8000/insights/" - ) - ) - try client.initialize(config: config) - - let result = client.resolveOptimizedEntry(baseline: entry) - - XCTAssertEqual(result.entry.getField("title"), "Hello", "the entry must actually round-trip through the JS bridge, not just fall back to the pre-mapped baseline") - XCTAssertEqual(result.entry.getField("slug"), "hello-world") - } -} diff --git a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/ResolvedEntryTests.swift b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/ResolvedEntryTests.swift deleted file mode 100644 index b33551492..000000000 --- a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/ResolvedEntryTests.swift +++ /dev/null @@ -1,129 +0,0 @@ -@testable import ContentfulOptimization -import XCTest - -/// Direct unit tests for `ResolvedEntry` in isolation — the happy path is already exercised -/// indirectly through `OptimizedEntryContentfulInitTests`, but the absent/wrong-type cases (a -/// resolver output missing `sys`/`fields`, or a field read back as the wrong type) have no -/// coverage anywhere else. -final class ResolvedEntryTests: XCTestCase { - func testGetFieldReturnsValueForMatchingType() { - let resolved = ResolvedEntry([ - "sys": ["id": "e1"], - "fields": ["title": "Hello", "count": 3, "isFeatured": true], - ]) - - XCTAssertEqual(resolved.getField("title"), "Hello") - XCTAssertEqual(resolved.getField("count"), 3) - XCTAssertEqual(resolved.getField("isFeatured"), true) - } - - func testGetFieldReturnsNilForWrongRequestedType() { - // "count" is an Int in the raw map; requesting it as String must fail the `as?` cast and - // return nil, not crash or coerce. - let resolved = ResolvedEntry(["sys": [:], "fields": ["count": 3]]) - - let asString: String? = resolved.getField("count") - XCTAssertNil(asString) - } - - func testGetFieldReturnsNilForAbsentField() { - let resolved = ResolvedEntry(["sys": [:], "fields": ["title": "Hello"]]) - - let missing: String? = resolved.getField("subtitle") - XCTAssertNil(missing) - } - - func testGetFieldReturnsNilWhenFieldsKeyIsAbsent() { - // No "fields" key at all — e.g. a malformed or partial resolver output. - let resolved = ResolvedEntry(["sys": ["id": "e1"]]) - - let value: String? = resolved.getField("title") - XCTAssertNil(value) - } - - func testIdReturnsSysId() { - let resolved = ResolvedEntry(["sys": ["id": "e1"], "fields": [:]]) - - XCTAssertEqual(resolved.id, "e1") - } - - func testIdReturnsNilWhenSysKeyIsAbsent() { - let resolved = ResolvedEntry(["fields": ["title": "Hello"]]) - - XCTAssertNil(resolved.id) - } - - func testIdReturnsNilWhenSysIdIsWrongType() { - // "id" present but not a String — e.g. accidentally passed a number. - let resolved = ResolvedEntry(["sys": ["id": 123], "fields": [:]]) - - XCTAssertNil(resolved.id) - } - - // MARK: - localeCode mirrors Entry.localeCode - - func testLocaleCodeReturnsSysLocale() { - let resolved = ResolvedEntry(["sys": ["id": "e1", "locale": "en-US"], "fields": [:]]) - - XCTAssertEqual(resolved.localeCode, "en-US") - } - - func testLocaleCodeReturnsNilWhenAbsent() { - // Absent on a raw CDA response fetched via /sync or the wildcard `locale=*` query — - // same case where `Entry.localeCode` itself returns nil. - let resolved = ResolvedEntry(["sys": ["id": "e1"], "fields": [:]]) - - XCTAssertNil(resolved.localeCode) - } - - // MARK: - createdAt/updatedAt mirror Entry.createdAt/updatedAt - - func testCreatedAtAndUpdatedAtParseISO8601SysTimestamps() { - let resolved = ResolvedEntry([ - "sys": ["id": "e1", "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-06-15T12:30:00Z"], - "fields": [:], - ]) - - XCTAssertNotNil(resolved.createdAt) - XCTAssertNotNil(resolved.updatedAt) - XCTAssertNotEqual(resolved.createdAt, resolved.updatedAt) - } - - func testCreatedAtAndUpdatedAtReturnNilWhenAbsent() { - // A resolver-synthesized entry may carry no creation/update timestamps — same as - // `Entry.createdAt`/`updatedAt` returning nil for a resource fetched without `sys` dates. - let resolved = ResolvedEntry(["sys": ["id": "e1"], "fields": [:]]) - - XCTAssertNil(resolved.createdAt) - XCTAssertNil(resolved.updatedAt) - } - - func testCreatedAtReturnsNilForUnparseableTimestamp() { - let resolved = ResolvedEntry(["sys": ["id": "e1", "createdAt": "not-a-date"], "fields": [:]]) - - XCTAssertNil(resolved.createdAt) - } - - // MARK: - String/Int subscripts mirror Entry's convenience subscripts - - func testStringSubscriptReadsFromFields() { - let resolved = ResolvedEntry(["sys": [:], "fields": ["title": "Hello"]]) - - let title: String? = resolved["title"] - XCTAssertEqual(title, "Hello") - } - - func testIntSubscriptReadsFromFields() { - let resolved = ResolvedEntry(["sys": [:], "fields": ["count": 3]]) - - let count: Int? = resolved["count"] - XCTAssertEqual(count, 3) - } - - func testStringSubscriptReturnsNilForWrongType() { - let resolved = ResolvedEntry(["sys": [:], "fields": ["count": 3]]) - - let asString: String? = resolved["count"] - XCTAssertNil(asString) - } -} From 77ecc31cc1e9a49167ce0801311141e6eb33583b Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Fri, 31 Jul 2026 00:51:52 +0200 Subject: [PATCH 07/21] refactor(swift): use CTEntry for OptimizedEntry's stored baseline entry [NT-3808] Store OptimizedEntry.entry as CTEntry instead of [String: Any] so the non-optimized rendering path reads through the same getField/id surface as the optimized path, rather than a raw dict. Also fixes a real bug this surfaced: getField with T inferred as Any returns a non-nil Optional(nil) for a missing field (nil as? Any always succeeds), so isOptimized now checks presence via toFoundation() instead. Co-Authored-By: Claude Sonnet 5 --- .../Contentful/CTEntry.swift | 4 ++++ .../Views/OptimizedEntry.swift | 23 +++++++++++-------- .../OptimizedEntryContentfulInitTests.swift | 15 ++++++------ 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift index bedc57f62..194862718 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift @@ -151,6 +151,10 @@ public struct CTEntry { } /// A field's resolved value, or nil if absent. + /// + /// Do not call this with `T` inferred as `Any` (or `Any?`) to check presence — `nil as? Any` + /// always succeeds, so a missing field comes back as a non-nil `Optional(nil)` rather than + /// `nil`. Check presence via `toFoundation()` instead, or infer a concrete `T`. public func getField(_ name: String) -> T? { self["fields"]?[name]?.toFoundation() as? T } diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift index 25b040ee2..54da2f742 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift @@ -17,7 +17,7 @@ import SwiftUI /// } /// ``` public struct OptimizedEntry: View { - let entry: [String: Any] + let entry: CTEntry let dwellTimeMs: Int let minVisibleRatio: Double let viewDurationUpdateIntervalMs: Int @@ -47,7 +47,7 @@ public struct OptimizedEntry: View { onTap: (([String: Any]) -> Void)? = nil, @ViewBuilder content: @escaping ([String: Any]) -> Content ) { - self.entry = entry + self.entry = (try? CTEntry(any: entry)) ?? (try! CTEntry(any: [String: Any]())) self.dwellTimeMs = dwellTimeMs self.minVisibleRatio = minVisibleRatio self.viewDurationUpdateIntervalMs = viewDurationUpdateIntervalMs @@ -62,8 +62,7 @@ public struct OptimizedEntry: View { /// Accepts a `contentful.swift` `Entry` directly, encoding it to the `{sys, fields, metadata}` /// shape the resolver expects (see `CTEntry(_: Contentful.Entry)`) and handing the resolved /// variant back through `CTEntry` — `getField`, not `as?` casts on a raw map. - /// The encoding happens once, here, at construction — `content` itself stays dict-shaped - /// internally so `body` doesn't need to know which initializer built this instance. + /// The encoding happens once, here, at construction. public init( entry: Contentful.Entry, dwellTimeMs: Int = 2000, @@ -76,7 +75,7 @@ public struct OptimizedEntry: View { onTap: (([String: Any]) -> Void)? = nil, @ViewBuilder content: @escaping (CTEntry) -> Content ) { - self.entry = CTEntry(entry).toFoundation() as? [String: Any] ?? [:] + self.entry = CTEntry(entry) self.dwellTimeMs = dwellTimeMs self.minVisibleRatio = minVisibleRatio self.viewDurationUpdateIntervalMs = viewDurationUpdateIntervalMs @@ -89,7 +88,10 @@ public struct OptimizedEntry: View { } private var isOptimized: Bool { - guard let fields = entry["fields"] as? [String: Any] else { return false } + // Not `entry.getField("nt_experiences")` — `getField` with `T` inferred as `Any` + // returns a non-nil `Optional(nil)` for a missing field (`nil as? Any` always + // succeeds), so an `Any?` read can't distinguish "absent" from "present but nil". + guard let fields = (entry.toFoundation() as? [String: Any])?["fields"] as? [String: Any] else { return false } return fields["nt_experiences"] != nil } @@ -117,15 +119,16 @@ public struct OptimizedEntry: View { } public var body: some View { + let entryDict = entry.toFoundation() as? [String: Any] ?? [:] let result: ResolvedOptimizedEntry = { if isOptimized { return client.resolveOptimizedEntry( - baseline: entry, + baseline: entryDict, selectedOptimizations: effectiveOptimizations ) } else { return ResolvedOptimizedEntry( - entry: (try? CTEntry(any: entry)) ?? (try! CTEntry(any: [String: Any]())), + entry: entry, selectedOptimization: nil, optimizationContextId: nil ) @@ -134,7 +137,7 @@ public struct OptimizedEntry: View { content(result.entry.toFoundation() as? [String: Any] ?? [:]) .modifier(ViewTrackingModifier( - entry: entry, + entry: entryDict, optimizationContextId: result.optimizationContextId, selectedOptimization: result.selectedOptimization, minVisibleRatio: minVisibleRatio, @@ -144,7 +147,7 @@ public struct OptimizedEntry: View { client: client )) .modifier(TapTrackingModifier( - entry: entry, + entry: entryDict, optimizationContextId: result.optimizationContextId, selectedOptimization: result.selectedOptimization, enabled: tapsEnabled, diff --git a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizedEntryContentfulInitTests.swift b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizedEntryContentfulInitTests.swift index ff533505c..738f61ce0 100644 --- a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizedEntryContentfulInitTests.swift +++ b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizedEntryContentfulInitTests.swift @@ -57,11 +57,12 @@ final class OptimizedEntryContentfulInitTests: XCTestCase { let sut = OptimizedEntry(entry: entry) { (_: CTEntry) in EmptyView() } XCTAssertEqual( - NSDictionary(dictionary: sut.entry), + NSDictionary(dictionary: sut.entry.toFoundation() as? [String: Any] ?? [:]), NSDictionary(dictionary: CTEntry(entry).toFoundation() as? [String: Any] ?? [:]) ) - XCTAssertEqual((sut.entry["sys"] as? [String: Any])?["id"] as? String, "e1") - XCTAssertNotNil(sut.entry["metadata"], "the always-present metadata guarantee must hold through the initializer, not just the mapper") + XCTAssertEqual(sut.entry.id, "e1") + let dict = sut.entry.toFoundation() as? [String: Any] + XCTAssertNotNil(dict?["metadata"], "the always-present metadata guarantee must hold through the initializer, not just the mapper") } // MARK: - The stored `content` closure forwards its actual argument, not the captured baseline entry @@ -108,8 +109,8 @@ final class OptimizedEntryContentfulInitTests: XCTestCase { SwiftUI.Text("entry") } - XCTAssertEqual((fromDict.entry["sys"] as? [String: Any])?["id"] as? String, "x") - XCTAssertEqual((fromEntry.entry["sys"] as? [String: Any])?["id"] as? String, "e1") + XCTAssertEqual(fromDict.entry.id, "x") + XCTAssertEqual(fromEntry.entry.id, "e1") } // MARK: - Non-optimized entries: the Contentful.Entry initializer still round-trips through body's baseline path @@ -121,7 +122,7 @@ final class OptimizedEntryContentfulInitTests: XCTestCase { // `nt_experiences` key, which is the input `isOptimized` reads. let sut = OptimizedEntry(entry: entry) { (_: CTEntry) in EmptyView() } - let fields = sut.entry["fields"] as? [String: Any] + let fields = (sut.entry.toFoundation() as? [String: Any])?["fields"] as? [String: Any] XCTAssertNil(fields?["nt_experiences"]) } @@ -149,7 +150,7 @@ final class OptimizedEntryContentfulInitTests: XCTestCase { // Exercises the same call `TapTrackingModifier` makes: `onTap?(entry)`, with the view's // own stored baseline `entry` (`sut.entry`) — not a resolved variant. - sut.onTap?(sut.entry) + sut.onTap?(sut.entry.toFoundation() as? [String: Any] ?? [:]) XCTAssertEqual((receivedOnTapArgument?["sys"] as? [String: Any])?["id"] as? String, "e1") } From 6dd903bbf18a4997565156a896f2cac4a623cdc1 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Fri, 31 Jul 2026 01:29:27 +0200 Subject: [PATCH 08/21] refactor(swift): back CTEntry with the typed CDA.EntryEnvelope contract Replace CTEntry's private let json: JSONValue storage with CDA.EntryEnvelope, so the type is backed by the same typed {sys, fields, metadata} contract CDA.EntryEnvelope.from already builds from a Contentful.Entry, instead of an untyped JSON tree. Sys/EntryEnvelope decode sys/fields/metadata (and each of Sys's own properties) independently via try?, so a caller-supplied baseline that's missing or has a wrong-typed key degrades only that piece to nil rather than throwing and losing the whole entry - matching this type's existing "lose a field, not the entry" policy and every behavior the prior JSONValue-backed tests already pinned. init(any:) keeps its parseValue validation walk rather than calling JSONSerialization.data(withJSONObject:) directly on unvalidated input: that API raises an uncaught NSException (not a catchable Error) on a type it can't serialize, e.g. Date, which would crash the process instead of throwing. Co-Authored-By: Claude Sonnet 5 --- .../Contentful/CTEntry.swift | 164 +++++++++++------- 1 file changed, 104 insertions(+), 60 deletions(-) diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift index 194862718..bd00daa2b 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift @@ -2,26 +2,26 @@ import Contentful import Foundation /// Both directions of the `Contentful.Entry <-> JSON` boundary `OptimizedEntry` and -/// `OptimizationClient.resolveOptimizedEntry` need, wrapping the package's existing `JSONValue` -/// AST rather than a hand-built `[String: Any]` dictionary read back with `as?` casts: +/// `OptimizationClient.resolveOptimizedEntry` need, backed by `CDA.EntryEnvelope` — a typed +/// `{sys, fields, metadata}` contract — rather than a hand-built `[String: Any]` dictionary read +/// back with `as?` casts: /// /// - **Encode**: `CTEntry(_: Contentful.Entry)` builds the `{sys, fields, metadata}` tree a /// `Contentful.Entry` maps to, reconstructing the resolved-link JSON shape a raw CDA response /// carried before the Delivery SDK decoded it. Every fixed-shape piece (`Sys`, a content-type /// link, `Metadata`, a link stub, an asset envelope, a Structured Text node) is a small -/// `Codable` struct (`CDA`, below the type) with its own `static func from(...)` -/// factory, converted to `JSONValue` with a real `JSONEncoder` round trip — not a hand-assembled -/// `.object([...])` dictionary literal. `toJSON()` serializes the whole tree the same way. +/// `Codable` struct (`CDA`, below the type) with its own `static func from(...)` factory. A +/// field's own *value* (as opposed to the envelope's fixed shape) still goes through +/// `JSONValue` — `EntryEnvelope.fields` is `[String: JSONValue]`, since a field's runtime type +/// is only known once `CDA.Field.from` inspects it, not upfront like `sys`/`metadata`. +/// `toJSON()` serializes `envelope` directly via `JSONEncoder`. /// - **Decode**: `init(any:)` wraps the resolver's already-parsed `[String: Any]` bridge output; -/// `init(json:)` decodes a raw JSON string via `JSONValue`'s `Codable` conformance and -/// `JSONDecoder`. The reader surface below (`id`, `localeCode`, `createdAt`, `updatedAt`, -/// `getField`) mirrors `Contentful.Entry`'s own readable surface, so resolved content reads -/// like a fetched entry instead of a raw map dug through with `as?` casts. -/// -/// `JSONValue` itself is the plain, Contentful-agnostic JSON tree (already used by -/// `EventPayloads`/`PreviewState`/the bridge); this type is the higher-level, entry-specific layer -/// on top — it delegates all actual parsing/serialization to `JSONValue`'s existing `Codable` -/// conformance and `JSONEncoder`/`JSONDecoder`, rather than reimplementing either. +/// `init(json:)` decodes a raw JSON string. Both land on the same `CDA.EntryEnvelope`, whose +/// `init(from:)` decodes `sys`/`fields`/`metadata` independently (see the type for why) so a +/// caller's partial or malformed input loses only the missing/malformed piece, not the whole +/// entry. The reader surface below (`id`, `localeCode`, `createdAt`, `updatedAt`, `getField`) +/// mirrors `Contentful.Entry`'s own readable surface, so resolved content reads like a fetched +/// entry instead of a raw map dug through with `as?` casts. /// /// Ported from the reference implementation's simulation of this exact gap: /// `examples/apps/travel-guide-ios/Sources/OptimizationAdapter.swift` @@ -50,28 +50,36 @@ import Foundation /// reads `fields` from; a resolved tree is already a single-locale snapshot with no such state /// to mutate. public struct CTEntry { - private let json: JSONValue + private let envelope: CDA.EntryEnvelope - private init(_ json: JSONValue) { - self.json = json + private init(_ envelope: CDA.EntryEnvelope) { + self.envelope = envelope } // MARK: - Parsing + /// Decodes a raw JSON string directly into `CDA.EntryEnvelope` — no separate `JSONValue` + /// parse step, since the envelope's own tolerant `init(from:)` (see the type) already handles + /// a partial or malformed tree without throwing. init(json: String) throws { guard let data = json.data(using: .utf8) else { throw OptimizationError.configError("JSON string is not valid UTF-8") } - self.json = try JSONDecoder().decode(JSONValue.self, from: data) + envelope = try JSONDecoder().decode(CDA.EntryEnvelope.self, from: data) } /// Wraps an already-decoded `Any` value (e.g. `JSONSerialization`'s output, or a hand-built - /// `[String: Any]` at a call site that hasn't adopted this type). Throws rather than silently - /// treating an unrecognized value as absent — a caller that got something wrong here should - /// see a parse error, not a value that quietly reads back as missing everywhere `getField`/the - /// subscript check it. + /// `[String: Any]` at a call site that hasn't adopted this type). `parseValue` validates every + /// leaf is JSON-safe first and throws a Swift error on one that isn't (e.g. `Date`) — calling + /// `JSONSerialization.data(withJSONObject:)` directly on an unvalidated `Any` is not safe here: + /// on an unsupported type it raises an uncaught `NSException`, not a catchable `Error`. Once + /// validated, the value is JSON-encoded and decoded into `CDA.EntryEnvelope`, whose own + /// tolerant `init(from:)` (see the type) degrades a merely wrong-shaped-for-an-entry tree to + /// `nil` fields rather than throwing. init(any: Any) throws { - json = try Self.parseValue(from: any) + let validated = try Self.parseValue(from: any) + let data = try JSONEncoder().encode(validated) + envelope = try JSONDecoder().decode(CDA.EntryEnvelope.self, from: data) } private static func parseValue(from any: Any) throws -> JSONValue { @@ -97,44 +105,35 @@ public struct CTEntry { // MARK: - Serializing - /// Serializes via `JSONValue`'s `Codable` conformance and `JSONEncoder` — a real encoder, not - /// `JSONSerialization.data(withJSONObject:)` over a `toFoundation()`-produced `Any`. func toJSON() throws -> String { - let data = try JSONEncoder().encode(json) + let data = try JSONEncoder().encode(envelope) guard let string = String(data: data, encoding: .utf8) else { throw OptimizationError.configError("Failed to encode CTEntry as UTF-8 JSON") } return string } - /// The Foundation type (`String`, `Int`/`Double`, `Bool`, `NSNull`, `[Any]`, `[String: Any]`) - /// call sites still on `[String: Any]` (`OptimizedEntry`'s dict-based initializer, - /// `resolveOptimizedEntry(baseline: [String: Any])`) expect. + /// The Foundation type (`[String: Any]`) call sites still on `[String: Any]` + /// (`OptimizedEntry`'s dict-based initializer, `resolveOptimizedEntry(baseline: [String: Any])`) + /// expect. Round-trips through `JSONEncoder`/`JSONSerialization` rather than hand-assembling + /// the dict from `envelope`'s typed properties. func toFoundation() -> Any { - json.toFoundation() + guard let data = try? JSONEncoder().encode(envelope) else { return [String: Any]() } + return (try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])) ?? [String: Any]() } // MARK: - Reading a resolved entry - private subscript(key: String) -> CTEntry? { - guard case let .object(dict) = json, let value = dict[key] else { return nil } - return CTEntry(value) - } - - private var stringValue: String? { - json.stringValue - } - /// The entry `sys.id` — stable across a variant swap, so it's safe for navigation. public var id: String? { - self["sys"]?["id"]?.stringValue + envelope.sys?.id } /// Mirrors `Entry.localeCode` (via `FlatResource`) — the code of the locale this resolved /// variant's `fields` were read for. Absent on a raw CDA response fetched via `/sync` or the /// wildcard `locale=*` query, same as on `Entry` itself. public var localeCode: String? { - self["sys"]?["locale"]?.stringValue + envelope.sys?.locale } /// Mirrors `Entry.createdAt`. `nil` if the resolved tree never carried a `sys.createdAt` — a @@ -142,12 +141,12 @@ public struct CTEntry { /// have no creation timestamp to report, same as `Entry.createdAt` returning `nil` for a /// resource `select()`-queried without `sys`. public var createdAt: Date? { - self["sys"]?["createdAt"]?.stringValue.flatMap { ISO8601DateFormatter().date(from: $0) } + envelope.sys?.createdAt.flatMap { ISO8601DateFormatter().date(from: $0) } } /// Mirrors `Entry.updatedAt`. See `createdAt` for why this can be `nil`. public var updatedAt: Date? { - self["sys"]?["updatedAt"]?.stringValue.flatMap { ISO8601DateFormatter().date(from: $0) } + envelope.sys?.updatedAt.flatMap { ISO8601DateFormatter().date(from: $0) } } /// A field's resolved value, or nil if absent. @@ -156,7 +155,7 @@ public struct CTEntry { /// always succeeds, so a missing field comes back as a non-nil `Optional(nil)` rather than /// `nil`. Check presence via `toFoundation()` instead, or infer a concrete `T`. public func getField(_ name: String) -> T? { - self["fields"]?[name]?.toFoundation() as? T + envelope.fields[name]?.toFoundation() as? T } /// Mirrors `Entry`'s `String` convenience subscript, which reads directly from `fields`. @@ -166,19 +165,10 @@ public struct CTEntry { // MARK: - Encoding a `Contentful.Entry` - /// Encodes a `contentful.swift` `Entry` into the `{sys, fields, metadata}` tree + /// Encodes a `contentful.swift` `Entry` into the `{sys, fields, metadata}` envelope /// `OptimizedEntry`/`resolveOptimizedEntry` expect. - /// - /// `JSONValue.encoded` can fail only on a non-finite `Double` (`NaN`/`±infinity`) reaching a - /// `CDA` struct's `Double` field. `sys`'s own fields are never `Double`, so this call can't - /// fail that way — `try!` here is a real invariant, not a swallowed error. Every *nested* - /// value that could carry a non-finite `Double` (a field via `CDA.Field.from`, a link via - /// `CDA.LinkValue.from`) is already funneled through one of those two, both of which drop - /// the offending value with `try?` rather than let a failure propagate up into this call — - /// losing an unused field beats losing personalization on the entry that holds it, the - /// policy `CDA.Field.from` documents for its own `default` case. public init(_ entry: Contentful.Entry) { - json = try! JSONValue.encoded(CDA.EntryEnvelope.from(entry, ancestors: [])) + envelope = CDA.EntryEnvelope.from(entry, ancestors: []) } } @@ -308,10 +298,18 @@ private enum CDA { } } + /// `id`/`locale`/`createdAt`/`updatedAt` are all plain optional properties decoded + /// independently via `try?` (see `init(from:)`) rather than a synthesized `Codable` + /// conformance: a synthesized decoder throws — failing the *entire* `Sys`, and by extension + /// the entry that holds it — the moment any one key is absent or the wrong type (e.g. + /// `sys.id` being a number instead of a string). A caller-supplied baseline is not guaranteed + /// well-formed (see `CTEntry.init(any:)`), so a per-field `try?` degrades exactly the + /// offending key to `nil` and leaves the rest of `Sys` — and every other entry field — + /// intact, matching this type's "lose a field, not the entry" policy. struct Sys: Codable { - let id: String - let type: String - let contentType: ContentTypeLink + let id: String? + let type: String? + let contentType: ContentTypeLink? let createdAt: String? let updatedAt: String? let revision: Int? @@ -321,6 +319,31 @@ private enum CDA { let sys: LinkStub.Sys } + private enum CodingKeys: String, CodingKey { + case id, type, contentType, createdAt, updatedAt, revision, locale + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try? container.decode(String.self, forKey: .id) + type = try? container.decode(String.self, forKey: .type) + contentType = try? container.decode(ContentTypeLink.self, forKey: .contentType) + createdAt = try? container.decode(String.self, forKey: .createdAt) + updatedAt = try? container.decode(String.self, forKey: .updatedAt) + revision = try? container.decode(Int.self, forKey: .revision) + locale = try? container.decode(String.self, forKey: .locale) + } + + init(id: String?, type: String?, contentType: ContentTypeLink?, createdAt: String?, updatedAt: String?, revision: Int?, locale: String?) { + self.id = id + self.type = type + self.contentType = contentType + self.createdAt = createdAt + self.updatedAt = updatedAt + self.revision = revision + self.locale = locale + } + /// All of `createdAt`/`updatedAt`/`revision`/`locale` are independently optional on /// `Contentful.Sys` itself (e.g. `locale` is absent on a `/sync` or wildcard-locale /// response); `Codable`'s default `encodeIfPresent` behavior for `nil` optionals then @@ -338,10 +361,31 @@ private enum CDA { } } + /// `sys`/`fields`/`metadata` decode independently via `try?`, for the same reason `Sys`'s own + /// properties do: a caller-supplied baseline can be missing any of them (see + /// `CTEntry.init(any:)`/`init(json:)`), and losing the whole entry to one absent or + /// wrong-shaped top-level key would be worse than reading that piece back as `nil`/empty. struct EntryEnvelope: Codable { - let sys: Sys + let sys: Sys? let fields: [String: JSONValue] - let metadata: Metadata + let metadata: Metadata? + + private enum CodingKeys: String, CodingKey { + case sys, fields, metadata + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + sys = try? container.decode(Sys.self, forKey: .sys) + fields = (try? container.decode([String: JSONValue].self, forKey: .fields)) ?? [:] + metadata = try? container.decode(Metadata.self, forKey: .metadata) + } + + init(sys: Sys?, fields: [String: JSONValue], metadata: Metadata?) { + self.sys = sys + self.fields = fields + self.metadata = metadata + } /// `ancestors` is the set of entry ids on the path from the root to here. The Delivery /// SDK resolves links into shared object references, so a variant that links back to From 5c44a4cd035028fab7d67aa4a6d113fa024a33f6 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Fri, 31 Jul 2026 01:35:18 +0200 Subject: [PATCH 09/21] refactor(swift): simplify CTEntry.init(any:)/toJSON via built-in Foundation checks Replace the hand-written parseValue recursive JSONValue walk with JSONSerialization.isValidJSONObject as a pre-check, then decode straight off JSONSerialization.data(withJSONObject:) - same safety property (throws a catchable error rather than crashing on an unsupported type like Date), less code to maintain. Also drop toJSON's unreachable String(data:encoding:.utf8) failure path: JSONEncoder's output is always valid UTF-8, so String(decoding:as:) (which cannot fail) is the correct call here. Co-Authored-By: Claude Sonnet 5 --- .../Contentful/CTEntry.swift | 47 ++++++------------- 1 file changed, 14 insertions(+), 33 deletions(-) diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift index bd00daa2b..94364c201 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift @@ -69,48 +69,29 @@ public struct CTEntry { } /// Wraps an already-decoded `Any` value (e.g. `JSONSerialization`'s output, or a hand-built - /// `[String: Any]` at a call site that hasn't adopted this type). `parseValue` validates every - /// leaf is JSON-safe first and throws a Swift error on one that isn't (e.g. `Date`) — calling - /// `JSONSerialization.data(withJSONObject:)` directly on an unvalidated `Any` is not safe here: - /// on an unsupported type it raises an uncaught `NSException`, not a catchable `Error`. Once - /// validated, the value is JSON-encoded and decoded into `CDA.EntryEnvelope`, whose own - /// tolerant `init(from:)` (see the type) degrades a merely wrong-shaped-for-an-entry tree to - /// `nil` fields rather than throwing. + /// `[String: Any]` at a call site that hasn't adopted this type). Guarded by + /// `isValidJSONObject` first — calling `JSONSerialization.data(withJSONObject:)` on a value + /// it can't serialize (e.g. `Date`) raises an uncaught `NSException`, not a catchable `Error`, + /// so that check has to happen before the call, not around it. Once validated, the value is + /// JSON-encoded and decoded into `CDA.EntryEnvelope`, whose own tolerant `init(from:)` (see + /// the type) degrades a merely wrong-shaped-for-an-entry tree to `nil` fields rather than + /// throwing. init(any: Any) throws { - let validated = try Self.parseValue(from: any) - let data = try JSONEncoder().encode(validated) - envelope = try JSONDecoder().decode(CDA.EntryEnvelope.self, from: data) - } - - private static func parseValue(from any: Any) throws -> JSONValue { - switch any { - case is NSNull: - return .null - case let bool as Bool: - return .bool(bool) - case let number as Int: - return .number(Double(number)) - case let number as Double: - return .number(number) - case let string as String: - return .string(string) - case let array as [Any]: - return .array(try array.map { try parseValue(from: $0) }) - case let object as [String: Any]: - return .object(try object.mapValues { try parseValue(from: $0) }) - default: + guard JSONSerialization.isValidJSONObject(any) else { throw OptimizationError.configError("Unsupported value of type \(Swift.type(of: any)) in CTEntry(any:)") } + let data = try JSONSerialization.data(withJSONObject: any) + envelope = try JSONDecoder().decode(CDA.EntryEnvelope.self, from: data) } // MARK: - Serializing + /// `JSONEncoder`'s output is always valid UTF-8 by spec, so `String(decoding:as:)` — which + /// never fails — is correct here; `String(data:encoding:.utf8)`'s optional would just be + /// unreachable dead code on this input. func toJSON() throws -> String { let data = try JSONEncoder().encode(envelope) - guard let string = String(data: data, encoding: .utf8) else { - throw OptimizationError.configError("Failed to encode CTEntry as UTF-8 JSON") - } - return string + return String(decoding: data, as: UTF8.self) } /// The Foundation type (`[String: Any]`) call sites still on `[String: Any]` From 3923bd10cf8bb22ffe9706c5cc287feb718c76e9 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Fri, 31 Jul 2026 11:08:23 +0200 Subject: [PATCH 10/21] refactor(swift): trim CTEntry doc comments and rename CDA.EntryEnvelope to CDA.Entry Cuts multi-paragraph docstrings down to the load-bearing why (Int/Double JSONValue quirk, NSException guard, ancestor-cycle handling, the metadata requirement) and drops restated "what" and provenance narration. Co-Authored-By: Claude Sonnet 5 --- .../Contentful/CTEntry.swift | 246 ++++++------------ 1 file changed, 74 insertions(+), 172 deletions(-) diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift index 94364c201..1896fa60a 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift @@ -1,169 +1,104 @@ import Contentful import Foundation -/// Both directions of the `Contentful.Entry <-> JSON` boundary `OptimizedEntry` and -/// `OptimizationClient.resolveOptimizedEntry` need, backed by `CDA.EntryEnvelope` — a typed -/// `{sys, fields, metadata}` contract — rather than a hand-built `[String: Any]` dictionary read -/// back with `as?` casts: +/// Bridges `Contentful.Entry` and the resolver's raw JSON (`{sys, fields, metadata}`), backed by +/// `CDA.Entry` rather than a hand-built `[String: Any]` read back with `as?` casts. +/// `init(_:Contentful.Entry)`/`toJSON()` encode; `init(any:)`/`init(json:)` decode. /// -/// - **Encode**: `CTEntry(_: Contentful.Entry)` builds the `{sys, fields, metadata}` tree a -/// `Contentful.Entry` maps to, reconstructing the resolved-link JSON shape a raw CDA response -/// carried before the Delivery SDK decoded it. Every fixed-shape piece (`Sys`, a content-type -/// link, `Metadata`, a link stub, an asset envelope, a Structured Text node) is a small -/// `Codable` struct (`CDA`, below the type) with its own `static func from(...)` factory. A -/// field's own *value* (as opposed to the envelope's fixed shape) still goes through -/// `JSONValue` — `EntryEnvelope.fields` is `[String: JSONValue]`, since a field's runtime type -/// is only known once `CDA.Field.from` inspects it, not upfront like `sys`/`metadata`. -/// `toJSON()` serializes `envelope` directly via `JSONEncoder`. -/// - **Decode**: `init(any:)` wraps the resolver's already-parsed `[String: Any]` bridge output; -/// `init(json:)` decodes a raw JSON string. Both land on the same `CDA.EntryEnvelope`, whose -/// `init(from:)` decodes `sys`/`fields`/`metadata` independently (see the type for why) so a -/// caller's partial or malformed input loses only the missing/malformed piece, not the whole -/// entry. The reader surface below (`id`, `localeCode`, `createdAt`, `updatedAt`, `getField`) -/// mirrors `Contentful.Entry`'s own readable surface, so resolved content reads like a fetched -/// entry instead of a raw map dug through with `as?` casts. +/// This shares the resolved *shape* with `Entry`, not the type: `Entry.init(from:)` needs a +/// `LocalizationContext` only a live CDA decode carries. `type`, `currentlySelectedLocale`, +/// `metadata`, and `setLocale(withCode:)` have no counterpart here — each needs a resource +/// (`ContentType`, `Locale`, `Metadata`) with no public initializer to fabricate from the resolved +/// tree alone. /// -/// Ported from the reference implementation's simulation of this exact gap: -/// `examples/apps/travel-guide-ios/Sources/OptimizationAdapter.swift` -/// (`Entry.optimizationMap` + `ResolvedEntry`). -/// -/// `JSONValue.number` has no separate `Int` case — an `Int` field (`sys.revision`, an asset's -/// `file.details.size`, a plain integer field) round-trips as a `Double`. `getField`/`as? Int` -/// on such a field does not match; read it as `Double` (or `Int` via `Int(exactly:)` on the -/// `Double`) instead. Accepted for reuse of the package's one shared JSON AST rather than -/// introducing a second, `Int`-preserving JSON value type solely for this file. -/// -/// An `Entry` can't be rebuilt from a resolved value — `Contentful.Entry.init(from:)` needs a -/// `LocalizationContext` in `decoder.userInfo` that only a live CDA decode carries. This type -/// shares the resolved *shape* with `Entry`, not the type, on purpose: the reader surface below is -/// as far as that mirroring can go. Three `Entry` members have no counterpart here, by -/// construction rather than oversight: -/// - `type: ContentType?` — a full fetched content-type schema resource. The resolved tree only -/// ever carries the content type's `id` (`sys.contentType.sys.id`), never the schema -/// `ContentType` itself, and `ContentType` has no public initializer to reconstruct one from -/// that id alone. -/// - `currentlySelectedLocale: Locale` — a full locale object (code/name/fallback chain), which -/// the resolved tree never carries and `Locale` has no public initializer to fabricate. -/// - `metadata: Metadata?` / `setLocale(withCode:)` — `Metadata` has no public initializer, so -/// the resolved tree's `metadata.tags` can't be wrapped back into a real `Metadata` value, only -/// read via `getField("metadata")`. `setLocale` mutates which locale a live multi-locale decode -/// reads `fields` from; a resolved tree is already a single-locale snapshot with no such state -/// to mutate. +/// `JSONValue.number` has no `Int` case, so an `Int` field (`sys.revision`, a file's `details.size`) +/// round-trips as `Double`; `getField` won't match it — read `Double` instead. public struct CTEntry { - private let envelope: CDA.EntryEnvelope + private let envelope: CDA.Entry - private init(_ envelope: CDA.EntryEnvelope) { + private init(_ envelope: CDA.Entry) { self.envelope = envelope } - // MARK: - Parsing + // Parsing - /// Decodes a raw JSON string directly into `CDA.EntryEnvelope` — no separate `JSONValue` - /// parse step, since the envelope's own tolerant `init(from:)` (see the type) already handles - /// a partial or malformed tree without throwing. + public init(_ entry: Contentful.Entry) { + envelope = CDA.Entry.from(entry, ancestors: []) + } + init(json: String) throws { guard let data = json.data(using: .utf8) else { throw OptimizationError.configError("JSON string is not valid UTF-8") } - envelope = try JSONDecoder().decode(CDA.EntryEnvelope.self, from: data) + envelope = try JSONDecoder().decode(CDA.Entry.self, from: data) } - /// Wraps an already-decoded `Any` value (e.g. `JSONSerialization`'s output, or a hand-built - /// `[String: Any]` at a call site that hasn't adopted this type). Guarded by - /// `isValidJSONObject` first — calling `JSONSerialization.data(withJSONObject:)` on a value - /// it can't serialize (e.g. `Date`) raises an uncaught `NSException`, not a catchable `Error`, - /// so that check has to happen before the call, not around it. Once validated, the value is - /// JSON-encoded and decoded into `CDA.EntryEnvelope`, whose own tolerant `init(from:)` (see - /// the type) degrades a merely wrong-shaped-for-an-entry tree to `nil` fields rather than - /// throwing. init(any: Any) throws { guard JSONSerialization.isValidJSONObject(any) else { throw OptimizationError.configError("Unsupported value of type \(Swift.type(of: any)) in CTEntry(any:)") } let data = try JSONSerialization.data(withJSONObject: any) - envelope = try JSONDecoder().decode(CDA.EntryEnvelope.self, from: data) + envelope = try JSONDecoder().decode(CDA.Entry.self, from: data) } - // MARK: - Serializing + // Serializing - /// `JSONEncoder`'s output is always valid UTF-8 by spec, so `String(decoding:as:)` — which - /// never fails — is correct here; `String(data:encoding:.utf8)`'s optional would just be - /// unreachable dead code on this input. func toJSON() throws -> String { let data = try JSONEncoder().encode(envelope) return String(decoding: data, as: UTF8.self) } - /// The Foundation type (`[String: Any]`) call sites still on `[String: Any]` - /// (`OptimizedEntry`'s dict-based initializer, `resolveOptimizedEntry(baseline: [String: Any])`) - /// expect. Round-trips through `JSONEncoder`/`JSONSerialization` rather than hand-assembling - /// the dict from `envelope`'s typed properties. func toFoundation() -> Any { guard let data = try? JSONEncoder().encode(envelope) else { return [String: Any]() } return (try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])) ?? [String: Any]() } - // MARK: - Reading a resolved entry + /// A field's resolved value, or nil if absent. + /// + /// Don't call this with `T` inferred as `Any`/`Any?` to check presence — `nil as? Any` always + /// succeeds, so a missing field comes back `Optional(nil)`, not `nil`. Use `toFoundation()` or + /// a concrete `T` instead. + public func getField(_ name: String) -> T? { + envelope.fields[name]?.toFoundation() as? T + } + + // Mirrors Contenful Entry /// The entry `sys.id` — stable across a variant swap, so it's safe for navigation. public var id: String? { envelope.sys?.id } - /// Mirrors `Entry.localeCode` (via `FlatResource`) — the code of the locale this resolved - /// variant's `fields` were read for. Absent on a raw CDA response fetched via `/sync` or the - /// wildcard `locale=*` query, same as on `Entry` itself. + /// Mirrors `Entry.localeCode`. Absent on a raw CDA response fetched via `/sync` or wildcard + /// `locale=*`, same as on `Entry` itself. public var localeCode: String? { envelope.sys?.locale } - /// Mirrors `Entry.createdAt`. `nil` if the resolved tree never carried a `sys.createdAt` — a - /// resolver-synthesized entry (e.g. a variant assembled without a full CDA round trip) may - /// have no creation timestamp to report, same as `Entry.createdAt` returning `nil` for a - /// resource `select()`-queried without `sys`. + /// Mirrors `Entry.createdAt`/`updatedAt`. `nil` if the resolved tree never carried the + /// timestamp, same as `Entry` returning `nil` for a `select()`-queried resource without `sys`. public var createdAt: Date? { envelope.sys?.createdAt.flatMap { ISO8601DateFormatter().date(from: $0) } } - /// Mirrors `Entry.updatedAt`. See `createdAt` for why this can be `nil`. public var updatedAt: Date? { envelope.sys?.updatedAt.flatMap { ISO8601DateFormatter().date(from: $0) } } - /// A field's resolved value, or nil if absent. - /// - /// Do not call this with `T` inferred as `Any` (or `Any?`) to check presence — `nil as? Any` - /// always succeeds, so a missing field comes back as a non-nil `Optional(nil)` rather than - /// `nil`. Check presence via `toFoundation()` instead, or infer a concrete `T`. - public func getField(_ name: String) -> T? { - envelope.fields[name]?.toFoundation() as? T - } - - /// Mirrors `Entry`'s `String` convenience subscript, which reads directly from `fields`. + /// Mirrors `Entry`'s `String` subscript. public subscript(field key: String) -> String? { getField(key) } - - // MARK: - Encoding a `Contentful.Entry` - - /// Encodes a `contentful.swift` `Entry` into the `{sys, fields, metadata}` envelope - /// `OptimizedEntry`/`resolveOptimizedEntry` expect. - public init(_ entry: Contentful.Entry) { - envelope = CDA.EntryEnvelope.from(entry, ancestors: []) - } } // MARK: - Codable envelopes for the raw CDA response shapes -/// Small `Codable` structs mirroring the fixed parts of a raw CDA response — `sys`, a -/// content-type link, `metadata`, an unresolved-link stub, an asset, a Structured Text node. Each -/// has a `static func from(...)` factory building it from the corresponding `contentful.swift` -/// type, and converts to `JSONValue` via `JSONValue.encoded(_:)` (a real `JSONEncoder` round trip -/// through `JSONValue`'s own `Codable` conformance) — never a hand-assembled dictionary literal. +/// Small `Codable` structs mirroring the fixed parts of a raw CDA response — `sys`, a content-type +/// link, `metadata`, an unresolved-link stub, an asset, a Structured Text node. Each has a +/// `static func from(...)` built from the corresponding `contentful.swift` type. private enum CDA { - /// The `{sys: {id, type: "Link", linkType}}` shape a back-edge or an unresolved link has in a - /// raw CDA response — the one stub shape every unresolved case (`Link.unresolved`, a back-edge - /// entry, an untyped `EntryDecodable`) emits. + /// The `{sys: {id, type: "Link", linkType}}` shape an unresolved link has in a raw CDA + /// response. struct LinkStub: Codable { let sys: Sys struct Sys: Codable { @@ -177,10 +112,9 @@ private enum CDA { } } - /// A link field's resolved value, one step before it becomes `JSONValue` — every case still - /// holds its own `Codable` envelope, encoded on demand via `encoded()`. + /// A link field's resolved value, one step before it becomes `JSONValue`. enum LinkValue { - case entry(EntryEnvelope) + case entry(Entry) case asset(AssetEnvelope) case stub(LinkStub) @@ -192,9 +126,8 @@ private enum CDA { } } - /// A link field, expanded into the linked resource when the Delivery SDK resolved it. /// `ancestors` is the set of entry ids on the path from the root to here — see - /// `EntryEnvelope.from` for why a back-edge becomes `.stub` instead of recursing. + /// `CDA.Entry.from` for why a back-edge becomes `.stub` instead of recursing. static func from(_ link: Contentful.Link, ancestors: Set) -> LinkValue { switch link { case let .entry(entry) where !ancestors.contains(entry.id): @@ -211,24 +144,18 @@ private enum CDA { } } - /// One field value's resolved shape, one step before it becomes `JSONValue` — mirrors - /// `LinkValue` above: `Field.from` dispatches on the field's runtime type into one of these - /// cases with a plain type-checked `switch`; whether that particular value can actually - /// become `JSONValue` (a non-finite `Double` is the only failure mode anywhere in this tree) - /// is decided once, in `encoded()`, not per case at the dispatch site. + /// One field value's resolved shape, one step before it becomes `JSONValue`. enum Field { - /// A leaf or already-recursed container `JSONValue` — `nil` for a value `from` has no - /// case for (dropped, per the type's documented "lose the field, not the entry" policy) - /// or a non-finite `Double`/`Location` coordinate. + /// A leaf/container `JSONValue`, or `nil` for a value `from` drops (no case for it, or a + /// non-finite `Double`/`Location` coordinate). case value(JSONValue?) case link(LinkValue) case richText(RichTextNodeEnvelope) case fileMetadata(FileMetadataEnvelope) case location(LocationEnvelope) - /// `nil` if this value can't become `JSONValue` — a `.value(nil)` case, or a `Codable` - /// envelope whose encode failed on a non-finite `Double`. Every caller drops the field on - /// `nil` rather than losing the whole entry. + /// `nil` if this value can't become `JSONValue`. Every caller drops the field on `nil` + /// rather than losing the whole entry. func encoded() -> JSONValue? { switch self { case let .value(value): return value @@ -239,22 +166,17 @@ private enum CDA { } } - /// One field value, reduced to something the bridge accepts — the resolver serializes - /// the whole tree before handing it to its JS bridge, and one illegal value fails the - /// entry outright (it falls back to baseline, logging rather than throwing). Anything - /// not listed here is dropped rather than risking that: losing an unused field beats - /// losing personalization on the entry that holds it. + /// One field value, reduced to something the bridge accepts. Anything not listed here is + /// dropped: losing an unused field beats losing personalization on the entry that holds it. static func from(_ value: Any, ancestors: Set) -> Field { switch value { case let link as Contentful.Link: return .link(.from(link, ancestors: ancestors)) case let richText as Contentful.RichTextDocument: return .richText(.from(richText, ancestors: ancestors)) - // A field of Contentful type "Object" shaped exactly like a file metadata blob - // (`{fileName, contentType, url, details: {size, image: {width, height}}}`) decodes - // to this type — the generic `[String: Any]` decoder (`Decodable.swift`) tries it - // before falling back to a plain dictionary. Reuses `FileMetadataEnvelope.from`, the - // same factory a resolved asset link's `file` field goes through. + // A field of Contentful type "Object" shaped like a file metadata blob + // (`{fileName, contentType, url, details: {size, image: {width, height}}}`) decodes to + // this type before falling back to a plain dictionary. case let file as Contentful.Asset.FileMetadata: return .fileMetadata(.from(file)) case let array as [Any]: @@ -279,14 +201,10 @@ private enum CDA { } } - /// `id`/`locale`/`createdAt`/`updatedAt` are all plain optional properties decoded - /// independently via `try?` (see `init(from:)`) rather than a synthesized `Codable` - /// conformance: a synthesized decoder throws — failing the *entire* `Sys`, and by extension - /// the entry that holds it — the moment any one key is absent or the wrong type (e.g. - /// `sys.id` being a number instead of a string). A caller-supplied baseline is not guaranteed - /// well-formed (see `CTEntry.init(any:)`), so a per-field `try?` degrades exactly the - /// offending key to `nil` and leaves the rest of `Sys` — and every other entry field — - /// intact, matching this type's "lose a field, not the entry" policy. + /// Properties decode independently via `try?` rather than synthesized `Codable`: a + /// synthesized decoder throws — failing all of `Sys` — the moment one key is absent or the + /// wrong type. A caller-supplied baseline isn't guaranteed well-formed, so a per-field `try?` + /// degrades just the offending key to `nil`. struct Sys: Codable { let id: String? let type: String? @@ -325,10 +243,6 @@ private enum CDA { self.locale = locale } - /// All of `createdAt`/`updatedAt`/`revision`/`locale` are independently optional on - /// `Contentful.Sys` itself (e.g. `locale` is absent on a `/sync` or wildcard-locale - /// response); `Codable`'s default `encodeIfPresent` behavior for `nil` optionals then - /// omits the key, matching the raw CDA response shape rather than emitting null. static func from(_ sys: Contentful.Sys) -> Sys { Sys( id: sys.id, @@ -342,11 +256,9 @@ private enum CDA { } } - /// `sys`/`fields`/`metadata` decode independently via `try?`, for the same reason `Sys`'s own - /// properties do: a caller-supplied baseline can be missing any of them (see - /// `CTEntry.init(any:)`/`init(json:)`), and losing the whole entry to one absent or - /// wrong-shaped top-level key would be worse than reading that piece back as `nil`/empty. - struct EntryEnvelope: Codable { + /// `sys`/`fields`/`metadata` decode independently via `try?` for the same reason `Sys`'s + /// properties do — a caller-supplied baseline can be missing any of them. + struct Entry: Codable { let sys: Sys? let fields: [String: JSONValue] let metadata: Metadata? @@ -368,30 +280,26 @@ private enum CDA { self.metadata = metadata } - /// `ancestors` is the set of entry ids on the path from the root to here. The Delivery - /// SDK resolves links into shared object references, so a variant that links back to - /// its baseline is a real cycle in the object graph; recursing an entry already on the - /// current path would loop forever. Re-linking an ancestor emits an unresolved link stub - /// instead — the shape a back-edge has in a raw CDA response. Scoping to the current - /// path (not a global visited set) still expands diamonds: an entry reached by two - /// sibling branches expands fully in both. - static func from(_ entry: Contentful.Entry, ancestors: Set) -> EntryEnvelope { + /// `ancestors` is the set of entry ids on the path from root to here. The Delivery SDK + /// resolves links into shared object references, so a variant linking back to its + /// baseline is a real cycle; recursing an entry already on the current path would loop + /// forever, so a re-linked ancestor emits an unresolved link stub instead. Scoping to the + /// current path (not a global visited set) still expands diamonds fully on both branches. + static func from(_ entry: Contentful.Entry, ancestors: Set) -> Entry { let childAncestors = ancestors.union([entry.id]) let sys = Sys.from(entry.sys) let fields = entry.fields.compactMapValues { Field.from($0, ancestors: childAncestors).encoded() } // Required, not cosmetic: the resolver's entry guard rejects any entry without a - // `metadata` object, and a rejected baseline is never given its variant. A raw CDA - // response carries it on every entry; `Entry` keeps it out of `fields`, so this has - // to put it back. `concepts` is always empty — `contentful.swift`'s `Metadata` - // models only `tags`, so the SDK gives us nothing else to forward. + // `metadata` object. `concepts` is always empty — `contentful.swift`'s `Metadata` + // models only `tags`. let metadata = Metadata( tags: (entry.metadata?.tags ?? []).compactMap { try? LinkValue.from($0, ancestors: childAncestors).encoded() }, concepts: [] ) - return EntryEnvelope(sys: sys, fields: fields, metadata: metadata) + return Entry(sys: sys, fields: fields, metadata: metadata) } } @@ -429,10 +337,7 @@ private enum CDA { } } - /// An asset's `file` metadata, reduced to the raw CDA response shape - /// (`{fileName, contentType, details: {size, image: {width, height}}, url}`) — the same shape - /// whether it arrived via a resolved asset link (`AssetEnvelope.from`) or as a directly - /// decoded field value (`jsonValue`'s `Asset.FileMetadata` case). `details.image` is only + /// An asset's `file` metadata, reduced to the raw CDA response shape. `details.image` is only /// present for image files. struct FileMetadataEnvelope: Codable { let fileName: String? @@ -473,7 +378,7 @@ private enum CDA { } } - /// One Structured Text node, reduced to the same `{nodeType, data, content}` shape a raw CDA + /// One Structured Text node, reduced to the `{nodeType, data, content}` shape a raw CDA /// response carries. struct RichTextNodeEnvelope: Codable { let nodeType: String @@ -502,12 +407,9 @@ private enum CDA { self.content = content } - /// `ResourceLinkBlock`/`ResourceLinkInline` (embedded entries and assets — both `-block` - /// and `-inline` variants share these two Swift types across all five - /// `embedded-*`/`*-hyperlink` node types) must be matched before the generic - /// `RecursiveNode` case, since both conform to it; falling through to the generic case - /// would silently drop the embedded resource's resolved-or-unresolved link entirely; - /// ordering matters here. + /// `ResourceLinkBlock`/`ResourceLinkInline` must be matched before the generic + /// `RecursiveNode` case, since both conform to it — falling through would silently drop + /// the embedded resource's link entirely. static func from(_ node: Contentful.Node, ancestors: Set) -> RichTextNodeEnvelope { switch node { case let resourceLink as Contentful.ResourceLinkBlock: From bb1040306cb91d3e7278e06d7c5c9b4221c0a251 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Fri, 31 Jul 2026 11:09:38 +0200 Subject: [PATCH 11/21] refactor(swift): convert CDA envelope from(...) factories to init(...) Matches CTEntry's own init(_ entry:) and standard Swift construction idiom, rather than mixing static factory methods with initializers within the same private CDA enum. Co-Authored-By: Claude Sonnet 5 --- .../Contentful/CTEntry.swift | 122 +++++++++--------- 1 file changed, 64 insertions(+), 58 deletions(-) diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift index 1896fa60a..722560646 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift @@ -23,9 +23,9 @@ public struct CTEntry { // Parsing public init(_ entry: Contentful.Entry) { - envelope = CDA.Entry.from(entry, ancestors: []) + envelope = CDA.Entry(entry, ancestors: []) } - + init(json: String) throws { guard let data = json.data(using: .utf8) else { throw OptimizationError.configError("JSON string is not valid UTF-8") @@ -94,8 +94,8 @@ public struct CTEntry { // MARK: - Codable envelopes for the raw CDA response shapes /// Small `Codable` structs mirroring the fixed parts of a raw CDA response — `sys`, a content-type -/// link, `metadata`, an unresolved-link stub, an asset, a Structured Text node. Each has a -/// `static func from(...)` built from the corresponding `contentful.swift` type. +/// link, `metadata`, an unresolved-link stub, an asset, a Structured Text node. Each has an +/// `init(_:)` built from the corresponding `contentful.swift` type. private enum CDA { /// The `{sys: {id, type: "Link", linkType}}` shape an unresolved link has in a raw CDA /// response. @@ -127,27 +127,27 @@ private enum CDA { } /// `ancestors` is the set of entry ids on the path from the root to here — see - /// `CDA.Entry.from` for why a back-edge becomes `.stub` instead of recursing. - static func from(_ link: Contentful.Link, ancestors: Set) -> LinkValue { + /// `CDA.Entry.init(_:ancestors:)` for why a back-edge becomes `.stub` instead of recursing. + init(_ link: Contentful.Link, ancestors: Set) { switch link { case let .entry(entry) where !ancestors.contains(entry.id): - return .entry(.from(entry, ancestors: ancestors)) + self = .entry(Entry(entry, ancestors: ancestors)) case let .asset(asset): - return .asset(.from(asset)) + self = .asset(AssetEnvelope(asset)) case let .unresolved(sys): - return .stub(.init(id: sys.id, linkType: sys.linkType)) + self = .stub(.init(id: sys.id, linkType: sys.linkType)) // A back-edge, or a typed `EntryDecodable` this mapper never registers: emit the // stub an unresolved link has in a raw CDA response. case .entry, .entryDecodable: - return .stub(.init(id: link.id, linkType: "Entry")) + self = .stub(.init(id: link.id, linkType: "Entry")) } } } /// One field value's resolved shape, one step before it becomes `JSONValue`. enum Field { - /// A leaf/container `JSONValue`, or `nil` for a value `from` drops (no case for it, or a - /// non-finite `Double`/`Location` coordinate). + /// A leaf/container `JSONValue`, or `nil` for a value `init(_:ancestors:)` drops (no case + /// for it, or a non-finite `Double`/`Location` coordinate). case value(JSONValue?) case link(LinkValue) case richText(RichTextNodeEnvelope) @@ -168,35 +168,35 @@ private enum CDA { /// One field value, reduced to something the bridge accepts. Anything not listed here is /// dropped: losing an unused field beats losing personalization on the entry that holds it. - static func from(_ value: Any, ancestors: Set) -> Field { + init(_ value: Any, ancestors: Set) { switch value { case let link as Contentful.Link: - return .link(.from(link, ancestors: ancestors)) + self = .link(LinkValue(link, ancestors: ancestors)) case let richText as Contentful.RichTextDocument: - return .richText(.from(richText, ancestors: ancestors)) + self = .richText(RichTextNodeEnvelope(richText, ancestors: ancestors)) // A field of Contentful type "Object" shaped like a file metadata blob // (`{fileName, contentType, url, details: {size, image: {width, height}}}`) decodes to // this type before falling back to a plain dictionary. case let file as Contentful.Asset.FileMetadata: - return .fileMetadata(.from(file)) + self = .fileMetadata(FileMetadataEnvelope(file)) case let array as [Any]: - return .value(.array(array.compactMap { from($0, ancestors: ancestors).encoded() })) + self = .value(.array(array.compactMap { Field($0, ancestors: ancestors).encoded() })) case let dictionary as [String: Any]: - return .value(.object(dictionary.compactMapValues { from($0, ancestors: ancestors).encoded() })) + self = .value(.object(dictionary.compactMapValues { Field($0, ancestors: ancestors).encoded() })) case let location as Contentful.Location: - return .location(.from(location)) + self = .location(LocationEnvelope(location)) case let date as Date: - return .value(.string(ISO8601DateFormatter().string(from: date))) + self = .value(.string(ISO8601DateFormatter().string(from: date))) case let string as String: - return .value(.string(string)) + self = .value(.string(string)) case let int as Int: - return .value(.number(Double(int))) + self = .value(.number(Double(int))) case let double as Double: - return .value(double.isFinite ? .number(double) : nil) + self = .value(double.isFinite ? .number(double) : nil) case let bool as Bool: - return .value(.bool(bool)) + self = .value(.bool(bool)) default: - return .value(nil) + self = .value(nil) } } } @@ -243,8 +243,8 @@ private enum CDA { self.locale = locale } - static func from(_ sys: Contentful.Sys) -> Sys { - Sys( + init(_ sys: Contentful.Sys) { + self.init( id: sys.id, type: "Entry", contentType: .init(sys: .init(id: sys.contentTypeId ?? "", type: "Link", linkType: "ContentType")), @@ -285,21 +285,21 @@ private enum CDA { /// baseline is a real cycle; recursing an entry already on the current path would loop /// forever, so a re-linked ancestor emits an unresolved link stub instead. Scoping to the /// current path (not a global visited set) still expands diamonds fully on both branches. - static func from(_ entry: Contentful.Entry, ancestors: Set) -> Entry { + init(_ entry: Contentful.Entry, ancestors: Set) { let childAncestors = ancestors.union([entry.id]) - let sys = Sys.from(entry.sys) - let fields = entry.fields.compactMapValues { Field.from($0, ancestors: childAncestors).encoded() } + let sys = Sys(entry.sys) + let fields = entry.fields.compactMapValues { Field($0, ancestors: childAncestors).encoded() } // Required, not cosmetic: the resolver's entry guard rejects any entry without a // `metadata` object. `concepts` is always empty — `contentful.swift`'s `Metadata` // models only `tags`. let metadata = Metadata( - tags: (entry.metadata?.tags ?? []).compactMap { try? LinkValue.from($0, ancestors: childAncestors).encoded() }, + tags: (entry.metadata?.tags ?? []).compactMap { try? LinkValue($0, ancestors: childAncestors).encoded() }, concepts: [] ) - return Entry(sys: sys, fields: fields, metadata: metadata) + self.init(sys: sys, fields: fields, metadata: metadata) } } @@ -323,15 +323,13 @@ private enum CDA { let file: FileMetadataEnvelope } - static func from(_ asset: Contentful.Asset) -> AssetEnvelope { - AssetEnvelope( - sys: .init(id: asset.id, type: "Asset"), - fields: .init( - title: asset.title ?? "", - description: asset.description, - file: asset.file.map(FileMetadataEnvelope.from) ?? FileMetadataEnvelope( - fileName: nil, contentType: nil, details: nil, url: asset.urlString ?? "" - ) + init(_ asset: Contentful.Asset) { + sys = .init(id: asset.id, type: "Asset") + fields = .init( + title: asset.title ?? "", + description: asset.description, + file: asset.file.map(FileMetadataEnvelope.init) ?? FileMetadataEnvelope( + fileName: nil, contentType: nil, details: nil, url: asset.urlString ?? "" ) ) } @@ -355,8 +353,15 @@ private enum CDA { } } - static func from(_ file: Contentful.Asset.FileMetadata) -> FileMetadataEnvelope { - FileMetadataEnvelope( + init(fileName: String?, contentType: String?, details: Details?, url: String) { + self.fileName = fileName + self.contentType = contentType + self.details = details + self.url = url + } + + init(_ file: Contentful.Asset.FileMetadata) { + self.init( fileName: file.fileName, contentType: file.contentType, details: .init( @@ -373,8 +378,9 @@ private enum CDA { let lat: Double let lon: Double - static func from(_ location: Contentful.Location) -> LocationEnvelope { - LocationEnvelope(lat: location.latitude, lon: location.longitude) + init(_ location: Contentful.Location) { + lat = location.latitude + lon = location.longitude } } @@ -410,28 +416,28 @@ private enum CDA { /// `ResourceLinkBlock`/`ResourceLinkInline` must be matched before the generic /// `RecursiveNode` case, since both conform to it — falling through would silently drop /// the embedded resource's link entirely. - static func from(_ node: Contentful.Node, ancestors: Set) -> RichTextNodeEnvelope { + init(_ node: Contentful.Node, ancestors: Set) { switch node { case let resourceLink as Contentful.ResourceLinkBlock: - return RichTextNodeEnvelope( + self.init( nodeType: resourceLink.nodeType.rawValue, - data: .init(target: try? LinkValue.from(resourceLink.data.target, ancestors: ancestors).encoded()), - content: resourceLink.content.map { from($0, ancestors: ancestors) } + data: .init(target: try? LinkValue(resourceLink.data.target, ancestors: ancestors).encoded()), + content: resourceLink.content.map { RichTextNodeEnvelope($0, ancestors: ancestors) } ) case let resourceLink as Contentful.ResourceLinkInline: - return RichTextNodeEnvelope( + self.init( nodeType: resourceLink.nodeType.rawValue, - data: .init(target: try? LinkValue.from(resourceLink.data.target, ancestors: ancestors).encoded()), - content: resourceLink.content.map { from($0, ancestors: ancestors) } + data: .init(target: try? LinkValue(resourceLink.data.target, ancestors: ancestors).encoded()), + content: resourceLink.content.map { RichTextNodeEnvelope($0, ancestors: ancestors) } ) case let hyperlink as Contentful.Hyperlink: - return RichTextNodeEnvelope( + self.init( nodeType: hyperlink.nodeType.rawValue, data: .init(uri: hyperlink.data.uri), - content: hyperlink.content.map { from($0, ancestors: ancestors) } + content: hyperlink.content.map { RichTextNodeEnvelope($0, ancestors: ancestors) } ) case let text as Contentful.Text: - return RichTextNodeEnvelope( + self.init( nodeType: text.nodeType.rawValue, value: text.value, marks: text.marks.map { .init(type: $0.type.rawValue) } @@ -440,12 +446,12 @@ private enum CDA { // HorizontalRule/OrderedList/UnorderedList/ListItem, and the top-level // RichTextDocument itself — all plain containers with no data beyond their children. case let recursive as Contentful.RecursiveNode: - return RichTextNodeEnvelope( + self.init( nodeType: recursive.nodeType.rawValue, - content: recursive.content.map { from($0, ancestors: ancestors) } + content: recursive.content.map { RichTextNodeEnvelope($0, ancestors: ancestors) } ) default: - return RichTextNodeEnvelope(nodeType: node.nodeType.rawValue) + self.init(nodeType: node.nodeType.rawValue) } } } From 95b12e3ccd96b1ee7548df3e1326594093da0de5 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Fri, 31 Jul 2026 11:39:45 +0200 Subject: [PATCH 12/21] refactor(swift): add CTEntry.hasField, use it for OptimizedEntry's isOptimized check isOptimized previously round-tripped the whole entry through toFoundation() (JSONEncoder/JSONSerialization) just to check one field's presence, silently swallowing an encode failure as "not optimized." envelope.fields is already the decoded dictionary, so presence is a direct, infallible lookup. Co-Authored-By: Claude Sonnet 5 --- .../Contentful/CTEntry.swift | 9 +++++++-- .../Views/OptimizedEntry.swift | 6 +----- .../CTEntryTests.swift | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift index 722560646..cb5e84d9f 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift @@ -56,12 +56,17 @@ public struct CTEntry { /// A field's resolved value, or nil if absent. /// /// Don't call this with `T` inferred as `Any`/`Any?` to check presence — `nil as? Any` always - /// succeeds, so a missing field comes back `Optional(nil)`, not `nil`. Use `toFoundation()` or - /// a concrete `T` instead. + /// succeeds, so a missing field comes back `Optional(nil)`, not `nil`. Use `hasField` or a + /// concrete `T` instead. public func getField(_ name: String) -> T? { envelope.fields[name]?.toFoundation() as? T } + /// Whether a field is present, regardless of its value's type. + public func hasField(_ name: String) -> Bool { + envelope.fields[name] != nil + } + // Mirrors Contenful Entry /// The entry `sys.id` — stable across a variant swap, so it's safe for navigation. diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift index 54da2f742..21f1dba6b 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift @@ -88,11 +88,7 @@ public struct OptimizedEntry: View { } private var isOptimized: Bool { - // Not `entry.getField("nt_experiences")` — `getField` with `T` inferred as `Any` - // returns a non-nil `Optional(nil)` for a missing field (`nil as? Any` always - // succeeds), so an `Any?` read can't distinguish "absent" from "present but nil". - guard let fields = (entry.toFoundation() as? [String: Any])?["fields"] as? [String: Any] else { return false } - return fields["nt_experiences"] != nil + entry.hasField("nt_experiences") } // An open preview panel always forces live updates, overriding an explicit diff --git a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/CTEntryTests.swift b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/CTEntryTests.swift index 207cba334..3d8b3fbf0 100644 --- a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/CTEntryTests.swift +++ b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/CTEntryTests.swift @@ -1205,6 +1205,24 @@ final class CTEntryTests: XCTestCase { XCTAssertNil(value) } + func testHasFieldReturnsTrueForPresentFieldRegardlessOfValueType() throws { + let resolved = try CTEntry(any: ["sys": [:], "fields": ["nt_experiences": NSNull()]]) + + XCTAssertTrue(resolved.hasField("nt_experiences")) + } + + func testHasFieldReturnsFalseForAbsentField() throws { + let resolved = try CTEntry(any: ["sys": [:], "fields": ["title": "Hello"]]) + + XCTAssertFalse(resolved.hasField("nt_experiences")) + } + + func testHasFieldReturnsFalseWhenFieldsKeyIsAbsent() throws { + let resolved = try CTEntry(any: ["sys": ["id": "e1"]]) + + XCTAssertFalse(resolved.hasField("nt_experiences")) + } + func testIdReturnsSysId() throws { let resolved = try CTEntry(any: ["sys": ["id": "e1"], "fields": [:]]) From 2c375f887f4564b274ad1072a55951c0467b2205 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Fri, 31 Jul 2026 11:52:17 +0200 Subject: [PATCH 13/21] fix(swift): make CTEntry.toFoundation public, fix UIKit reference impl ResolvedOptimizedEntry.entry became CTEntry for the dict-based resolveOptimizedEntry overload too, which broke implementations/ios-sdk/uikit/Components/OptimizedEntryUIView.swift (entirely [String: Any]-based) since toFoundation() was internal to the package module. CI's "Build iOS UI Test Bundles" job caught this. Co-Authored-By: Claude Sonnet 5 --- .../ios-sdk/uikit/Components/OptimizedEntryUIView.swift | 2 +- .../Sources/ContentfulOptimization/Contentful/CTEntry.swift | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/implementations/ios-sdk/uikit/Components/OptimizedEntryUIView.swift b/implementations/ios-sdk/uikit/Components/OptimizedEntryUIView.swift index a94865965..bd0788874 100644 --- a/implementations/ios-sdk/uikit/Components/OptimizedEntryUIView.swift +++ b/implementations/ios-sdk/uikit/Components/OptimizedEntryUIView.swift @@ -107,7 +107,7 @@ final class OptimizedEntryUIView: UIView { baseline: entry, selectedOptimizations: effectiveOptimizations ) - resolvedEntry = result.entry + resolvedEntry = result.entry.toFoundation() as? [String: Any] ?? entry resolvedOptimization = result.selectedOptimization } else { resolvedEntry = entry diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift index cb5e84d9f..767fb9614 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift @@ -48,7 +48,10 @@ public struct CTEntry { return String(decoding: data, as: UTF8.self) } - func toFoundation() -> Any { + /// For call sites that still work with `[String: Any]` — e.g. the reference UIKit + /// implementation's dict-based render closures, or `OptimizedEntry`'s own `[String: Any]` + /// initializer. + public func toFoundation() -> Any { guard let data = try? JSONEncoder().encode(envelope) else { return [String: Any]() } return (try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])) ?? [String: Any]() } From 1425974fa461dce13d28ef8f1a612693e0a6687a Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Fri, 31 Jul 2026 12:03:40 +0200 Subject: [PATCH 14/21] refactor(swift): add CTEntry.parseWithFallback, use it at every silent CTEntry(any:) fallback try? CTEntry(any: x) ?? fallback was duplicated at four call sites, two of which swallowed a parse failure with no log signal at all, and two of which used try! CTEntry(any: [:]) instead of a proper .empty case. Consolidates into one static factory that logs via DiagnosticLogger before returning an explicit fallback (.empty by default). Co-Authored-By: Claude Sonnet 5 --- .../Contentful/CTEntry.swift | 16 ++++++++++++++++ .../Core/OptimizationClient.swift | 9 ++++----- .../Views/OptimizedEntry.swift | 4 ++-- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift index 767fb9614..9a8f3cece 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift @@ -22,6 +22,11 @@ public struct CTEntry { // Parsing + /// An entry with no `sys`/`fields`/`metadata` — the fallback for a baseline that fails to + /// parse (see `init(any:)`), since every reader below (`id`, `getField`, `hasField`, etc.) + /// already treats an empty envelope as "absent." + static let empty = CTEntry(CDA.Entry(sys: nil, fields: [:], metadata: nil)) + public init(_ entry: Contentful.Entry) { envelope = CDA.Entry(entry, ancestors: []) } @@ -41,6 +46,17 @@ public struct CTEntry { envelope = try JSONDecoder().decode(CDA.Entry.self, from: data) } + /// `init(any:)`, but for a caller with a fallback rather than a `throws` path — logs and + /// returns `fallback` (`.empty` by default) instead of throwing. + static func parseWithFallback(_ any: Any, fallback: @autoclosure () -> CTEntry = .empty) -> CTEntry { + do { + return try CTEntry(any: any) + } catch { + DiagnosticLogger.shared.warning("[CTEntry] Failed to parse entry: \(error.localizedDescription)") + return fallback() + } + } + // Serializing func toJSON() throws -> String { diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift index 6716f170b..a09a9e39e 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift @@ -357,10 +357,9 @@ public final class OptimizationClient: ObservableObject { baseline: [String: Any], selectedOptimizations: [[String: Any]]? = nil ) -> ResolvedOptimizedEntry { - // `baseline` is caller-supplied and not guaranteed JSON-safe, so `CTEntry(any:)` can - // itself throw on a value it doesn't recognize; every fallback path below returns - // `baseline` unchanged and falls back further, to an empty entry, if even that fails. - let baselineEntry = (try? CTEntry(any: baseline)) ?? (try! CTEntry(any: [String: Any]())) + // `baseline` is caller-supplied and not guaranteed JSON-safe — `.parseWithFallback` logs + // and falls back to `.empty` rather than throwing. + let baselineEntry = CTEntry.parseWithFallback(baseline) guard isInitialized else { return ResolvedOptimizedEntry( @@ -397,7 +396,7 @@ public final class OptimizationClient: ObservableObject { let selectedOptimization = dict["selectedOptimization"] as? [String: Any] let optimizationContextId = dict["optimizationContextId"] as? String return ResolvedOptimizedEntry( - entry: (try? CTEntry(any: entry)) ?? (baselineEntry), + entry: .parseWithFallback(entry, fallback: baselineEntry), selectedOptimization: selectedOptimization, optimizationContextId: optimizationContextId ) diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift index 21f1dba6b..50fbc7ae6 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift @@ -47,7 +47,7 @@ public struct OptimizedEntry: View { onTap: (([String: Any]) -> Void)? = nil, @ViewBuilder content: @escaping ([String: Any]) -> Content ) { - self.entry = (try? CTEntry(any: entry)) ?? (try! CTEntry(any: [String: Any]())) + self.entry = .parseWithFallback(entry) self.dwellTimeMs = dwellTimeMs self.minVisibleRatio = minVisibleRatio self.viewDurationUpdateIntervalMs = viewDurationUpdateIntervalMs @@ -84,7 +84,7 @@ public struct OptimizedEntry: View { self.trackTaps = trackTaps self.accessibilityIdentifier = accessibilityIdentifier self.onTap = onTap - self.content = { raw in content((try? CTEntry(any: raw)) ?? CTEntry(entry)) } + self.content = { raw in content(.parseWithFallback(raw, fallback: CTEntry(entry))) } } private var isOptimized: Bool { From 3cbb09f52493e86763abd1edf76dd3e5698eda4f Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Fri, 31 Jul 2026 12:11:29 +0200 Subject: [PATCH 15/21] refactor(swift): add CTEntry.toDictionary(fallback:), narrow toFoundation back to internal toFoundation() as? [String: Any] ?? x was duplicated at 4 call sites (3 in packages/ios, 1 in the UIKit reference implementation), one of which used a different fallback than the rest. toDictionary(fallback:) consolidates the cast-and-fallback, matching parseWithFallback's naming. toFoundation() no longer needs to be public now that every external caller goes through toDictionary() instead. Co-Authored-By: Claude Sonnet 5 --- .../uikit/Components/OptimizedEntryUIView.swift | 2 +- .../ContentfulOptimization/Contentful/CTEntry.swift | 13 ++++++++++--- .../Core/OptimizationClient.swift | 5 ++--- .../Views/OptimizedEntry.swift | 4 ++-- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/implementations/ios-sdk/uikit/Components/OptimizedEntryUIView.swift b/implementations/ios-sdk/uikit/Components/OptimizedEntryUIView.swift index bd0788874..7111dd9e9 100644 --- a/implementations/ios-sdk/uikit/Components/OptimizedEntryUIView.swift +++ b/implementations/ios-sdk/uikit/Components/OptimizedEntryUIView.swift @@ -107,7 +107,7 @@ final class OptimizedEntryUIView: UIView { baseline: entry, selectedOptimizations: effectiveOptimizations ) - resolvedEntry = result.entry.toFoundation() as? [String: Any] ?? entry + resolvedEntry = result.entry.toDictionary(fallback: entry) resolvedOptimization = result.selectedOptimization } else { resolvedEntry = entry diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift index 9a8f3cece..6f4987ccc 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift @@ -64,12 +64,19 @@ public struct CTEntry { return String(decoding: data, as: UTF8.self) } + func toFoundation() -> Any { + guard let data = try? JSONEncoder().encode(envelope) else { return [String: Any]() } + return (try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])) ?? [String: Any]() + } + + /// `toFoundation()`, narrowed to `[String: Any]` — an entry's top level is always + /// `{sys, fields, metadata}`, so this only fails to cast if `toFoundation()` itself already + /// degraded (e.g. encoding failed), in which case it returns `fallback` (`[:]` by default). /// For call sites that still work with `[String: Any]` — e.g. the reference UIKit /// implementation's dict-based render closures, or `OptimizedEntry`'s own `[String: Any]` /// initializer. - public func toFoundation() -> Any { - guard let data = try? JSONEncoder().encode(envelope) else { return [String: Any]() } - return (try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])) ?? [String: Any]() + public func toDictionary(fallback: @autoclosure () -> [String: Any] = [:]) -> [String: Any] { + toFoundation() as? [String: Any] ?? fallback() } /// A field's resolved value, or nil if absent. diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift index a09a9e39e..c2ce90cd3 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift @@ -414,7 +414,7 @@ public final class OptimizationClient: ObservableObject { /// `Contentful.Entry` overload of `resolveOptimizedEntry(baseline:selectedOptimizations:)` — /// encodes `baseline` through `CTEntry(_: Contentful.Entry)` once, so callers stop /// hand-writing the `Entry -> {sys, fields, metadata}` mapping outside of `OptimizedEntry`'s - /// view initializer. Delegates to the dict-based overload above (via `toFoundation()`), so it + /// view initializer. Delegates to the dict-based overload above (via `toDictionary()`), so it /// inherits the same fail-soft behavior: not initialized, a serialization error, or an /// unparseable bridge result all fall back to the mapped baseline with /// `selectedOptimization`/`optimizationContextId` nil, logging rather than throwing. @@ -422,8 +422,7 @@ public final class OptimizationClient: ObservableObject { baseline: Contentful.Entry, selectedOptimizations: [[String: Any]]? = nil ) -> ResolvedOptimizedEntry { - let mappedBaseline = CTEntry(baseline) - let dictBaseline = mappedBaseline.toFoundation() as? [String: Any] ?? [:] + let dictBaseline = CTEntry(baseline).toDictionary() return resolveOptimizedEntry(baseline: dictBaseline, selectedOptimizations: selectedOptimizations) } diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift index 50fbc7ae6..67394b0fd 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift @@ -115,7 +115,7 @@ public struct OptimizedEntry: View { } public var body: some View { - let entryDict = entry.toFoundation() as? [String: Any] ?? [:] + let entryDict = entry.toDictionary() let result: ResolvedOptimizedEntry = { if isOptimized { return client.resolveOptimizedEntry( @@ -131,7 +131,7 @@ public struct OptimizedEntry: View { } }() - content(result.entry.toFoundation() as? [String: Any] ?? [:]) + content(result.entry.toDictionary()) .modifier(ViewTrackingModifier( entry: entryDict, optimizationContextId: result.optimizationContextId, From 814b34ff98f8f4ee8bfeed837c6256c524871188 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Mon, 3 Aug 2026 09:02:22 +0200 Subject: [PATCH 16/21] refactor(swift): cache JSONEncoder/ISO8601DateFormatter in CTEntry, trim comments Reuse shared instances instead of allocating per call in the resolve hot path, and cut doc comments that only restated the code, per PR review. --- .../Contentful/CTEntry.swift | 114 ++++++------------ 1 file changed, 36 insertions(+), 78 deletions(-) diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift index 6f4987ccc..8928bf6d2 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift @@ -1,18 +1,20 @@ import Contentful import Foundation -/// Bridges `Contentful.Entry` and the resolver's raw JSON (`{sys, fields, metadata}`), backed by -/// `CDA.Entry` rather than a hand-built `[String: Any]` read back with `as?` casts. +/// Reused across `CTEntry`/`CDA` rather than allocated per call. +private let jsonEncoder = JSONEncoder() +private let iso8601DateFormatter = ISO8601DateFormatter() + +/// Bridges `Contentful.Entry` and the resolver's raw JSON (`{sys, fields, metadata}`). /// `init(_:Contentful.Entry)`/`toJSON()` encode; `init(any:)`/`init(json:)` decode. /// -/// This shares the resolved *shape* with `Entry`, not the type: `Entry.init(from:)` needs a -/// `LocalizationContext` only a live CDA decode carries. `type`, `currentlySelectedLocale`, -/// `metadata`, and `setLocale(withCode:)` have no counterpart here — each needs a resource +/// Shares the resolved *shape* with `Entry`, not the type: `type`, `currentlySelectedLocale`, +/// `metadata`, and `setLocale(withCode:)` have no counterpart here, since each needs a resource /// (`ContentType`, `Locale`, `Metadata`) with no public initializer to fabricate from the resolved /// tree alone. /// -/// `JSONValue.number` has no `Int` case, so an `Int` field (`sys.revision`, a file's `details.size`) -/// round-trips as `Double`; `getField` won't match it — read `Double` instead. +/// `JSONValue.number` has no `Int` case, so an `Int` field round-trips as `Double` — +/// `getField` won't match it. public struct CTEntry { private let envelope: CDA.Entry @@ -20,11 +22,7 @@ public struct CTEntry { self.envelope = envelope } - // Parsing - - /// An entry with no `sys`/`fields`/`metadata` — the fallback for a baseline that fails to - /// parse (see `init(any:)`), since every reader below (`id`, `getField`, `hasField`, etc.) - /// already treats an empty envelope as "absent." + /// The `parseWithFallback` default — every reader below treats an empty envelope as "absent." static let empty = CTEntry(CDA.Entry(sys: nil, fields: [:], metadata: nil)) public init(_ entry: Contentful.Entry) { @@ -46,8 +44,7 @@ public struct CTEntry { envelope = try JSONDecoder().decode(CDA.Entry.self, from: data) } - /// `init(any:)`, but for a caller with a fallback rather than a `throws` path — logs and - /// returns `fallback` (`.empty` by default) instead of throwing. + /// `init(any:)` without a `throws` path — logs and returns `fallback` instead. static func parseWithFallback(_ any: Any, fallback: @autoclosure () -> CTEntry = .empty) -> CTEntry { do { return try CTEntry(any: any) @@ -57,24 +54,18 @@ public struct CTEntry { } } - // Serializing - func toJSON() throws -> String { - let data = try JSONEncoder().encode(envelope) + let data = try jsonEncoder.encode(envelope) return String(decoding: data, as: UTF8.self) } func toFoundation() -> Any { - guard let data = try? JSONEncoder().encode(envelope) else { return [String: Any]() } + guard let data = try? jsonEncoder.encode(envelope) else { return [String: Any]() } return (try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])) ?? [String: Any]() } - /// `toFoundation()`, narrowed to `[String: Any]` — an entry's top level is always - /// `{sys, fields, metadata}`, so this only fails to cast if `toFoundation()` itself already - /// degraded (e.g. encoding failed), in which case it returns `fallback` (`[:]` by default). - /// For call sites that still work with `[String: Any]` — e.g. the reference UIKit - /// implementation's dict-based render closures, or `OptimizedEntry`'s own `[String: Any]` - /// initializer. + /// `toFoundation()`, narrowed to `[String: Any]` for callers that still work in that shape + /// (e.g. the reference UIKit implementation, `OptimizedEntry`'s `[String: Any]` initializer). public func toDictionary(fallback: @autoclosure () -> [String: Any] = [:]) -> [String: Any] { toFoundation() as? [String: Any] ?? fallback() } @@ -88,48 +79,34 @@ public struct CTEntry { envelope.fields[name]?.toFoundation() as? T } - /// Whether a field is present, regardless of its value's type. public func hasField(_ name: String) -> Bool { envelope.fields[name] != nil } - // Mirrors Contenful Entry - - /// The entry `sys.id` — stable across a variant swap, so it's safe for navigation. + /// Stable across a variant swap, so it's safe for navigation. public var id: String? { envelope.sys?.id } - /// Mirrors `Entry.localeCode`. Absent on a raw CDA response fetched via `/sync` or wildcard - /// `locale=*`, same as on `Entry` itself. public var localeCode: String? { envelope.sys?.locale } - /// Mirrors `Entry.createdAt`/`updatedAt`. `nil` if the resolved tree never carried the - /// timestamp, same as `Entry` returning `nil` for a `select()`-queried resource without `sys`. public var createdAt: Date? { - envelope.sys?.createdAt.flatMap { ISO8601DateFormatter().date(from: $0) } + envelope.sys?.createdAt.flatMap { iso8601DateFormatter.date(from: $0) } } public var updatedAt: Date? { - envelope.sys?.updatedAt.flatMap { ISO8601DateFormatter().date(from: $0) } + envelope.sys?.updatedAt.flatMap { iso8601DateFormatter.date(from: $0) } } - /// Mirrors `Entry`'s `String` subscript. public subscript(field key: String) -> String? { getField(key) } } -// MARK: - Codable envelopes for the raw CDA response shapes - -/// Small `Codable` structs mirroring the fixed parts of a raw CDA response — `sys`, a content-type -/// link, `metadata`, an unresolved-link stub, an asset, a Structured Text node. Each has an -/// `init(_:)` built from the corresponding `contentful.swift` type. +/// Codable structs mirroring the fixed parts of a raw CDA response. private enum CDA { - /// The `{sys: {id, type: "Link", linkType}}` shape an unresolved link has in a raw CDA - /// response. struct LinkStub: Codable { let sys: Sys struct Sys: Codable { @@ -143,7 +120,6 @@ private enum CDA { } } - /// A link field's resolved value, one step before it becomes `JSONValue`. enum LinkValue { case entry(Entry) case asset(AssetEnvelope) @@ -175,18 +151,14 @@ private enum CDA { } } - /// One field value's resolved shape, one step before it becomes `JSONValue`. enum Field { - /// A leaf/container `JSONValue`, or `nil` for a value `init(_:ancestors:)` drops (no case - /// for it, or a non-finite `Double`/`Location` coordinate). case value(JSONValue?) case link(LinkValue) case richText(RichTextNodeEnvelope) case fileMetadata(FileMetadataEnvelope) case location(LocationEnvelope) - /// `nil` if this value can't become `JSONValue`. Every caller drops the field on `nil` - /// rather than losing the whole entry. + /// `nil` if unrepresentable — caller drops the field rather than losing the whole entry. func encoded() -> JSONValue? { switch self { case let .value(value): return value @@ -197,17 +169,14 @@ private enum CDA { } } - /// One field value, reduced to something the bridge accepts. Anything not listed here is - /// dropped: losing an unused field beats losing personalization on the entry that holds it. init(_ value: Any, ancestors: Set) { switch value { case let link as Contentful.Link: self = .link(LinkValue(link, ancestors: ancestors)) case let richText as Contentful.RichTextDocument: self = .richText(RichTextNodeEnvelope(richText, ancestors: ancestors)) - // A field of Contentful type "Object" shaped like a file metadata blob - // (`{fileName, contentType, url, details: {size, image: {width, height}}}`) decodes to - // this type before falling back to a plain dictionary. + // An "Object" field shaped like file metadata decodes to this type before falling + // back to a plain dictionary. case let file as Contentful.Asset.FileMetadata: self = .fileMetadata(FileMetadataEnvelope(file)) case let array as [Any]: @@ -217,7 +186,7 @@ private enum CDA { case let location as Contentful.Location: self = .location(LocationEnvelope(location)) case let date as Date: - self = .value(.string(ISO8601DateFormatter().string(from: date))) + self = .value(.string(iso8601DateFormatter.string(from: date))) case let string as String: self = .value(.string(string)) case let int as Int: @@ -232,10 +201,9 @@ private enum CDA { } } - /// Properties decode independently via `try?` rather than synthesized `Codable`: a - /// synthesized decoder throws — failing all of `Sys` — the moment one key is absent or the - /// wrong type. A caller-supplied baseline isn't guaranteed well-formed, so a per-field `try?` - /// degrades just the offending key to `nil`. + /// Properties decode independently via `try?`: a synthesized decoder would fail all of `Sys` + /// the moment one key is absent or the wrong type, but a caller-supplied baseline isn't + /// guaranteed well-formed. struct Sys: Codable { let id: String? let type: String? @@ -279,16 +247,15 @@ private enum CDA { id: sys.id, type: "Entry", contentType: .init(sys: .init(id: sys.contentTypeId ?? "", type: "Link", linkType: "ContentType")), - createdAt: sys.createdAt.map { ISO8601DateFormatter().string(from: $0) }, - updatedAt: sys.updatedAt.map { ISO8601DateFormatter().string(from: $0) }, + createdAt: sys.createdAt.map { iso8601DateFormatter.string(from: $0) }, + updatedAt: sys.updatedAt.map { iso8601DateFormatter.string(from: $0) }, revision: sys.revision, locale: sys.locale ) } } - /// `sys`/`fields`/`metadata` decode independently via `try?` for the same reason `Sys`'s - /// properties do — a caller-supplied baseline can be missing any of them. + /// Same per-field `try?` reasoning as `Sys`. struct Entry: Codable { let sys: Sys? let fields: [String: JSONValue] @@ -311,20 +278,17 @@ private enum CDA { self.metadata = metadata } - /// `ancestors` is the set of entry ids on the path from root to here. The Delivery SDK - /// resolves links into shared object references, so a variant linking back to its - /// baseline is a real cycle; recursing an entry already on the current path would loop - /// forever, so a re-linked ancestor emits an unresolved link stub instead. Scoping to the - /// current path (not a global visited set) still expands diamonds fully on both branches. + /// `ancestors` is the path from root to here — a variant linking back to its baseline is a + /// real cycle, so a re-linked ancestor emits an unresolved link stub instead of recursing + /// forever. Scoped to the current path (not a global visited set) so diamonds still expand + /// fully on both branches. init(_ entry: Contentful.Entry, ancestors: Set) { let childAncestors = ancestors.union([entry.id]) let sys = Sys(entry.sys) let fields = entry.fields.compactMapValues { Field($0, ancestors: childAncestors).encoded() } - // Required, not cosmetic: the resolver's entry guard rejects any entry without a - // `metadata` object. `concepts` is always empty — `contentful.swift`'s `Metadata` - // models only `tags`. + // The resolver's entry guard rejects any entry without a `metadata` object. let metadata = Metadata( tags: (entry.metadata?.tags ?? []).compactMap { try? LinkValue($0, ancestors: childAncestors).encoded() }, concepts: [] @@ -366,8 +330,6 @@ private enum CDA { } } - /// An asset's `file` metadata, reduced to the raw CDA response shape. `details.image` is only - /// present for image files. struct FileMetadataEnvelope: Codable { let fileName: String? let contentType: String? @@ -404,7 +366,6 @@ private enum CDA { } } - /// A `Location` field, reduced to the raw CDA response shape (`{lat, lon}`). struct LocationEnvelope: Codable { let lat: Double let lon: Double @@ -415,8 +376,6 @@ private enum CDA { } } - /// One Structured Text node, reduced to the `{nodeType, data, content}` shape a raw CDA - /// response carries. struct RichTextNodeEnvelope: Codable { let nodeType: String var value: String? @@ -473,9 +432,8 @@ private enum CDA { value: text.value, marks: text.marks.map { .init(type: $0.type.rawValue) } ) - // Table/TableRow/TableRowHeaderCell/TableRowCell/Paragraph/Heading/BlockQuote/ - // HorizontalRule/OrderedList/UnorderedList/ListItem, and the top-level - // RichTextDocument itself — all plain containers with no data beyond their children. + // Every other container node (tables, lists, headings, the document root, etc.) + // conforms to RecursiveNode with no data beyond its children. case let recursive as Contentful.RecursiveNode: self.init( nodeType: recursive.nodeType.rawValue, From 3765aa5a93d8bf654525f48b3ba853feef692637 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Mon, 3 Aug 2026 09:17:04 +0200 Subject: [PATCH 17/21] refactor(swift): move JSONValue.encoded(_:) into CTEntry, cache JSONDecoder Only CTEntry's CDA mapping used it, so it doesn't belong on the general-purpose JSONValue type. Reuses the shared encoder/decoder instead of allocating fresh JSONEncoder/JSONDecoder instances. --- .../Contentful/CTEntry.swift | 14 ++++++++++++-- .../ContentfulOptimization/Core/JSONValue.swift | 7 ------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift index 8928bf6d2..5111916b7 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift @@ -3,8 +3,18 @@ import Foundation /// Reused across `CTEntry`/`CDA` rather than allocated per call. private let jsonEncoder = JSONEncoder() +private let jsonDecoder = JSONDecoder() private let iso8601DateFormatter = ISO8601DateFormatter() +private extension JSONValue { + /// Encodes any `Encodable` value into `JSONValue` via a real `JSONEncoder` -> `JSONDecoder` + /// round trip, rather than a hand-assembled dictionary literal. + static func encoded(_ value: some Encodable) throws -> JSONValue { + let data = try jsonEncoder.encode(value) + return try jsonDecoder.decode(JSONValue.self, from: data) + } +} + /// Bridges `Contentful.Entry` and the resolver's raw JSON (`{sys, fields, metadata}`). /// `init(_:Contentful.Entry)`/`toJSON()` encode; `init(any:)`/`init(json:)` decode. /// @@ -33,7 +43,7 @@ public struct CTEntry { guard let data = json.data(using: .utf8) else { throw OptimizationError.configError("JSON string is not valid UTF-8") } - envelope = try JSONDecoder().decode(CDA.Entry.self, from: data) + envelope = try jsonDecoder.decode(CDA.Entry.self, from: data) } init(any: Any) throws { @@ -41,7 +51,7 @@ public struct CTEntry { throw OptimizationError.configError("Unsupported value of type \(Swift.type(of: any)) in CTEntry(any:)") } let data = try JSONSerialization.data(withJSONObject: any) - envelope = try JSONDecoder().decode(CDA.Entry.self, from: data) + envelope = try jsonDecoder.decode(CDA.Entry.self, from: data) } /// `init(any:)` without a `throws` path — logs and returns `fallback` instead. diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/JSONValue.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/JSONValue.swift index cdf990f58..73ac68693 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/JSONValue.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/JSONValue.swift @@ -50,13 +50,6 @@ extension JSONValue: Codable { case .object(let v): try container.encode(v) } } - - /// Encodes any `Encodable` value into `JSONValue` via a real `JSONEncoder` -> `JSONDecoder` - /// round trip, rather than a hand-assembled dictionary literal. - public static func encoded(_ value: some Encodable) throws -> JSONValue { - let data = try JSONEncoder().encode(value) - return try JSONDecoder().decode(JSONValue.self, from: data) - } } // MARK: - Accessors From 5c2ed1d47c0425d2c683050e692992c2f69d8547 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Mon, 3 Aug 2026 09:41:22 +0200 Subject: [PATCH 18/21] refactor(swift): merge CTEntry.init(any:) and parseWithFallback into one initializer init(any:) had no caller that used its throw separately from parseWithFallback's catch-and-log, so collapse them into a single non-throwing init(any:fallback:). Also fold CTEntry.toFoundation() into toDictionary(fallback:) since every caller immediately cast its Any result to [String: Any] anyway. --- .../Contentful/CTEntry.swift | 88 +++++++++--------- .../Core/OptimizationClient.swift | 6 +- .../Views/OptimizedEntry.swift | 4 +- .../CTEntryTests.swift | 90 +++++++++++-------- .../OptimizedEntryContentfulInitTests.swift | 12 +-- 5 files changed, 102 insertions(+), 98 deletions(-) diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift index 5111916b7..5b8b5e1f8 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift @@ -6,13 +6,11 @@ private let jsonEncoder = JSONEncoder() private let jsonDecoder = JSONDecoder() private let iso8601DateFormatter = ISO8601DateFormatter() -private extension JSONValue { - /// Encodes any `Encodable` value into `JSONValue` via a real `JSONEncoder` -> `JSONDecoder` - /// round trip, rather than a hand-assembled dictionary literal. - static func encoded(_ value: some Encodable) throws -> JSONValue { - let data = try jsonEncoder.encode(value) - return try jsonDecoder.decode(JSONValue.self, from: data) - } +/// Encodes any `Encodable` value into `JSONValue` via a real `JSONEncoder` -> `JSONDecoder` round +/// trip, rather than a hand-assembled dictionary literal. +private func jsonValueEncoded(_ value: some Encodable) throws -> JSONValue { + let data = try jsonEncoder.encode(value) + return try jsonDecoder.decode(JSONValue.self, from: data) } /// Bridges `Contentful.Entry` and the resolver's raw JSON (`{sys, fields, metadata}`). @@ -26,58 +24,54 @@ private extension JSONValue { /// `JSONValue.number` has no `Int` case, so an `Int` field round-trips as `Double` — /// `getField` won't match it. public struct CTEntry { - private let envelope: CDA.Entry + private let entry: CDA.Entry - private init(_ envelope: CDA.Entry) { - self.envelope = envelope + private init(_ entry: CDA.Entry) { + self.entry = entry } - /// The `parseWithFallback` default — every reader below treats an empty envelope as "absent." + /// The `init(any:fallback:)` default — every reader below treats an empty entry as "absent." static let empty = CTEntry(CDA.Entry(sys: nil, fields: [:], metadata: nil)) - public init(_ entry: Contentful.Entry) { - envelope = CDA.Entry(entry, ancestors: []) + public init(_ contentfulEntry: Contentful.Entry) { + entry = CDA.Entry(contentfulEntry, ancestors: []) } init(json: String) throws { guard let data = json.data(using: .utf8) else { throw OptimizationError.configError("JSON string is not valid UTF-8") } - envelope = try jsonDecoder.decode(CDA.Entry.self, from: data) - } - - init(any: Any) throws { - guard JSONSerialization.isValidJSONObject(any) else { - throw OptimizationError.configError("Unsupported value of type \(Swift.type(of: any)) in CTEntry(any:)") - } - let data = try JSONSerialization.data(withJSONObject: any) - envelope = try jsonDecoder.decode(CDA.Entry.self, from: data) + entry = try jsonDecoder.decode(CDA.Entry.self, from: data) } - /// `init(any:)` without a `throws` path — logs and returns `fallback` instead. - static func parseWithFallback(_ any: Any, fallback: @autoclosure () -> CTEntry = .empty) -> CTEntry { + /// `any` is caller-supplied and not guaranteed JSON-safe — logs and falls back to `fallback` + /// (`.empty` by default) instead of throwing. + init(any: Any, fallback: @autoclosure () -> CTEntry = .empty) { do { - return try CTEntry(any: any) + guard JSONSerialization.isValidJSONObject(any) else { + throw OptimizationError.configError("Unsupported value of type \(Swift.type(of: any)) in CTEntry(any:)") + } + let data = try JSONSerialization.data(withJSONObject: any) + entry = try jsonDecoder.decode(CDA.Entry.self, from: data) } catch { DiagnosticLogger.shared.warning("[CTEntry] Failed to parse entry: \(error.localizedDescription)") - return fallback() + self = fallback() } } func toJSON() throws -> String { - let data = try jsonEncoder.encode(envelope) + let data = try jsonEncoder.encode(entry) return String(decoding: data, as: UTF8.self) } - func toFoundation() -> Any { - guard let data = try? jsonEncoder.encode(envelope) else { return [String: Any]() } - return (try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])) ?? [String: Any]() - } - - /// `toFoundation()`, narrowed to `[String: Any]` for callers that still work in that shape - /// (e.g. the reference UIKit implementation, `OptimizedEntry`'s `[String: Any]` initializer). + /// For callers that still work in `[String: Any]` shape (e.g. the reference UIKit + /// implementation, `OptimizedEntry`'s `[String: Any]` initializer). public func toDictionary(fallback: @autoclosure () -> [String: Any] = [:]) -> [String: Any] { - toFoundation() as? [String: Any] ?? fallback() + guard let data = try? jsonEncoder.encode(entry), + let object = try? JSONSerialization.jsonObject(with: data), + let dictionary = object as? [String: Any] + else { return fallback() } + return dictionary } /// A field's resolved value, or nil if absent. @@ -86,28 +80,28 @@ public struct CTEntry { /// succeeds, so a missing field comes back `Optional(nil)`, not `nil`. Use `hasField` or a /// concrete `T` instead. public func getField(_ name: String) -> T? { - envelope.fields[name]?.toFoundation() as? T + entry.fields[name]?.toFoundation() as? T } public func hasField(_ name: String) -> Bool { - envelope.fields[name] != nil + entry.fields[name] != nil } /// Stable across a variant swap, so it's safe for navigation. public var id: String? { - envelope.sys?.id + entry.sys?.id } public var localeCode: String? { - envelope.sys?.locale + entry.sys?.locale } public var createdAt: Date? { - envelope.sys?.createdAt.flatMap { iso8601DateFormatter.date(from: $0) } + entry.sys?.createdAt.flatMap { iso8601DateFormatter.date(from: $0) } } public var updatedAt: Date? { - envelope.sys?.updatedAt.flatMap { iso8601DateFormatter.date(from: $0) } + entry.sys?.updatedAt.flatMap { iso8601DateFormatter.date(from: $0) } } public subscript(field key: String) -> String? { @@ -137,9 +131,9 @@ private enum CDA { func encoded() throws -> JSONValue { switch self { - case let .entry(envelope): return try JSONValue.encoded(envelope) - case let .asset(envelope): return try JSONValue.encoded(envelope) - case let .stub(envelope): return try JSONValue.encoded(envelope) + case let .entry(envelope): return try jsonValueEncoded(envelope) + case let .asset(envelope): return try jsonValueEncoded(envelope) + case let .stub(envelope): return try jsonValueEncoded(envelope) } } @@ -173,9 +167,9 @@ private enum CDA { switch self { case let .value(value): return value case let .link(linkValue): return try? linkValue.encoded() - case let .richText(envelope): return try? JSONValue.encoded(envelope) - case let .fileMetadata(envelope): return try? JSONValue.encoded(envelope) - case let .location(envelope): return try? JSONValue.encoded(envelope) + case let .richText(envelope): return try? jsonValueEncoded(envelope) + case let .fileMetadata(envelope): return try? jsonValueEncoded(envelope) + case let .location(envelope): return try? jsonValueEncoded(envelope) } } diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift index fab1510b6..16ba5af24 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift @@ -357,9 +357,7 @@ public final class OptimizationClient: ObservableObject { baseline: [String: Any], selectedOptimizations: [[String: Any]]? = nil ) -> ResolvedOptimizedEntry { - // `baseline` is caller-supplied and not guaranteed JSON-safe — `.parseWithFallback` logs - // and falls back to `.empty` rather than throwing. - let baselineEntry = CTEntry.parseWithFallback(baseline) + let baselineEntry = CTEntry(any: baseline) guard isInitialized else { return ResolvedOptimizedEntry( @@ -396,7 +394,7 @@ public final class OptimizationClient: ObservableObject { let selectedOptimization = dict["selectedOptimization"] as? [String: Any] let optimizationContextId = dict["optimizationContextId"] as? String return ResolvedOptimizedEntry( - entry: .parseWithFallback(entry, fallback: baselineEntry), + entry: CTEntry(any: entry, fallback: baselineEntry), selectedOptimization: selectedOptimization, optimizationContextId: optimizationContextId ) diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift index 67394b0fd..8af75fb26 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift @@ -47,7 +47,7 @@ public struct OptimizedEntry: View { onTap: (([String: Any]) -> Void)? = nil, @ViewBuilder content: @escaping ([String: Any]) -> Content ) { - self.entry = .parseWithFallback(entry) + self.entry = CTEntry(any: entry) self.dwellTimeMs = dwellTimeMs self.minVisibleRatio = minVisibleRatio self.viewDurationUpdateIntervalMs = viewDurationUpdateIntervalMs @@ -84,7 +84,7 @@ public struct OptimizedEntry: View { self.trackTaps = trackTaps self.accessibilityIdentifier = accessibilityIdentifier self.onTap = onTap - self.content = { raw in content(.parseWithFallback(raw, fallback: CTEntry(entry))) } + self.content = { raw in content(CTEntry(any: raw, fallback: CTEntry(entry))) } } private var isOptimized: Bool { diff --git a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/CTEntryTests.swift b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/CTEntryTests.swift index 3d8b3fbf0..c0b0f5d35 100644 --- a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/CTEntryTests.swift +++ b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/CTEntryTests.swift @@ -1169,8 +1169,8 @@ final class CTEntryTests: XCTestCase { // MARK: - Reading a resolved entry - func testGetFieldReturnsValueForMatchingType() throws { - let resolved = try CTEntry(any: [ + func testGetFieldReturnsValueForMatchingType() { + let resolved = CTEntry(any: [ "sys": ["id": "e1"], "fields": ["title": "Hello", "count": 3.0, "isFeatured": true], ]) @@ -1181,87 +1181,87 @@ final class CTEntryTests: XCTestCase { XCTAssertEqual(resolved.getField("isFeatured"), true) } - func testGetFieldReturnsNilForWrongRequestedType() throws { + func testGetFieldReturnsNilForWrongRequestedType() { // "count" is a Double in the raw map; requesting it as String must fail the `as?` cast // and return nil, not crash or coerce. - let resolved = try CTEntry(any: ["sys": [:], "fields": ["count": 3.0]]) + let resolved = CTEntry(any: ["sys": [:], "fields": ["count": 3.0]]) let asString: String? = resolved.getField("count") XCTAssertNil(asString) } - func testGetFieldReturnsNilForAbsentField() throws { - let resolved = try CTEntry(any: ["sys": [:], "fields": ["title": "Hello"]]) + func testGetFieldReturnsNilForAbsentField() { + let resolved = CTEntry(any: ["sys": [:], "fields": ["title": "Hello"]]) let missing: String? = resolved.getField("subtitle") XCTAssertNil(missing) } - func testGetFieldReturnsNilWhenFieldsKeyIsAbsent() throws { + func testGetFieldReturnsNilWhenFieldsKeyIsAbsent() { // No "fields" key at all — e.g. a malformed or partial resolver output. - let resolved = try CTEntry(any: ["sys": ["id": "e1"]]) + let resolved = CTEntry(any: ["sys": ["id": "e1"]]) let value: String? = resolved.getField("title") XCTAssertNil(value) } - func testHasFieldReturnsTrueForPresentFieldRegardlessOfValueType() throws { - let resolved = try CTEntry(any: ["sys": [:], "fields": ["nt_experiences": NSNull()]]) + func testHasFieldReturnsTrueForPresentFieldRegardlessOfValueType() { + let resolved = CTEntry(any: ["sys": [:], "fields": ["nt_experiences": NSNull()]]) XCTAssertTrue(resolved.hasField("nt_experiences")) } - func testHasFieldReturnsFalseForAbsentField() throws { - let resolved = try CTEntry(any: ["sys": [:], "fields": ["title": "Hello"]]) + func testHasFieldReturnsFalseForAbsentField() { + let resolved = CTEntry(any: ["sys": [:], "fields": ["title": "Hello"]]) XCTAssertFalse(resolved.hasField("nt_experiences")) } - func testHasFieldReturnsFalseWhenFieldsKeyIsAbsent() throws { - let resolved = try CTEntry(any: ["sys": ["id": "e1"]]) + func testHasFieldReturnsFalseWhenFieldsKeyIsAbsent() { + let resolved = CTEntry(any: ["sys": ["id": "e1"]]) XCTAssertFalse(resolved.hasField("nt_experiences")) } - func testIdReturnsSysId() throws { - let resolved = try CTEntry(any: ["sys": ["id": "e1"], "fields": [:]]) + func testIdReturnsSysId() { + let resolved = CTEntry(any: ["sys": ["id": "e1"], "fields": [:]]) XCTAssertEqual(resolved.id, "e1") } - func testIdReturnsNilWhenSysKeyIsAbsent() throws { - let resolved = try CTEntry(any: ["fields": ["title": "Hello"]]) + func testIdReturnsNilWhenSysKeyIsAbsent() { + let resolved = CTEntry(any: ["fields": ["title": "Hello"]]) XCTAssertNil(resolved.id) } - func testIdReturnsNilWhenSysIdIsWrongType() throws { + func testIdReturnsNilWhenSysIdIsWrongType() { // "id" present but not a String — e.g. accidentally passed a number. - let resolved = try CTEntry(any: ["sys": ["id": 123.0], "fields": [:]]) + let resolved = CTEntry(any: ["sys": ["id": 123.0], "fields": [:]]) XCTAssertNil(resolved.id) } // MARK: - localeCode mirrors Entry.localeCode - func testLocaleCodeReturnsSysLocale() throws { - let resolved = try CTEntry(any: ["sys": ["id": "e1", "locale": "en-US"], "fields": [:]]) + func testLocaleCodeReturnsSysLocale() { + let resolved = CTEntry(any: ["sys": ["id": "e1", "locale": "en-US"], "fields": [:]]) XCTAssertEqual(resolved.localeCode, "en-US") } - func testLocaleCodeReturnsNilWhenAbsent() throws { + func testLocaleCodeReturnsNilWhenAbsent() { // Absent on a raw CDA response fetched via /sync or the wildcard `locale=*` query — // same case where `Entry.localeCode` itself returns nil. - let resolved = try CTEntry(any: ["sys": ["id": "e1"], "fields": [:]]) + let resolved = CTEntry(any: ["sys": ["id": "e1"], "fields": [:]]) XCTAssertNil(resolved.localeCode) } // MARK: - createdAt/updatedAt mirror Entry.createdAt/updatedAt - func testCreatedAtAndUpdatedAtParseISO8601SysTimestamps() throws { - let resolved = try CTEntry(any: [ + func testCreatedAtAndUpdatedAtParseISO8601SysTimestamps() { + let resolved = CTEntry(any: [ "sys": ["id": "e1", "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-06-15T12:30:00Z"], "fields": [:], ]) @@ -1271,42 +1271,54 @@ final class CTEntryTests: XCTestCase { XCTAssertNotEqual(resolved.createdAt, resolved.updatedAt) } - func testCreatedAtAndUpdatedAtReturnNilWhenAbsent() throws { + func testCreatedAtAndUpdatedAtReturnNilWhenAbsent() { // A resolver-synthesized entry may carry no creation/update timestamps — same as // `Entry.createdAt`/`updatedAt` returning nil for a resource fetched without `sys` dates. - let resolved = try CTEntry(any: ["sys": ["id": "e1"], "fields": [:]]) + let resolved = CTEntry(any: ["sys": ["id": "e1"], "fields": [:]]) XCTAssertNil(resolved.createdAt) XCTAssertNil(resolved.updatedAt) } - func testCreatedAtReturnsNilForUnparseableTimestamp() throws { - let resolved = try CTEntry(any: ["sys": ["id": "e1", "createdAt": "not-a-date"], "fields": [:]]) + func testCreatedAtReturnsNilForUnparseableTimestamp() { + let resolved = CTEntry(any: ["sys": ["id": "e1", "createdAt": "not-a-date"], "fields": [:]]) XCTAssertNil(resolved.createdAt) } // MARK: - String field subscript mirrors Entry's convenience subscript - func testStringFieldSubscriptReadsFromFields() throws { - let resolved = try CTEntry(any: ["sys": [:], "fields": ["title": "Hello"]]) + func testStringFieldSubscriptReadsFromFields() { + let resolved = CTEntry(any: ["sys": [:], "fields": ["title": "Hello"]]) let title: String? = resolved[field: "title"] XCTAssertEqual(title, "Hello") } - func testStringFieldSubscriptReturnsNilForWrongType() throws { - let resolved = try CTEntry(any: ["sys": [:], "fields": ["count": 3.0]]) + func testStringFieldSubscriptReturnsNilForWrongType() { + let resolved = CTEntry(any: ["sys": [:], "fields": ["count": 3.0]]) let asString: String? = resolved[field: "count"] XCTAssertNil(asString) } - // MARK: - init(any:) rejects unsupported Foundation types + // MARK: - init(any:fallback:) falls back on unsupported Foundation types - /// `Date`/`Data`/other non-JSON-safe Foundation values have no case in `init(any:)` — a - /// caller passing one gets a thrown error, not a value that quietly reads back as absent. - func testInitAnyThrowsForUnsupportedType() { - XCTAssertThrowsError(try CTEntry(any: ["fields": ["publishedAt": Date()]])) + /// `Date`/`Data`/other non-JSON-safe Foundation values have no case `init(any:fallback:)` + /// handles — a caller passing one gets `fallback` (`.empty` by default), not a value that + /// quietly reads back as absent. + func testInitAnyFallsBackForUnsupportedType() { + let resolved = CTEntry(any: ["fields": ["publishedAt": Date()]]) + + XCTAssertNil(resolved.id) + let value: String? = resolved.getField("publishedAt") + XCTAssertNil(value) + } + + func testInitAnyUsesProvidedFallbackForUnsupportedType() { + let fallback = CTEntry(any: ["sys": ["id": "fallback-id"], "fields": [:]]) + let resolved = CTEntry(any: ["fields": ["publishedAt": Date()]], fallback: fallback) + + XCTAssertEqual(resolved.id, "fallback-id") } } diff --git a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizedEntryContentfulInitTests.swift b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizedEntryContentfulInitTests.swift index 738f61ce0..985858654 100644 --- a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizedEntryContentfulInitTests.swift +++ b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizedEntryContentfulInitTests.swift @@ -57,12 +57,12 @@ final class OptimizedEntryContentfulInitTests: XCTestCase { let sut = OptimizedEntry(entry: entry) { (_: CTEntry) in EmptyView() } XCTAssertEqual( - NSDictionary(dictionary: sut.entry.toFoundation() as? [String: Any] ?? [:]), - NSDictionary(dictionary: CTEntry(entry).toFoundation() as? [String: Any] ?? [:]) + NSDictionary(dictionary: sut.entry.toDictionary()), + NSDictionary(dictionary: CTEntry(entry).toDictionary()) ) XCTAssertEqual(sut.entry.id, "e1") - let dict = sut.entry.toFoundation() as? [String: Any] - XCTAssertNotNil(dict?["metadata"], "the always-present metadata guarantee must hold through the initializer, not just the mapper") + let dict = sut.entry.toDictionary() + XCTAssertNotNil(dict["metadata"], "the always-present metadata guarantee must hold through the initializer, not just the mapper") } // MARK: - The stored `content` closure forwards its actual argument, not the captured baseline entry @@ -122,7 +122,7 @@ final class OptimizedEntryContentfulInitTests: XCTestCase { // `nt_experiences` key, which is the input `isOptimized` reads. let sut = OptimizedEntry(entry: entry) { (_: CTEntry) in EmptyView() } - let fields = (sut.entry.toFoundation() as? [String: Any])?["fields"] as? [String: Any] + let fields = sut.entry.toDictionary()["fields"] as? [String: Any] XCTAssertNil(fields?["nt_experiences"]) } @@ -150,7 +150,7 @@ final class OptimizedEntryContentfulInitTests: XCTestCase { // Exercises the same call `TapTrackingModifier` makes: `onTap?(entry)`, with the view's // own stored baseline `entry` (`sut.entry`) — not a resolved variant. - sut.onTap?(sut.entry.toFoundation() as? [String: Any] ?? [:]) + sut.onTap?(sut.entry.toDictionary()) XCTAssertEqual((receivedOnTapArgument?["sys"] as? [String: Any])?["id"] as? String, "e1") } From 56e903bf27d567ad8602e501f3d2d163c26d6b6f Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Mon, 3 Aug 2026 09:58:32 +0200 Subject: [PATCH 19/21] refactor(swift): drop Envelope/Value suffixes from CDA mirror types LinkValue, AssetEnvelope, LocationEnvelope, FileMetadataEnvelope, and RichTextNodeEnvelope become Link, Asset, Location, FileMetadata, and RichTextNode, matching the existing bare-name convention CDA.Sys/ CDA.Entry/CDA.Metadata already use to shadow their Contentful.* counterparts. --- .../Contentful/CTEntry.swift | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift index 5b8b5e1f8..68de37cf4 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift @@ -124,9 +124,9 @@ private enum CDA { } } - enum LinkValue { + enum Link { case entry(Entry) - case asset(AssetEnvelope) + case asset(Asset) case stub(LinkStub) func encoded() throws -> JSONValue { @@ -144,7 +144,7 @@ private enum CDA { case let .entry(entry) where !ancestors.contains(entry.id): self = .entry(Entry(entry, ancestors: ancestors)) case let .asset(asset): - self = .asset(AssetEnvelope(asset)) + self = .asset(Asset(asset)) case let .unresolved(sys): self = .stub(.init(id: sys.id, linkType: sys.linkType)) // A back-edge, or a typed `EntryDecodable` this mapper never registers: emit the @@ -157,10 +157,10 @@ private enum CDA { enum Field { case value(JSONValue?) - case link(LinkValue) - case richText(RichTextNodeEnvelope) - case fileMetadata(FileMetadataEnvelope) - case location(LocationEnvelope) + case link(Link) + case richText(RichTextNode) + case fileMetadata(FileMetadata) + case location(Location) /// `nil` if unrepresentable — caller drops the field rather than losing the whole entry. func encoded() -> JSONValue? { @@ -176,19 +176,19 @@ private enum CDA { init(_ value: Any, ancestors: Set) { switch value { case let link as Contentful.Link: - self = .link(LinkValue(link, ancestors: ancestors)) + self = .link(Link(link, ancestors: ancestors)) case let richText as Contentful.RichTextDocument: - self = .richText(RichTextNodeEnvelope(richText, ancestors: ancestors)) + self = .richText(RichTextNode(richText, ancestors: ancestors)) // An "Object" field shaped like file metadata decodes to this type before falling // back to a plain dictionary. case let file as Contentful.Asset.FileMetadata: - self = .fileMetadata(FileMetadataEnvelope(file)) + self = .fileMetadata(FileMetadata(file)) case let array as [Any]: self = .value(.array(array.compactMap { Field($0, ancestors: ancestors).encoded() })) case let dictionary as [String: Any]: self = .value(.object(dictionary.compactMapValues { Field($0, ancestors: ancestors).encoded() })) case let location as Contentful.Location: - self = .location(LocationEnvelope(location)) + self = .location(Location(location)) case let date as Date: self = .value(.string(iso8601DateFormatter.string(from: date))) case let string as String: @@ -294,7 +294,7 @@ private enum CDA { // The resolver's entry guard rejects any entry without a `metadata` object. let metadata = Metadata( - tags: (entry.metadata?.tags ?? []).compactMap { try? LinkValue($0, ancestors: childAncestors).encoded() }, + tags: (entry.metadata?.tags ?? []).compactMap { try? Link($0, ancestors: childAncestors).encoded() }, concepts: [] ) @@ -307,7 +307,7 @@ private enum CDA { let concepts: [JSONValue] } - struct AssetEnvelope: Codable { + struct Asset: Codable { let sys: AssetSys let fields: AssetFields @@ -319,7 +319,7 @@ private enum CDA { struct AssetFields: Codable { let title: String let description: String? - let file: FileMetadataEnvelope + let file: FileMetadata } init(_ asset: Contentful.Asset) { @@ -327,14 +327,14 @@ private enum CDA { fields = .init( title: asset.title ?? "", description: asset.description, - file: asset.file.map(FileMetadataEnvelope.init) ?? FileMetadataEnvelope( + file: asset.file.map(FileMetadata.init) ?? FileMetadata( fileName: nil, contentType: nil, details: nil, url: asset.urlString ?? "" ) ) } } - struct FileMetadataEnvelope: Codable { + struct FileMetadata: Codable { let fileName: String? let contentType: String? let details: Details? @@ -370,7 +370,7 @@ private enum CDA { } } - struct LocationEnvelope: Codable { + struct Location: Codable { let lat: Double let lon: Double @@ -380,12 +380,12 @@ private enum CDA { } } - struct RichTextNodeEnvelope: Codable { + struct RichTextNode: Codable { let nodeType: String var value: String? var marks: [Mark]? var data: NodeData - var content: [RichTextNodeEnvelope]? + var content: [RichTextNode]? struct Mark: Codable { let type: String } @@ -399,7 +399,7 @@ private enum CDA { } } - init(nodeType: String, value: String? = nil, marks: [Mark]? = nil, data: NodeData = NodeData(), content: [RichTextNodeEnvelope]? = nil) { + init(nodeType: String, value: String? = nil, marks: [Mark]? = nil, data: NodeData = NodeData(), content: [RichTextNode]? = nil) { self.nodeType = nodeType self.value = value self.marks = marks @@ -415,20 +415,20 @@ private enum CDA { case let resourceLink as Contentful.ResourceLinkBlock: self.init( nodeType: resourceLink.nodeType.rawValue, - data: .init(target: try? LinkValue(resourceLink.data.target, ancestors: ancestors).encoded()), - content: resourceLink.content.map { RichTextNodeEnvelope($0, ancestors: ancestors) } + data: .init(target: try? Link(resourceLink.data.target, ancestors: ancestors).encoded()), + content: resourceLink.content.map { RichTextNode($0, ancestors: ancestors) } ) case let resourceLink as Contentful.ResourceLinkInline: self.init( nodeType: resourceLink.nodeType.rawValue, - data: .init(target: try? LinkValue(resourceLink.data.target, ancestors: ancestors).encoded()), - content: resourceLink.content.map { RichTextNodeEnvelope($0, ancestors: ancestors) } + data: .init(target: try? Link(resourceLink.data.target, ancestors: ancestors).encoded()), + content: resourceLink.content.map { RichTextNode($0, ancestors: ancestors) } ) case let hyperlink as Contentful.Hyperlink: self.init( nodeType: hyperlink.nodeType.rawValue, data: .init(uri: hyperlink.data.uri), - content: hyperlink.content.map { RichTextNodeEnvelope($0, ancestors: ancestors) } + content: hyperlink.content.map { RichTextNode($0, ancestors: ancestors) } ) case let text as Contentful.Text: self.init( @@ -441,7 +441,7 @@ private enum CDA { case let recursive as Contentful.RecursiveNode: self.init( nodeType: recursive.nodeType.rawValue, - content: recursive.content.map { RichTextNodeEnvelope($0, ancestors: ancestors) } + content: recursive.content.map { RichTextNode($0, ancestors: ancestors) } ) default: self.init(nodeType: node.nodeType.rawValue) From 4d9dba65a76a32a5ef35d1389f0ab689dc8f00b7 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Mon, 3 Aug 2026 10:07:27 +0200 Subject: [PATCH 20/21] fix(swift): omit contentType.sys.id instead of defaulting to "" in CDA.Sys An entry with no content type ID previously encoded contentType.sys.id as "", which still satisfies the JS resolver's contentTypeSys.id !== undefined guard check and could make a malformed entry spuriously pass. Omit the whole contentType key instead when the ID is unknown. --- .../Sources/ContentfulOptimization/Contentful/CTEntry.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift index 68de37cf4..6fb7f3ea6 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift @@ -250,7 +250,7 @@ private enum CDA { self.init( id: sys.id, type: "Entry", - contentType: .init(sys: .init(id: sys.contentTypeId ?? "", type: "Link", linkType: "ContentType")), + contentType: sys.contentTypeId.map { .init(sys: .init(id: $0, type: "Link", linkType: "ContentType")) }, createdAt: sys.createdAt.map { iso8601DateFormatter.string(from: $0) }, updatedAt: sys.updatedAt.map { iso8601DateFormatter.string(from: $0) }, revision: sys.revision, From fed41653d28026cc287407a8a2b09f1cb31bd652 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Mon, 3 Aug 2026 10:12:41 +0200 Subject: [PATCH 21/21] refactor(swift): rename stale envelope/linkValue switch bindings in CDA.encoded() Bindings still used pre-rename names from when these cases held *Envelope/LinkValue types. Match them to the current case/type names (entry, asset, stub, link, richText, fileMetadata, location). --- .../Contentful/CTEntry.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift index 6fb7f3ea6..f891293f6 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift @@ -131,9 +131,9 @@ private enum CDA { func encoded() throws -> JSONValue { switch self { - case let .entry(envelope): return try jsonValueEncoded(envelope) - case let .asset(envelope): return try jsonValueEncoded(envelope) - case let .stub(envelope): return try jsonValueEncoded(envelope) + case let .entry(entry): return try jsonValueEncoded(entry) + case let .asset(asset): return try jsonValueEncoded(asset) + case let .stub(stub): return try jsonValueEncoded(stub) } } @@ -166,10 +166,10 @@ private enum CDA { func encoded() -> JSONValue? { switch self { case let .value(value): return value - case let .link(linkValue): return try? linkValue.encoded() - case let .richText(envelope): return try? jsonValueEncoded(envelope) - case let .fileMetadata(envelope): return try? jsonValueEncoded(envelope) - case let .location(envelope): return try? jsonValueEncoded(envelope) + case let .link(link): return try? link.encoded() + case let .richText(richText): return try? jsonValueEncoded(richText) + case let .fileMetadata(fileMetadata): return try? jsonValueEncoded(fileMetadata) + case let .location(location): return try? jsonValueEncoded(location) } }