Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions packages/universal/api-schemas/src/contentful/CtflEntry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import * as z from 'zod/mini'
* Base Zod schema for entry fields.
*
* @remarks
* This is modeled as a catch-all map from string keys to JSON-compatible values.
* The strong typing ot consumer-specified Contentful Entry fields is not
* validated by these schemas.
* This is modeled as a catch-all map from string keys to pass-through values.
* The strong typing of consumer-specified Contentful Entry fields is not
* validated by these schemas. Arbitrary field values are intentionally treated
* as pass-through data so resolved Contentful entry graphs can contain circular
* references without recursive schema traversal.
*
* @public
*/
export const EntryFields = z.catchall(z.object({}), z.json())
export const EntryFields = z.catchall(z.object({}), z.any())

/**
* TypeScript type inferred from {@link EntryFields}.
Expand Down Expand Up @@ -175,8 +177,8 @@ export type EntrySys = z.infer<typeof EntrySys>
* Zod schema describing a generic Contentful entry.
*
* @remarks
* This model is intentionally loose: `fields` is any JSON-compliant object and
* `metadata` is modeled as a catch-all object that must contain an array of
* This model is intentionally loose: arbitrary `fields` values are pass-through
* data and `metadata` is modeled as an object that must contain an array of
* {@link TagLink} tags.
*
* @public
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { normalizeOptimizationConfig, OptimizationEntry, type OptimizationConfig } from '.'
import {
CtflEntry,
normalizeOptimizationConfig,
OptimizationEntry,
OptimizedEntry,
type OptimizationConfig,
} from '.'

const optimizationEntryBase = {
metadata: {
Expand Down Expand Up @@ -43,6 +49,43 @@ const optimizationEntryBase = {
},
}

const entryBase = {
metadata: {
tags: [],
concepts: [],
},
sys: {
type: 'Entry',
contentType: {
sys: {
type: 'Link',
linkType: 'ContentType',
id: 'article',
},
},
publishedVersion: 1,
id: 'entry-id',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
revision: 1,
space: {
sys: {
type: 'Link',
linkType: 'Space',
id: 'space-id',
},
},
environment: {
sys: {
type: 'Link',
linkType: 'Environment',
id: 'master',
},
},
},
fields: {},
}

describe('normalizeOptimizationConfig', () => {
it('returns runtime-safe defaults for nullish configs', () => {
expect(normalizeOptimizationConfig(undefined)).toEqual({
Expand Down Expand Up @@ -75,6 +118,52 @@ describe('normalizeOptimizationConfig', () => {
})
})

describe('CtflEntry', () => {
it('passes through arbitrary consumer fields without recursively validating them', () => {
const parent = {
...entryBase,
fields: {},
}
const child = {
...entryBase,
sys: {
...entryBase.sys,
id: 'child-entry-id',
},
fields: {
richText: {
nodeType: 'document',
data: {},
content: [
{
nodeType: 'embedded-entry-block',
data: { target: parent },
content: [],
},
],
},
},
}
parent.fields = {
relatedEntry: child,
}

expect(CtflEntry.safeParse(parent).success).toBe(true)
})

it('still validates Contentful system metadata', () => {
expect(
CtflEntry.safeParse({
...entryBase,
sys: {
...entryBase.sys,
id: 123,
},
}).success,
).toBe(false)
})
})

describe('OptimizationEntry', () => {
it('does not fabricate nt_config during parsing', () => {
const result = OptimizationEntry.safeParse(optimizationEntryBase)
Expand All @@ -85,4 +174,43 @@ describe('OptimizationEntry', () => {

expect(result.data.fields.nt_config).toBeUndefined()
})

it('still validates optimization-owned config fields', () => {
expect(
OptimizationEntry.safeParse({
...optimizationEntryBase,
fields: {
...optimizationEntryBase.fields,
nt_config: {
components: [
{
type: 'EntryReplacement',
baseline: { id: 123 },
variants: [],
},
],
},
},
}).success,
).toBe(false)
})
})

describe('OptimizedEntry', () => {
it('still requires valid optimization references', () => {
expect(
OptimizedEntry.safeParse({
...entryBase,
fields: {
nt_experiences: [
{
sys: {
id: 'missing-link-shape',
},
},
],
},
}).success,
).toBe(false)
})
})
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// OptimizedEntryResolver.test.ts
import {
isEntry,
isEntryReplacementComponent,
isEntryReplacementVariant,
isOptimizationEntry,
Expand All @@ -11,7 +12,7 @@ import {
type SelectedOptimizationArray,
} from '@contentful/optimization-api-client/api-schemas'
import { describe, expect, it, rs } from '@rstest/core'
import type { Entry } from 'contentful'
import type { Entry, EntrySkeletonType } from 'contentful'

import { mockLogger } from 'mocks'
import { optimizedEntry as optimizedEntryFixture } from '../test/fixtures/optimizedEntry'
Expand All @@ -22,6 +23,8 @@ const mockedLogger = rs.mocked(mockLogger)

const RESOLUTION_WARNING_BASE = 'Could not resolve optimized entry variant:'

type TestEntry = Entry<EntrySkeletonType, undefined>

const getOptimizedEntry = (): OptimizedEntry => {
if (!isOptimizedEntry(optimizedEntryFixture)) {
throw new Error('Fixture optimizedEntry is not an OptimizedEntry')
Expand Down Expand Up @@ -66,6 +69,100 @@ const getEuropeVariantConfig = (): EntryReplacementVariant => {
return maybeVariant
}

const createTestEntry = (id: string, fields: Record<string, unknown> = {}): TestEntry => {
const entry: unknown = {
fields,
metadata: { tags: [] },
sys: {
type: 'Entry',
id,
contentType: {
sys: {
type: 'Link',
linkType: 'ContentType',
id: 'testContentType',
},
},
publishedVersion: 1,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
revision: 1,
space: {
sys: {
type: 'Link',
linkType: 'Space',
id: 'testSpace',
},
},
environment: {
sys: {
type: 'Link',
linkType: 'Environment',
id: 'testEnvironment',
},
},
},
}

if (!isEntry<EntrySkeletonType, undefined>(entry)) {
throw new Error(`Expected test entry ${id} to match the Contentful entry schema`)
}

return entry
}

const createRichTextLinkedEntryDocument = (target: TestEntry): Record<string, unknown> => ({
nodeType: 'document',
data: {},
content: [
{
nodeType: 'embedded-entry-block',
data: { target },
content: [],
},
],
})

const createOptimizationEntry = ({
baselineEntry,
variantEntry,
}: {
baselineEntry: TestEntry
variantEntry: TestEntry
}): TestEntry =>
createTestEntry('experience-entry', {
nt_name: 'Personalized featured post',
nt_type: 'nt_personalization',
nt_experience_id: 'experience-entry',
nt_config: {
components: [
{
type: 'EntryReplacement',
baseline: { id: baselineEntry.sys.id },
variants: [{ id: variantEntry.sys.id }],
},
],
},
nt_variants: [variantEntry],
})

const createSelectedOptimizations = ({
baselineEntry,
variantEntry,
}: {
baselineEntry: TestEntry
variantEntry: TestEntry
}): SelectedOptimizationArray => [
{
experienceId: 'experience-entry',
variantIndex: 1,
variants: {
[baselineEntry.sys.id]: variantEntry.sys.id,
},
sticky: false,
},
]

describe('OptimizedEntryResolver', () => {
describe('getOptimizationEntry', () => {
it('returns the matching optimization entry for a selected experience', () => {
Expand Down Expand Up @@ -433,6 +530,61 @@ describe('OptimizedEntryResolver', () => {
)
})

it('resolves the selected variant when an unrelated rich-text linked entry graph contains a cycle', () => {
const baselineFields: Record<string, unknown> = {}
const baselineEntry = createTestEntry('baseline-entry', baselineFields)
const variantEntry = createTestEntry('variant-entry', {
internalTitle: 'Selected variant',
})
const linkedEntry = createTestEntry('linked-entry', {
text: createRichTextLinkedEntryDocument(baselineEntry),
})
const optimizationEntry = createOptimizationEntry({ baselineEntry, variantEntry })
baselineFields.nt_experiences = [optimizationEntry]
baselineFields.featuredPosts = [linkedEntry]

const result = OptimizedEntryResolver.resolve(
baselineEntry,
createSelectedOptimizations({ baselineEntry, variantEntry }),
)

expect(result.entry).toBe(variantEntry)
expect(result.selectedOptimization).toEqual(
expect.objectContaining({
experienceId: 'experience-entry',
variantIndex: 1,
}),
)
})

it('resolves the selected variant when the variant entry contains a rich-text linked entry cycle', () => {
const baselineFields: Record<string, unknown> = {}
const variantFields: Record<string, unknown> = {
internalTitle: 'Selected variant',
}
const baselineEntry = createTestEntry('baseline-entry', baselineFields)
const variantEntry = createTestEntry('variant-entry', variantFields)
const linkedEntry = createTestEntry('variant-linked-entry', {
text: createRichTextLinkedEntryDocument(variantEntry),
})
variantFields.featuredPosts = [linkedEntry]
const optimizationEntry = createOptimizationEntry({ baselineEntry, variantEntry })
baselineFields.nt_experiences = [optimizationEntry]

const result = OptimizedEntryResolver.resolve(
baselineEntry,
createSelectedOptimizations({ baselineEntry, variantEntry }),
)

expect(result.entry).toBe(variantEntry)
expect(result.selectedOptimization).toEqual(
expect.objectContaining({
experienceId: 'experience-entry',
variantIndex: 1,
}),
)
})

it('returns resolved data and optimization context for a matched optimization', () => {
const { optimizationContext, resolvedData } = OptimizedEntryResolver.resolveWithContext(
optimizedEntryFixture,
Expand Down