From e57062ceb59860093d3e5ceed0ddd34f94904b81 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 27 Aug 2026 09:26:39 -0400 Subject: [PATCH] PROD-2446: make companion-field reference scanning schema-driven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROD-2431/2435/2442 fixed ContentFieldMapper's remap of a linked-content field's companion column (LinkeContentDropdownValueField/SortIDFieldName), but two sibling utilities that scan the same content-item field data for different purposes were never updated to match — both still assumed a reference is only ever shaped as a contentid/contentID/sortids object key, with no knowledge of an arbitrarily-named companion field. 1. has-unresolved-content-references.ts (the PROD-2309 pre-push guard): collectUnresolvedContentReferences() now takes the source model and also checks each Content field's companion column. Previously an unresolved reference living only in a companion field shipped to the target undetected — the field mapper leaves it as the SOURCE id and only warns (not errors), which content-batch-processor.ts doesn't log or block on — reproducing the exact opaque server NullReferenceException PROD-2309 was built to prevent. 2. collect-content-id-references.ts (push-order dependency detection): collectContentIDReferences() now takes the source model too, so an item referenced only through a companion field is recognized as a dependency and promoted to push first (get-content-item-types.ts resolves each item's own source model via modelMapper to supply it). Extracted the companion-field-pair lookup (previously private inside ContentFieldMapper) into a shared src/lib/content/linked-content-companion-fields.ts (getLinkedContentCompanionFields/findFieldKey), used by all three now instead of duplicating the logic a third time. Added regression tests for both utilities (arbitrarily-named companion, SortIDFieldName companion, case-insensitive match, sentinel/no-model back-compat) plus a get-content-item-types push-order test proving an item referenced only via a companion field gets promoted to linked. Full suite: 105 suites / 1924 tests passing. npm run build compiles clean. Co-Authored-By: Claude Sonnet 5 --- src/lib/content/content-field-mapper.ts | 43 +------------ .../linked-content-companion-fields.ts | 43 +++++++++++++ .../content-pusher/content-batch-processor.ts | 6 +- .../util/collect-content-id-references.ts | 25 +++++++- .../util/get-content-item-types.ts | 28 +++++++-- .../util/has-unresolved-content-references.ts | 35 ++++++++++- .../collect-content-id-references.test.ts | 32 ++++++++++ .../util/tests/get-content-item-types.test.ts | 26 ++++++++ .../has-unresolved-content-references.test.ts | 62 +++++++++++++++++++ 9 files changed, 249 insertions(+), 51 deletions(-) create mode 100644 src/lib/content/linked-content-companion-fields.ts diff --git a/src/lib/content/content-field-mapper.ts b/src/lib/content/content-field-mapper.ts index e37d483..16e4624 100644 --- a/src/lib/content/content-field-mapper.ts +++ b/src/lib/content/content-field-mapper.ts @@ -2,6 +2,7 @@ import { AssetReferenceExtractor } from "../assets/asset-reference-extractor"; import * as mgmtApi from "@agility/management-sdk"; import { ContentItemMapper } from "lib/mappers/content-item-mapper"; import { AssetMapper } from "lib/mappers/asset-mapper"; +import { getLinkedContentCompanionFields, findFieldKey } from "./linked-content-companion-fields"; export function createContentFieldMapper() { return new ContentFieldMapper(); @@ -73,12 +74,12 @@ export class ContentFieldMapper { // "linkedContentId", HotAndColdNumbersSection's "LinkedContentValue") don't. The model schema is // the only reliable source for the real name, so remap by reading it from context.model instead // of guessing a suffix. - for (const [mainFieldName, valueFieldName] of this.getContentDropdownValueFieldNames(context)) { + for (const [mainFieldName, valueFieldName] of getLinkedContentCompanionFields(context?.model)) { // Self-pointing setting (e.g. GameBanner's own field name): nothing separate to remap — // the mainFieldName pass above (or the sortids/contentid handling) already covers it. if (valueFieldName.toLowerCase() === mainFieldName.toLowerCase()) continue; - const valueFieldKey = this.findFieldKey(fields, valueFieldName); + const valueFieldKey = findFieldKey(fields, valueFieldName); if (!valueFieldKey) continue; // e.g. a sentinel setting like "CREATENEW" that names no real field const rawValue = fields[valueFieldKey]; @@ -313,44 +314,6 @@ export class ContentFieldMapper { return { mappedValue, warnings, errors }; } - /** - * PROD-2431/PROD-2435: read [mainFieldName, companionFieldName] pairs off the model schema's - * Content-typed fields. `settings.LinkeContentDropdownValueField` (Agility's own spelling) names - * the field that actually carries the raw content ID(s) for a linked-content dropdown. Only - * non-empty settings are returned; the caller still has to handle the value naming a field that - * doesn't exist in the payload (a sentinel like "CREATENEW") or naming itself (no separate - * companion to remap). - */ - private getContentDropdownValueFieldNames(context?: ContentFieldMappingContext): Array<[string, string]> { - const modelFields = context?.model?.fields; - if (!Array.isArray(modelFields)) return []; - - const pairs: Array<[string, string]> = []; - for (const field of modelFields) { - // PROD-2442: a "grid" (full-list) Linked Content field's companion selection column is named - // by SortIDFieldName instead of LinkeContentDropdownValueField — the same per-field, no-fixed- - // convention naming problem PROD-2431/2435 already solved for dropdown/checkbox, just under a - // different setting. Fall back to it so that companion also gets remapped. - const valueFieldName: string | undefined = - field?.settings?.LinkeContentDropdownValueField || field?.settings?.SortIDFieldName; - if (field?.name && valueFieldName) { - pairs.push([field.name, valueFieldName]); - } - } - return pairs; - } - - /** - * Look up a field by name in a fields object, case-insensitively — the model schema's field - * name and the payload's actual field key can differ in casing (e.g. schema "LinkedContentValue" - * vs. a payload key camelCased to "linkedContentValue"). - */ - private findFieldKey(fields: any, fieldName: string): string | undefined { - if (fieldName in fields) return fieldName; - const lowerTarget = fieldName.toLowerCase(); - return Object.keys(fields).find((key) => key.toLowerCase() === lowerTarget); - } - /** * PROD-2431: shared remap for a comma-separated list of SOURCE content IDs, used both for a * content-reference field's "sortids" and for a linked-content dropdown's companion selection diff --git a/src/lib/content/linked-content-companion-fields.ts b/src/lib/content/linked-content-companion-fields.ts new file mode 100644 index 0000000..8a4e693 --- /dev/null +++ b/src/lib/content/linked-content-companion-fields.ts @@ -0,0 +1,43 @@ +import * as mgmtApi from "@agility/management-sdk"; + +/** + * A Content-typed dropdown/checkbox/searchlistbox field's actual selection, and a full-list + * "grid" field's custom sort order, live in a companion field separate from the one the designer + * sees — named by that field's own model setting (`LinkeContentDropdownValueField` or + * `SortIDFieldName` respectively). Neither has a fixed naming convention (PROD-2431/2435/2442 + * sampled a real target schema: only 4 of 14 dropdown fields used the `_ValueField` + * suffix), so the model schema is the only reliable source for the real name. + * + * Shared by ContentFieldMapper (which remaps the companion's value) and the content-pusher + * utilities that scan a content item's fields for references (which need to recognize the + * companion as carrying a reference at all) — every consumer of this pattern should read it from + * here rather than re-deriving its own naming heuristic. + */ +export function getLinkedContentCompanionFields( + model?: mgmtApi.Model | { fields?: mgmtApi.ModelField[] | any[] } | null +): Array<[mainFieldName: string, companionFieldName: string]> { + const modelFields = model?.fields; + if (!Array.isArray(modelFields)) return []; + + const pairs: Array<[string, string]> = []; + for (const field of modelFields) { + const companionFieldName: string | undefined = + field?.settings?.LinkeContentDropdownValueField || field?.settings?.SortIDFieldName; + if (field?.name && companionFieldName) { + pairs.push([field.name, companionFieldName]); + } + } + return pairs; +} + +/** + * Look up a field by name in a fields object, case-insensitively — the model schema's field name + * and the payload's actual field key can differ in casing (e.g. schema "LinkedContentValue" vs. a + * payload key camelCased to "linkedContentValue"). + */ +export function findFieldKey(fields: any, fieldName: string): string | undefined { + if (!fields || typeof fields !== "object") return undefined; + if (fieldName in fields) return fieldName; + const lowerTarget = fieldName.toLowerCase(); + return Object.keys(fields).find((key) => key.toLowerCase() === lowerTarget); +} diff --git a/src/lib/pushers/content-pusher/content-batch-processor.ts b/src/lib/pushers/content-pusher/content-batch-processor.ts index 8ecd5a3..ecdbf36 100644 --- a/src/lib/pushers/content-pusher/content-batch-processor.ts +++ b/src/lib/pushers/content-pusher/content-batch-processor.ts @@ -415,7 +415,11 @@ export class ContentBatchProcessor { // precise, actionable reason instead of shipping a payload that is guaranteed to fail. const unresolvedRefs = collectUnresolvedContentReferences( contentItem.fields || {}, - this.config.referenceMapper + this.config.referenceMapper, + "", + // PROD-2446: sourceModel carries each field's LinkeContentDropdownValueField/SortIDFieldName + // setting, needed to recognize an unresolved reference living only in a companion field. + sourceModel ); if (unresolvedRefs.length > 0) { const detail = unresolvedRefs diff --git a/src/lib/pushers/content-pusher/util/collect-content-id-references.ts b/src/lib/pushers/content-pusher/util/collect-content-id-references.ts index d553228..424d82c 100644 --- a/src/lib/pushers/content-pusher/util/collect-content-id-references.ts +++ b/src/lib/pushers/content-pusher/util/collect-content-id-references.ts @@ -1,3 +1,5 @@ +import { getLinkedContentCompanionFields, findFieldKey } from "lib/content/linked-content-companion-fields"; + /** * Recursively walks content item fields to find SINGLE-ITEM content references — * `contentid`/`contentID` values and comma-separated `sortids` — and returns the @@ -12,8 +14,14 @@ * Only positive IDs are returned; 0 / -1 mean "no reference selected" and are ignored so * we don't promote items with intentionally-empty linked-content fields. This matches the * `> 0` guard used by collectUnresolvedContentReferences. + * + * PROD-2446: a Content-typed dropdown/checkbox/grid field's reference can live ONLY in a + * companion field (named by the model's LinkeContentDropdownValueField/SortIDFieldName setting — + * see PROD-2431/2435/2442), invisible to the structural walk above since it's a bare string under + * an arbitrary key. Pass `model` so an item that depends on another item solely through such a + * companion field is still recognized as a dependency and promoted to push first. */ -export function collectContentIDReferences(fields: any): number[] { +export function collectContentIDReferences(fields: any, model?: any): number[] { const found: number[] = []; function walk(node: any): void { @@ -50,5 +58,20 @@ export function collectContentIDReferences(fields: any): number[] { } walk(fields); + + // Companion fields are top-level siblings of the main field, not nested inside it. + for (const [, companionFieldName] of getLinkedContentCompanionFields(model)) { + const companionKey = findFieldKey(fields, companionFieldName); + if (!companionKey) continue; + + const companionValue = fields[companionKey]; + if (typeof companionValue !== "string" || !companionValue.trim()) continue; + + for (const part of companionValue.split(",")) { + const id = parseInt(part.trim()); + if (!isNaN(id) && id > 0) found.push(id); + } + } + return found; } diff --git a/src/lib/pushers/content-pusher/util/get-content-item-types.ts b/src/lib/pushers/content-pusher/util/get-content-item-types.ts index b211225..37fc1a8 100644 --- a/src/lib/pushers/content-pusher/util/get-content-item-types.ts +++ b/src/lib/pushers/content-pusher/util/get-content-item-types.ts @@ -53,7 +53,7 @@ export function getContentItemTypes( // Find every item this one depends on — whole-list references (by referenceName) // AND single-item references (by contentID) — and mark them linked (pushed first). - const referencedIds = collectReferencedContentIDs(item, itemsByReferenceName, allItemsById); + const referencedIds = collectReferencedContentIDs(item, itemsByReferenceName, allItemsById, modelMapper); if (referencedIds.length > 0) { markReferencedItems( referencedIds, @@ -105,15 +105,18 @@ function buildItemMaps(contentItems: ContentItem[]): { * both reference kinds: * - whole-list references (referencename + fulllist:true) → every item sharing that * referenceName (the full list), looked up via itemsByReferenceName; - * - single-item references (contentid / sortids) → the specific referenced item, only - * when it is present in the current content set (allItemsById). + * - single-item references (contentid / sortids, INCLUDING one living only in a + * LinkeContentDropdownValueField/SortIDFieldName companion field — PROD-2446) → the + * specific referenced item, only when it is present in the current content set + * (allItemsById). * * Returns the referenced items' contentIDs; the referencing item itself is never included. */ function collectReferencedContentIDs( item: ContentItem, itemsByReferenceName: Map, - allItemsById: Map + allItemsById: Map, + modelMapper: ModelMapper ): number[] { const ids: number[] = []; @@ -125,7 +128,8 @@ function collectReferencedContentIDs( } // Single-item references → the specific referenced item, if it is in this push set - for (const contentID of collectContentIDReferences(item.fields || {})) { + const sourceModel = getSourceModel(item, modelMapper); + for (const contentID of collectContentIDReferences(item.fields || {}, sourceModel)) { if (allItemsById.has(contentID)) { ids.push(contentID); } @@ -134,6 +138,18 @@ function collectReferencedContentIDs( return ids; } +/** + * Resolves an item's own source model (by its definitionName), so a Content-typed field's + * companion-field name (LinkeContentDropdownValueField/SortIDFieldName) can be read off it. + * Returns null if unmapped — collectContentIDReferences degrades gracefully to the structural-only + * walk in that case, same as before PROD-2446. + */ +function getSourceModel(item: ContentItem, modelMapper: ModelMapper): any { + if (!item.properties?.definitionName) return null; + const modelMapping = modelMapper.getModelMappingByReferenceName(item.properties.definitionName.toLowerCase(), "source"); + return modelMapping ? modelMapper.getMappedEntity(modelMapping, "source") : null; +} + /** * Recursively marks all items referenced (transitively) by the given contentIDs as linked, * so they are pushed before the items that reference them. Uses a stack-based approach with @@ -170,7 +186,7 @@ function markReferencedItems( normalSet.delete(contentID); // Remove from normal if it was added there // Recursively process this item's own dependency targets - for (const nestedId of collectReferencedContentIDs(item, itemsByReferenceName, allItemsById)) { + for (const nestedId of collectReferencedContentIDs(item, itemsByReferenceName, allItemsById, modelMapper)) { if (!visited.has(nestedId)) { stack.push(nestedId); } diff --git a/src/lib/pushers/content-pusher/util/has-unresolved-content-references.ts b/src/lib/pushers/content-pusher/util/has-unresolved-content-references.ts index cebb906..4c98ccd 100644 --- a/src/lib/pushers/content-pusher/util/has-unresolved-content-references.ts +++ b/src/lib/pushers/content-pusher/util/has-unresolved-content-references.ts @@ -1,4 +1,5 @@ import { ContentItemMapper } from "lib/mappers/content-item-mapper"; +import { getLinkedContentCompanionFields, findFieldKey } from "lib/content/linked-content-companion-fields"; /** * Recursively check for unresolved content references @@ -59,11 +60,19 @@ export interface UnresolvedContentReference { * whole structure so callers can report exactly which field/reference is unmapped. Only * positive IDs are considered — 0 / -1 mean "no reference selected" and are ignored so we * don't over-skip items with intentionally empty linked-content fields. + * + * PROD-2446: a Content-typed dropdown/checkbox/grid field's reference can live ONLY in a + * companion field (named by the model's LinkeContentDropdownValueField/SortIDFieldName setting — + * see PROD-2431/2435/2442), invisible to the structural contentid/sortids walk above since it's a + * bare string under an arbitrary key. Pass `model` so this guard can recognize those too — without + * it, an unresolved companion-field reference ships to the target undetected, reproducing the same + * server-side NullReferenceException this guard exists to prevent (PROD-2309). */ export function collectUnresolvedContentReferences( obj: any, referenceMapper: ContentItemMapper, - path = "" + path = "", + model?: any ): UnresolvedContentReference[] { const results: UnresolvedContentReference[] = []; if (typeof obj !== "object" || obj === null) { @@ -72,7 +81,7 @@ export function collectUnresolvedContentReferences( if (Array.isArray(obj)) { obj.forEach((item, index) => { - results.push(...collectUnresolvedContentReferences(item, referenceMapper, `${path}[${index}]`)); + results.push(...collectUnresolvedContentReferences(item, referenceMapper, `${path}[${index}]`, model)); }); return results; } @@ -100,7 +109,27 @@ export function collectUnresolvedContentReferences( } // Recurse into nested objects/arrays - results.push(...collectUnresolvedContentReferences(value, referenceMapper, childPath)); + results.push(...collectUnresolvedContentReferences(value, referenceMapper, childPath, model)); + } + + // PROD-2446: companion fields are top-level siblings of the main field, not nested inside it — + // only check them at the top of the walk (path === ""), not on every recursive call, so a + // companion field's own value never gets misread as if it belonged to some nested object. + if (path === "") { + for (const [, companionFieldName] of getLinkedContentCompanionFields(model)) { + const companionKey = findFieldKey(obj, companionFieldName); + if (!companionKey) continue; + + const companionValue = obj[companionKey]; + if (typeof companionValue !== "string" || !companionValue.trim()) continue; + + for (const idStr of companionValue.split(",")) { + const contentId = parseInt(idStr.trim()); + if (!isNaN(contentId) && contentId > 0 && !referenceMapper.getContentItemMappingByContentID(contentId, "source")) { + results.push({ path: companionKey, contentID: contentId }); + } + } + } } return results; diff --git a/src/lib/pushers/content-pusher/util/tests/collect-content-id-references.test.ts b/src/lib/pushers/content-pusher/util/tests/collect-content-id-references.test.ts index b75705a..72ff05d 100644 --- a/src/lib/pushers/content-pusher/util/tests/collect-content-id-references.test.ts +++ b/src/lib/pushers/content-pusher/util/tests/collect-content-id-references.test.ts @@ -50,4 +50,36 @@ describe("collectContentIDReferences", () => { // Whole-list refs are handled by collectListReferenceNames, not here. expect(collectContentIDReferences({ items: { referencename: "somelist", fulllist: true } })).toEqual([]); }); + + // PROD-2446: a reference that lives ONLY in a LinkeContentDropdownValueField/SortIDFieldName + // companion field (PROD-2431/2435/2442) is a bare string under an arbitrary key — invisible to + // the structural walk above without the model to name it. + describe("companion-field references (schema-driven, PROD-2446)", () => { + it("collects an id living only in an arbitrarily-named companion field", () => { + const model = { + fields: [{ name: "linkedDrawGameAsset", settings: { LinkeContentDropdownValueField: "linkedContentId" } }], + }; + const fields = { linkedDrawGameAsset: "euro-jackpot", linkedContentId: "11875" }; + expect(collectContentIDReferences(fields, model)).toEqual([11875]); + }); + + it("collects comma-separated ids from a grid field's SortIDFieldName companion", () => { + const model = { + fields: [{ name: "sharedGridSorted", settings: { SortIDFieldName: "sharedGridSorted_SortField" } }], + }; + const fields = { sharedGridSorted: { referencename: "list", fulllist: true }, sharedGridSorted_SortField: "3,7,11" }; + expect(collectContentIDReferences(fields, model).sort((a, b) => a - b)).toEqual([3, 7, 11]); + }); + + it("is a no-op when no model is supplied (back-compat)", () => { + const fields = { linkedDrawGameAsset: "euro-jackpot", linkedContentId: "11875" }; + expect(collectContentIDReferences(fields)).toEqual([]); + }); + + it("tolerates a sentinel setting that names no real field", () => { + const model = { fields: [{ name: "posts", settings: { LinkeContentDropdownValueField: "CREATENEW" } }] }; + const fields = { posts: { referencename: "posts-list", fulllist: true } }; + expect(collectContentIDReferences(fields, model)).toEqual([]); + }); + }); }); diff --git a/src/lib/pushers/content-pusher/util/tests/get-content-item-types.test.ts b/src/lib/pushers/content-pusher/util/tests/get-content-item-types.test.ts index 3d32b01..7bd2c80 100644 --- a/src/lib/pushers/content-pusher/util/tests/get-content-item-types.test.ts +++ b/src/lib/pushers/content-pusher/util/tests/get-content-item-types.test.ts @@ -300,6 +300,32 @@ describe("getContentItemTypes — single-item content references", () => { expect(linkedIds).toContain(mid.contentID); expect(linkedIds).toContain(deep.contentID); }); + + // PROD-2446: a reference living only in a LinkeContentDropdownValueField/SortIDFieldName + // companion field (PROD-2431/2435/2442) is invisible to the structural contentid/sortids walk — + // without the referencing item's own model, the referenced item was never promoted to push-first. + it("promotes an item referenced only through a companion field (PROD-2446)", () => { + const target = makeItem("drawgamesassets", "DrawGameAssetsSchema"); + const playslip = makeItem("playslipsection", "PlaylipSection", { + // main field holds the container reference name, per the LinkedContentDropdown contract — + // the actual selected id lives only in the companion field + linkedDrawGameAsset: "drawgamesassets", + linkedContentId: `${target.contentID}`, + }); + + const opts = makeValidOpts(); + opts.modelMapper.getMappedEntity = jest.fn().mockReturnValue({ + id: 10, + fields: [{ name: "linkedDrawGameAsset", settings: { LinkeContentDropdownValueField: "linkedContentId" } }], + }); + + const result = getContentItemTypes([playslip, target], opts); + + expect(result.linkedContentItems).toHaveLength(1); + expect(result.linkedContentItems[0]).toBe(target); + expect(result.normalContentItems).toHaveLength(1); + expect(result.normalContentItems[0]).toBe(playslip); + }); }); // ─── reference to unknown item ──────────────────────────────────────────────── diff --git a/src/lib/pushers/content-pusher/util/tests/has-unresolved-content-references.test.ts b/src/lib/pushers/content-pusher/util/tests/has-unresolved-content-references.test.ts index d077ccd..033edc8 100644 --- a/src/lib/pushers/content-pusher/util/tests/has-unresolved-content-references.test.ts +++ b/src/lib/pushers/content-pusher/util/tests/has-unresolved-content-references.test.ts @@ -222,3 +222,65 @@ describe("collectUnresolvedContentReferences", () => { expect(result).toEqual([]); }); }); + +// ─── companion-field references (schema-driven, PROD-2446) ──────────────────── + +describe("collectUnresolvedContentReferences — companion fields (PROD-2446)", () => { + it("flags an unresolved id living only in an arbitrarily-named companion field", () => { + const model = { + fields: [{ name: "linkedDrawGameAsset", settings: { LinkeContentDropdownValueField: "linkedContentId" } }], + }; + const fields = { linkedDrawGameAsset: "euro-jackpot", linkedContentId: "11875" }; + const result = collectUnresolvedContentReferences(fields, makeMapper(false), "", model); + expect(result).toEqual([{ path: "linkedContentId", contentID: 11875 }]); + }); + + it("does not flag a companion field whose id is resolved", () => { + const model = { + fields: [{ name: "linkedDrawGameAsset", settings: { LinkeContentDropdownValueField: "linkedContentId" } }], + }; + const fields = { linkedDrawGameAsset: "euro-jackpot", linkedContentId: "11875" }; + const result = collectUnresolvedContentReferences(fields, makeMapper(true), "", model); + expect(result).toEqual([]); + }); + + it("flags every unresolved id in a grid field's SortIDFieldName companion", () => { + const model = { + fields: [{ name: "sharedGridSorted", settings: { SortIDFieldName: "sharedGridSorted_SortField" } }], + }; + const mapper = makePartialMapper([13086]); + // Main field itself carries no "sortids" key here, isolating the companion-only path — + // the combined case (both keys populated) is already covered by content-field-mapper.test.ts. + const fields = { + sharedGridSorted: { referencename: "list", fulllist: true }, + sharedGridSorted_SortField: "13086,13087,13088", + }; + const result = collectUnresolvedContentReferences(fields, mapper, "", model); + expect(result).toEqual([ + { path: "sharedGridSorted_SortField", contentID: 13087 }, + { path: "sharedGridSorted_SortField", contentID: 13088 }, + ]); + }); + + it("is a no-op when no model is supplied (back-compat)", () => { + const fields = { linkedDrawGameAsset: "euro-jackpot", linkedContentId: "11875" }; + expect(collectUnresolvedContentReferences(fields, makeMapper(false))).toEqual([]); + }); + + it("only checks companion fields at the top level, not inside nested recursion", () => { + // A field literally named "linkedContentId" nested somewhere else in the payload should not + // be double-counted as the companion for a different, unrelated model field pass. + const model = { + fields: [{ name: "linkedDrawGameAsset", settings: { LinkeContentDropdownValueField: "linkedContentId" } }], + }; + const fields = { + linkedDrawGameAsset: "euro-jackpot", + linkedContentId: "11875", + nested: { linkedContentId: "99999" }, + }; + const result = collectUnresolvedContentReferences(fields, makeMapper(false), "", model); + // top-level companion (11875) flagged once; the nested "linkedContentId" is a plain string + // field with no contentid/contentID/sortids key, so the structural walk doesn't touch it either + expect(result).toEqual([{ path: "linkedContentId", contentID: 11875 }]); + }); +});