From 2d45b3f9ec464aa2a6466a821c1ea0bbd46313e8 Mon Sep 17 00:00:00 2001 From: chetan-contentstack Date: Thu, 30 Jul 2026 17:00:00 +0530 Subject: [PATCH 1/2] fix(delta): resolve entry references correctly and fix premature locale-migrated tracking Reference fields (Link/Array Entry) written during a locale-localize restart kept the source CMS entry id instead of the real Contentstack uid, since only the master-locale bulk import resolved references correctly. Threads entry uid-mapper data into the update config so entry-update-script.cjs can resolve them, mirroring the existing asset uid resolution. Also fixes a bug where finishing any locale marked ALL configured locales as migrated, causing not-yet-migrated locales to be silently skipped on later delta restarts. Now only locales actually processed in that run are recorded. --- api/src/services/migration.service.ts | 8 +- api/src/services/runCli.service.ts | 61 +++++++-- api/src/utils/entry-update-script.cjs | 87 +++++++++++- api/src/utils/entry-update.utils.ts | 58 ++++++++ api/src/utils/locale-migration.utils.ts | 32 +++++ .../unit/utils/entry-update-script.test.ts | 129 +++++++++++++++++- .../unit/utils/entry-update.enrich.test.ts | 76 +++++++++++ .../unit/utils/locale-migration.utils.test.ts | 57 ++++++++ 8 files changed, 491 insertions(+), 17 deletions(-) diff --git a/api/src/services/migration.service.ts b/api/src/services/migration.service.ts index c7821a8b..4e1628a5 100644 --- a/api/src/services/migration.service.ts +++ b/api/src/services/migration.service.ts @@ -50,7 +50,7 @@ import { import { aemService } from './aem.service.js'; import { requestWithSsoTokenRefresh } from '../utils/sso-request.utils.js'; import { utilsUpdateCli } from './updateEntryCli.service.js'; -import { clearStaleEntries, enrichConfigWithAssetMapping, enrichConfigWithAssetUpdates, ensureUpdateConfigFile, removeEntriesFromDatabase } from '../utils/entry-update.utils.js'; +import { clearStaleEntries, enrichConfigWithAssetMapping, enrichConfigWithEntryMapping, enrichConfigWithAssetUpdates, ensureUpdateConfigFile, removeEntriesFromDatabase } from '../utils/entry-update.utils.js'; import { removeExistingAssets, saveAssetMetadata, AssetUpdate } from '../utils/asset-update.utils.js'; /** @@ -1262,6 +1262,12 @@ const startMigration = async (req: Request): Promise => { iteration, safeDeltaMigrationLogPath ); + enrichConfigWithEntryMapping( + configFilePath, + safePid, + iteration, + safeDeltaMigrationLogPath + ); enrichConfigWithAssetUpdates( configFilePath, assetUpdates, diff --git a/api/src/services/runCli.service.ts b/api/src/services/runCli.service.ts index 8f90b01a..e541d360 100644 --- a/api/src/services/runCli.service.ts +++ b/api/src/services/runCli.service.ts @@ -20,6 +20,7 @@ interface TestStack { } import { setBasicAuthConfig, setOAuthConfig } from '../utils/config-handler.util.js'; import writeUidMapping, { writePerLocaleEntryUidMapping } from '../utils/uid-mapper.utils.js'; +import { extractLocalesFromUpdateConfig, recordMigratedLocales } from '../utils/locale-migration.utils.js'; /** * Determines log level based on message content without removing ANSI codes @@ -322,20 +323,54 @@ export const runCli = async ( ProjectModelLowdb.data.projects[projectIndex].current_step = getStepperSteps(ProjectModelLowdb.data.projects[projectIndex]?.iteration).MIGRATION; ProjectModelLowdb.data.projects[projectIndex].status = 5; - // Record every locale that just successfully migrated so the next delta restart can - // tell which locales need a full pass vs delta. Set-union with prior value. - const proj: any = ProjectModelLowdb.data.projects[projectIndex]; - const ranLocales = Array.from( - new Set([ - ...Object.keys(proj?.master_locale ?? {}), - ...Object.keys(proj?.locales ?? {}), - ]), - ); - const existing: string[] = Array.isArray(proj?.migrated_locales) - ? proj.migrated_locales - : []; - proj.migrated_locales = Array.from(new Set([...existing, ...ranLocales])); await ProjectModelLowdb.write(); + + // Record every locale that was ACTUALLY processed this run so the next delta + // restart can tell which locales still need a full pass vs delta. + // + // On iteration 1 there's no delta/localize step at all — the whole configured + // locale set genuinely gets migrated in one shot, so using the full config is + // correct here. From iteration 2 onward, a locale only "ran" this iteration if + // it's the master locale (always present) or its entries were actually queued + // in this iteration's updated-entries.json (written by removeEntriesFromDatabase + // before this CLI import step even started). Using the FULL project locale + // config here — instead of what this run actually touched — used to mark + // not-yet-migrated locales as done prematurely, permanently skipping them on + // every later restart (see CMG delta-migration locale bug). + const proj: any = ProjectModelLowdb.data.projects[projectIndex]; + const currentIteration = proj?.iteration || 1; + let ranLocales: string[]; + if (currentIteration <= 1) { + ranLocales = Array.from( + new Set([ + ...Object.keys(proj?.master_locale ?? {}), + ...Object.keys(proj?.locales ?? {}), + ]), + ); + } else { + const updatedEntriesPath = path.join( + process.cwd(), + DATABASE_FILES.DIRECTORY, + projectId, + currentIteration.toString(), + DATABASE_FILES.UPDATED_ENTRIES, + ); + let updateConfig: Record | null = null; + if (fs.existsSync(updatedEntriesPath)) { + try { + updateConfig = JSON.parse(fs.readFileSync(updatedEntriesPath, 'utf-8')); + } catch { + updateConfig = null; + } + } + ranLocales = Array.from( + new Set([ + ...Object.keys(proj?.master_locale ?? {}), + ...extractLocalesFromUpdateConfig(updateConfig), + ]), + ); + } + await recordMigratedLocales(projectId, ranLocales); } } else { console.info('User not found.'); diff --git a/api/src/utils/entry-update-script.cjs b/api/src/utils/entry-update-script.cjs index a14d0bdb..96419bc5 100644 --- a/api/src/utils/entry-update-script.cjs +++ b/api/src/utils/entry-update-script.cjs @@ -4,6 +4,63 @@ const isAssetField = (value) => value && typeof value === 'object' && !Array.isArray(value) && 'urlPath' in value && 'filename' in value; +/** Shape produced by processField's 'reference' case: { uid, _content_type_uid }. */ +const isReferenceValue = (value) => + value && typeof value === 'object' && !Array.isArray(value) && + 'uid' in value && '_content_type_uid' in value; + +const isReferenceArray = (value) => + Array.isArray(value) && value.length > 0 && value.every(isReferenceValue); + +/** + * Resolves a source-side entry uid to its real Contentstack destination uid. + * + * The export JSON's reference fields carry the SOURCE cms entry id (see + * `contentful.service.ts`'s `createRefrence`), which only happens to equal the + * Contentstack uid when entries are imported preserving source ids. The + * bulk/master-locale import resolves this correctly via the CLI's own + * reference pass; this update path does not, so it needs the same uid-mapper + * data the asset resolution above already uses (see `entryMapping`). + * + * Preference order: per-locale mapping (most precise — handles entries that + * ended up as distinct Contentstack uids per locale across iterations) → + * flat mapping → identity fallback (keeps existing behavior when no mapping + * data exists, e.g. simple setups where source id equals destination uid). + */ +const resolveReferenceUid = (sourceUid, locale, entryMapping) => { + if (!sourceUid) return sourceUid; + const newByLocale = entryMapping?.new?.byLocale?.[locale]?.[sourceUid]; + if (newByLocale) return newByLocale; + const oldByLocale = entryMapping?.old?.byLocale?.[locale]?.[sourceUid]; + if (oldByLocale) return oldByLocale; + const newFlat = entryMapping?.new?.flat?.[sourceUid]; + if (newFlat) return newFlat; + const oldFlat = entryMapping?.old?.flat?.[sourceUid]; + if (oldFlat) return oldFlat; + return sourceUid; +}; + +/** + * Remaps the uid(s) inside a reference field value (single link object or + * array of link objects) to their Contentstack destination uids. + */ +const resolveReferenceField = (fieldName, entryUid, value, locale, entryMapping) => { + if (isReferenceValue(value)) { + const resolved = resolveReferenceUid(value.uid, locale, entryMapping); + if (resolved !== value.uid) { + console.info(`[${entryUid}] "${fieldName}"${locale ? ` (${locale})` : ''}: resolved reference uid "${value.uid}" → "${resolved}"`); + } + return { ...value, uid: resolved }; + } + if (isReferenceArray(value)) { + return value.map((item) => { + const resolved = resolveReferenceUid(item.uid, locale, entryMapping); + return { ...item, uid: resolved }; + }); + } + return value; +}; + /** Export JSON metadata — not Contentstack content-type field UIDs (WordPress entries are flat). */ const FLAT_PAYLOAD_SKIP = new Set([ 'uid', @@ -68,7 +125,7 @@ const resolveAssetField = (fieldName, entryUid, updateValue, stackValue, oldMapp * WordPress (and similar) write migration JSON with fields at the root (email, url, …). * Fetched stack entries keep custom fields under entry.content — merge flat updateData there. */ -const mergeFlatPayloadIntoEntry = async (entry, entryUid, updateData, oldMapping, newMapping, updateOpts) => { +const mergeFlatPayloadIntoEntry = async (entry, entryUid, updateData, oldMapping, newMapping, updateOpts, locale, entryMapping) => { for (const field of Object.keys(updateData)) { if (FLAT_PAYLOAD_SKIP.has(field)) { continue; @@ -89,6 +146,8 @@ const mergeFlatPayloadIntoEntry = async (entry, entryUid, updateData, oldMapping oldMapping, newMapping ); + } else if (isReferenceValue(nextVal) || isReferenceArray(nextVal)) { + nextVal = resolveReferenceField(field, entryUid, nextVal, locale, entryMapping); } entry.content[field] = nextVal; } @@ -103,6 +162,9 @@ module.exports = async ({ const assetMapping = config.__assetMapping__ || { old: {}, new: {} }; delete config.__assetMapping__; + const entryMapping = config.__entryMapping__ || { old: { flat: {}, byLocale: {} }, new: { flat: {}, byLocale: {} } }; + delete config.__entryMapping__; + // Assets the user chose to update in place (same UID, new file). const assetUpdates = Array.isArray(config.__assetUpdates__) ? config.__assetUpdates__ : []; delete config.__assetUpdates__; @@ -110,6 +172,7 @@ module.exports = async ({ const oldMapping = assetMapping.old || {}; const newMapping = assetMapping.new || {}; console.info(`Asset mappings loaded — old: ${Object.keys(oldMapping).length}, new: ${Object.keys(newMapping).length}`); + console.info(`Entry mappings loaded — old: ${Object.keys(entryMapping?.old?.flat || {}).length} flat / ${Object.keys(entryMapping?.old?.byLocale || {}).length} locales, new: ${Object.keys(entryMapping?.new?.flat || {}).length} flat / ${Object.keys(entryMapping?.new?.byLocale || {}).length} locales`); console.info(`Asset updates to replace in place: ${assetUpdates.length}`); const contentTypes = Object.keys(config); @@ -187,13 +250,21 @@ module.exports = async ({ oldMapping, newMapping ); + } else if (isReferenceValue(updateData?.content[field]) || isReferenceArray(updateData?.content[field])) { + updateData.content[field] = resolveReferenceField( + field, + entryUid, + updateData?.content[field], + locale, + entryMapping + ); } } Object.assign(entry?.content, updateData?.content); await entry.update(updateOpts); } else if (hasStackContent) { console.info(`[${realEntryUid}] Merging flat migration payload into entry.content (e.g. WordPress export)${locale ? ` for locale "${locale}"` : ''}`); - await mergeFlatPayloadIntoEntry(entry, realEntryUid, updateData, oldMapping, newMapping, updateOpts); + await mergeFlatPayloadIntoEntry(entry, realEntryUid, updateData, oldMapping, newMapping, updateOpts, locale, entryMapping); } else { if (updateData && entry) { for (const field of Object.keys(updateData)) { @@ -206,6 +277,14 @@ module.exports = async ({ oldMapping, newMapping ); + } else if (isReferenceValue(updateData[field]) || isReferenceArray(updateData[field])) { + updateData[field] = resolveReferenceField( + field, + entryUid, + updateData[field], + locale, + entryMapping + ); } } } @@ -237,3 +316,7 @@ module.exports = async ({ module.exports.isAssetField = isAssetField; module.exports.resolveAssetField = resolveAssetField; module.exports.mergeFlatPayloadIntoEntry = mergeFlatPayloadIntoEntry; +module.exports.isReferenceValue = isReferenceValue; +module.exports.isReferenceArray = isReferenceArray; +module.exports.resolveReferenceUid = resolveReferenceUid; +module.exports.resolveReferenceField = resolveReferenceField; diff --git a/api/src/utils/entry-update.utils.ts b/api/src/utils/entry-update.utils.ts index 79cf3a64..9e07e955 100644 --- a/api/src/utils/entry-update.utils.ts +++ b/api/src/utils/entry-update.utils.ts @@ -297,6 +297,64 @@ export const enrichConfigWithAssetMapping = ( writeLogEntry(`Asset references will be resolved using combined old and new mappings`, "enrichConfigWithAssetMapping", loggerPath); }; +/** + * Reads old (previous iteration) and new (current iteration) entry uid mappings + * — both the flat source→dest map and the per-locale map written by + * `writePerLocaleEntryUidMapping` — and merges them into the updated-entries + * config file under `__entryMapping__`. + * + * This lets the entry-update-script resolve Link(Entry)/reference field values + * to their real Contentstack destination uid before writing them onto a + * localized (non-master) copy of an entry. Without this, reference fields + * written during a locale-add restart keep the export's source-side uid, + * which only happens to work when source and destination uids are identical — + * the master-locale bulk import resolves this correctly via the Contentstack + * CLI's own reference pass, but this update path does not, unless we prime it + * with the same uid-mapper data (mirrors `enrichConfigWithAssetMapping`). + */ +export const enrichConfigWithEntryMapping = ( + configFilePath: string, + projectId: string, + iteration: number, + loggerPath?: string +): void => { + const dbBase = path.join(process.cwd(), DATABASE_FILES.DIRECTORY, projectId); + + const readEntryMapper = (iter: number): { flat: Record; byLocale: Record> } => { + const p = path.join(dbBase, iter.toString(), DATABASE_FILES.UID_MAPPER); + if (!fs.existsSync(p)) return { flat: {}, byLocale: {} }; + try { + const data = JSON.parse(fs.readFileSync(p, "utf-8")); + return { flat: data?.entry || {}, byLocale: data?.entryByLocale || {} }; + } catch (err) { + console.error(`Failed to read uid-mapper for iteration ${iter}:`, err); + return { flat: {}, byLocale: {} }; + } + }; + + const oldEntryMapping = iteration > 1 ? readEntryMapper(iteration - 1) : { flat: {}, byLocale: {} }; + const newEntryMapping = readEntryMapper(iteration); + + writeLogEntry( + `Loaded entry uid mappings — old: ${Object.keys(oldEntryMapping.flat).length} flat / ${Object.keys(oldEntryMapping.byLocale).length} locales, ` + + `new: ${Object.keys(newEntryMapping.flat).length} flat / ${Object.keys(newEntryMapping.byLocale).length} locales`, + "enrichConfigWithEntryMapping", + loggerPath, + ); + + try { + const config = JSON.parse(fs.readFileSync(configFilePath, "utf-8")); + config.__entryMapping__ = { old: oldEntryMapping, new: newEntryMapping }; + fs.writeFileSync(configFilePath, JSON.stringify(config), "utf-8"); + } catch (err) { + console.error("Failed to write entry mapping into update config:", err); + writeLogEntry(`Failed to write __entryMapping__ into ${configFilePath}: ${(err as Error)?.message}`, "enrichConfigWithEntryMapping", loggerPath); + return; + } + + writeLogEntry(`Entry mapping enriched into config for iteration ${iteration}`, "enrichConfigWithEntryMapping", loggerPath); +}; + /** * Ensures an update config file exists for this iteration and returns its path. * Used when there are asset updates but no entry updates produced a config, so diff --git a/api/src/utils/locale-migration.utils.ts b/api/src/utils/locale-migration.utils.ts index af5941c5..5095d94a 100644 --- a/api/src/utils/locale-migration.utils.ts +++ b/api/src/utils/locale-migration.utils.ts @@ -64,6 +64,38 @@ export const isFullMigrationForLocale = ( return !getMigratedLocales(project).includes(localeCode); }; +/** + * Extracts destination locale codes that were ACTUALLY targeted by a delta + * run, from an `updated-entries.json` config object. + * + * Per-entry keys in that config are `${csUid}::${localeCode}` (see + * `removeEntriesFromDatabase` in entry-update.utils.ts) — this reads the + * locale suffix back out. Bookkeeping keys added by the enrich* helpers + * (`__assetMapping__`, `__entryMapping__`, `__assetUpdates__`) are skipped. + * + * This exists to fix a bug where a locale got marked "migrated" as soon as + * ANY locale finished a delta run, instead of only the locale(s) that run + * actually processed — which permanently skipped locales configured ahead of + * when they were meant to be migrated (see `runCli.service.ts`). + */ +export const extractLocalesFromUpdateConfig = ( + config: Record | null | undefined, +): string[] => { + if (!config || typeof config !== 'object') return []; + const locales = new Set(); + for (const [ctKey, entries] of Object.entries(config)) { + if (ctKey.startsWith('__')) continue; + if (!entries || typeof entries !== 'object') continue; + for (const entryKey of Object.keys(entries)) { + const sep = entryKey.lastIndexOf('::'); + if (sep === -1) continue; + const locale = entryKey.slice(sep + 2); + if (locale) locales.add(locale); + } + } + return Array.from(locales); +}; + /** * Set-union the given locales into project.migrated_locales and persist. * Idempotent. diff --git a/api/tests/unit/utils/entry-update-script.test.ts b/api/tests/unit/utils/entry-update-script.test.ts index ae0582a7..aca18829 100644 --- a/api/tests/unit/utils/entry-update-script.test.ts +++ b/api/tests/unit/utils/entry-update-script.test.ts @@ -7,7 +7,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; // default function export for testing. const require = createRequire(import.meta.url); const script = require('../../../src/utils/entry-update-script.cjs'); -const { isAssetField, resolveAssetField, mergeFlatPayloadIntoEntry } = script; +const { + isAssetField, + resolveAssetField, + mergeFlatPayloadIntoEntry, + isReferenceValue, + isReferenceArray, + resolveReferenceUid, + resolveReferenceField, +} = script; describe('entry-update-script — isAssetField', () => { it('is true only for objects carrying urlPath + filename', () => { @@ -66,6 +74,91 @@ describe('entry-update-script — resolveAssetField (3-way resolution)', () => { }); }); +describe('entry-update-script — isReferenceValue / isReferenceArray', () => { + it('recognizes the { uid, _content_type_uid } shape produced by processField', () => { + expect(isReferenceValue({ uid: 'src-1', _content_type_uid: 'author' })).toBe(true); + }); + + it('is false for asset shapes, primitives, and arrays', () => { + expect(isReferenceValue({ urlPath: '/x', filename: 'f.jpg' })).toBe(false); + expect(isReferenceValue(null)).toBeFalsy(); + expect(isReferenceValue('str')).toBeFalsy(); + expect(isReferenceValue([{ uid: 'a', _content_type_uid: 'b' }])).toBe(false); + expect(isReferenceValue({ uid: 'src-1' })).toBe(false); // missing _content_type_uid + }); + + it('recognizes a non-empty array of reference values (multi-reference field)', () => { + expect(isReferenceArray([{ uid: 'a', _content_type_uid: 'article' }, { uid: 'b', _content_type_uid: 'article' }])).toBe(true); + }); + + it('is false for an empty array or a mixed array', () => { + expect(isReferenceArray([])).toBe(false); + expect(isReferenceArray([{ uid: 'a', _content_type_uid: 'article' }, 'not-a-ref'])).toBe(false); + }); +}); + +describe('entry-update-script — resolveReferenceUid', () => { + const locale = 'en-in'; + const entryMapping = { + old: { flat: { 'src-1': 'cs-old-flat' }, byLocale: { 'en-in': { 'src-2': 'cs-old-locale' } } }, + new: { flat: { 'src-3': 'cs-new-flat' }, byLocale: { 'en-in': { 'src-1': 'cs-new-locale' } } }, + }; + + it('prefers the new per-locale mapping over everything else', () => { + expect(resolveReferenceUid('src-1', locale, entryMapping)).toBe('cs-new-locale'); + }); + + it('falls back to the old per-locale mapping when no new per-locale entry exists', () => { + expect(resolveReferenceUid('src-2', locale, entryMapping)).toBe('cs-old-locale'); + }); + + it('falls back to the flat new mapping when no per-locale entry exists at all', () => { + expect(resolveReferenceUid('src-3', locale, entryMapping)).toBe('cs-new-flat'); + }); + + it('falls back to the flat old mapping as a last resort', () => { + const mapping = { old: { flat: { 'src-4': 'cs-old-flat-only' }, byLocale: {} }, new: { flat: {}, byLocale: {} } }; + expect(resolveReferenceUid('src-4', locale, mapping)).toBe('cs-old-flat-only'); + }); + + it('falls back to identity (source uid unchanged) when no mapping exists at all', () => { + expect(resolveReferenceUid('unmapped-src', locale, { old: { flat: {}, byLocale: {} }, new: { flat: {}, byLocale: {} } })).toBe('unmapped-src'); + }); + + it('handles a missing/undefined entryMapping gracefully', () => { + expect(resolveReferenceUid('src-1', locale, undefined)).toBe('src-1'); + }); +}); + +describe('entry-update-script — resolveReferenceField', () => { + const locale = 'en-gb'; + const entryMapping = { old: { flat: {}, byLocale: {} }, new: { flat: {}, byLocale: { 'en-gb': { 'author-src': 'author-cs' } } } }; + + it('remaps a single reference value uid', () => { + const out = resolveReferenceField('author', 'e1', { uid: 'author-src', _content_type_uid: 'author' }, locale, entryMapping); + expect(out).toEqual({ uid: 'author-cs', _content_type_uid: 'author' }); + }); + + it('remaps every uid in a multi-reference array', () => { + const mapping = { old: { flat: {}, byLocale: {} }, new: { flat: {}, byLocale: { 'en-gb': { a1: 'a1-cs', a2: 'a2-cs' } } } }; + const out = resolveReferenceField( + 'relatedArticles', + 'e1', + [{ uid: 'a1', _content_type_uid: 'article' }, { uid: 'a2', _content_type_uid: 'article' }], + locale, + mapping + ); + expect(out).toEqual([ + { uid: 'a1-cs', _content_type_uid: 'article' }, + { uid: 'a2-cs', _content_type_uid: 'article' }, + ]); + }); + + it('passes non-reference values through unchanged', () => { + expect(resolveReferenceField('title', 'e1', 'plain string', locale, entryMapping)).toBe('plain string'); + }); +}); + describe('entry-update-script — mergeFlatPayloadIntoEntry', () => { it('merges flat fields into entry.content, resolves assets, and skips reserved keys', async () => { const update = vi.fn().mockResolvedValue(undefined); @@ -88,6 +181,40 @@ describe('entry-update-script — mergeFlatPayloadIntoEntry', () => { expect(entry.content._version).toBeUndefined(); expect(update).toHaveBeenCalledTimes(1); }); + + it('resolves a reference field uid using entryMapping when localizing an existing entry (CMG delta bug)', async () => { + // Reproduces the bug: an export's reference field carries the SOURCE cms + // entry id (e.g. Contentful's id), which only equals the Contentstack uid + // by coincidence. Without entryMapping this used to be written verbatim, + // silently pointing at a non-existent uid on any locale added via restart. + const update = vi.fn().mockResolvedValue(undefined); + const entry: any = { title: 'old', content: {}, update }; + + const updateData = { + uid: 'should-be-skipped', + title: 'Article 1', + author: { uid: 'contentful-author-src-id', _content_type_uid: 'author' }, + }; + + const entryMapping = { + old: { flat: {}, byLocale: {} }, + new: { flat: {}, byLocale: { 'en-in': { 'contentful-author-src-id': 'real-cs-author-uid' } } }, + }; + + await mergeFlatPayloadIntoEntry(entry, 'e1', updateData, {}, {}, undefined, 'en-in', entryMapping); + + expect(entry.content.author).toEqual({ uid: 'real-cs-author-uid', _content_type_uid: 'author' }); + }); + + it('falls back to the source uid unchanged when no entryMapping is supplied (back-compat)', async () => { + const update = vi.fn().mockResolvedValue(undefined); + const entry: any = { title: 'old', content: {}, update }; + const updateData = { author: { uid: 'src-id', _content_type_uid: 'author' } }; + + await mergeFlatPayloadIntoEntry(entry, 'e1', updateData, {}, {}, undefined, 'en-in', undefined); + + expect(entry.content.author).toEqual({ uid: 'src-id', _content_type_uid: 'author' }); + }); }); describe('entry-update-script — main task runner', () => { diff --git a/api/tests/unit/utils/entry-update.enrich.test.ts b/api/tests/unit/utils/entry-update.enrich.test.ts index c4ee7e09..90c1f027 100644 --- a/api/tests/unit/utils/entry-update.enrich.test.ts +++ b/api/tests/unit/utils/entry-update.enrich.test.ts @@ -120,3 +120,79 @@ describe('entry-update.utils — enrichConfigWithAssetMapping (extra branches)', expect(written.__assetMapping__).toEqual({ old: {}, new: {} }); }); }); + +// Covers the fix for the "reference fields blank on localized entries" bug: +// entry-update-script.cjs needs entry uid-mapper data (flat + per-locale) +// threaded into the config under __entryMapping__, the same way asset uids +// already are under __assetMapping__. +describe('entry-update.utils — enrichConfigWithEntryMapping', () => { + beforeEach(() => vi.clearAllMocks()); + + it('writes empty old/new entry mappings when no uid-mapper files exist', async () => { + mockExistsSync.mockReturnValue(false); + mockReadFileSync.mockReturnValue(JSON.stringify({ page: {} })); + + const { enrichConfigWithEntryMapping } = await import('../../../src/utils/entry-update.utils.js'); + enrichConfigWithEntryMapping('/tmp/config.json', 'p1', 1, '/tmp/x.log'); + + const write = mockWriteFileSync.mock.calls.find((c) => c[0] === '/tmp/config.json'); + const written = JSON.parse(String(write?.[1])); + expect(written.__entryMapping__).toEqual({ + old: { flat: {}, byLocale: {} }, + new: { flat: {}, byLocale: {} }, + }); + }); + + it('reads new-iteration entry + entryByLocale maps from uid-mapper.json', async () => { + mockExistsSync.mockImplementation((p: string) => p.includes('/2/uid-mapper.json')); + mockReadFileSync.mockImplementation((p: string) => { + if (p.includes('/2/uid-mapper.json')) { + return JSON.stringify({ + entry: { 'src-a': 'cs-a' }, + entryByLocale: { 'en-in': { 'src-a': 'cs-a-in' } }, + }); + } + return JSON.stringify({ page: {} }); // config file + }); + + const { enrichConfigWithEntryMapping } = await import('../../../src/utils/entry-update.utils.js'); + enrichConfigWithEntryMapping('/tmp/config.json', 'p1', 2, '/tmp/x.log'); + + const write = mockWriteFileSync.mock.calls.find((c) => c[0] === '/tmp/config.json'); + const written = JSON.parse(String(write?.[1])); + expect(written.__entryMapping__.new).toEqual({ + flat: { 'src-a': 'cs-a' }, + byLocale: { 'en-in': { 'src-a': 'cs-a-in' } }, + }); + expect(written.__entryMapping__.old).toEqual({ flat: {}, byLocale: {} }); + }); + + it('reads both old (iteration-1) and new mappings when iteration > 1', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockImplementation((p: string) => { + if (p.includes('/1/uid-mapper.json')) { + return JSON.stringify({ entry: { 'src-old': 'cs-old' }, entryByLocale: {} }); + } + if (p.includes('/2/uid-mapper.json')) { + return JSON.stringify({ entry: { 'src-new': 'cs-new' }, entryByLocale: {} }); + } + return JSON.stringify({ page: {} }); + }); + + const { enrichConfigWithEntryMapping } = await import('../../../src/utils/entry-update.utils.js'); + enrichConfigWithEntryMapping('/tmp/config.json', 'p1', 2, '/tmp/x.log'); + + const write = mockWriteFileSync.mock.calls.find((c) => c[0] === '/tmp/config.json'); + const written = JSON.parse(String(write?.[1])); + expect(written.__entryMapping__.old.flat).toEqual({ 'src-old': 'cs-old' }); + expect(written.__entryMapping__.new.flat).toEqual({ 'src-new': 'cs-new' }); + }); + + it('swallows a read/parse error on the config file without throwing', async () => { + mockExistsSync.mockReturnValue(false); + mockReadFileSync.mockReturnValue('{ not json'); + + const { enrichConfigWithEntryMapping } = await import('../../../src/utils/entry-update.utils.js'); + expect(() => enrichConfigWithEntryMapping('/tmp/config.json', 'p1', 1, '/tmp/x.log')).not.toThrow(); + }); +}); diff --git a/api/tests/unit/utils/locale-migration.utils.test.ts b/api/tests/unit/utils/locale-migration.utils.test.ts index 38d39a9a..4d2ca581 100644 --- a/api/tests/unit/utils/locale-migration.utils.test.ts +++ b/api/tests/unit/utils/locale-migration.utils.test.ts @@ -18,6 +18,7 @@ import { getMigratedLocales, isFullMigrationForLocale, recordMigratedLocales, + extractLocalesFromUpdateConfig, } from '../../../src/utils/locale-migration.utils'; describe('locale-migration.utils', () => { @@ -183,4 +184,60 @@ describe('locale-migration.utils', () => { expect((data.projects[0] as any).migrated_locales).toBeUndefined(); }); }); + + // Covers the fix for the "locale marked migrated before it was ever + // actually processed" bug: runCli.service.ts used to compute the migrated + // locale set from the project's FULL configured locale list, which + // permanently skipped any locale configured ahead of when it was meant to + // be migrated. This helper extracts ONLY the locale(s) an iteration's delta + // pass actually queued, from updated-entries.json's compound + // `${csUid}::${localeCode}` keys. + describe('extractLocalesFromUpdateConfig', () => { + it('extracts locale codes from compound entry keys across content types', () => { + const config = { + article: { 'blt-1::en-in': {}, 'blt-2::en-in': {} }, + author: { 'blt-3::en-in': {} }, + }; + expect(extractLocalesFromUpdateConfig(config)).toEqual(['en-in']); + }); + + it('dedupes locales seen across multiple entries', () => { + const config = { + article: { 'blt-1::en-gb': {}, 'blt-2::en-gb': {}, 'blt-3::en-in': {} }, + }; + const result = extractLocalesFromUpdateConfig(config); + expect(result).toEqual(expect.arrayContaining(['en-gb', 'en-in'])); + expect(result).toHaveLength(2); + }); + + it('ignores bookkeeping keys (__assetMapping__, __entryMapping__, __assetUpdates__)', () => { + const config = { + __assetMapping__: { old: {}, new: {} }, + __entryMapping__: { old: {}, new: {} }, + __assetUpdates__: [{ uid: 'a' }], + article: { 'blt-1::en-gb': {} }, + }; + expect(extractLocalesFromUpdateConfig(config)).toEqual(['en-gb']); + }); + + it('ignores legacy keys with no locale suffix', () => { + const config = { page: { 'cs-1': { title: 'T' } } }; + expect(extractLocalesFromUpdateConfig(config)).toEqual([]); + }); + + it('returns [] for null, undefined, or non-object input', () => { + expect(extractLocalesFromUpdateConfig(null)).toEqual([]); + expect(extractLocalesFromUpdateConfig(undefined)).toEqual([]); + expect(extractLocalesFromUpdateConfig('not an object' as any)).toEqual([]); + }); + + it('returns [] for an empty config object', () => { + expect(extractLocalesFromUpdateConfig({})).toEqual([]); + }); + + it('skips content types whose value is not an object', () => { + const config = { article: null, author: { 'blt-1::en-in': {} } }; + expect(extractLocalesFromUpdateConfig(config)).toEqual(['en-in']); + }); + }); }); From f901d5e5ab0cd972ebf95bae15698eaeac3c87af Mon Sep 17 00:00:00 2001 From: chetan-contentstack Date: Fri, 31 Jul 2026 11:47:48 +0530 Subject: [PATCH 2/2] fix(contentful): correct Array.isArray typo in reference field lookup Was reading a literal .id property on the entryId map instead of the dynamic key, so the single-reference branch never took the array path when the mapper legitimately held an array of destination uids. --- api/src/services/contentful.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/services/contentful.service.ts b/api/src/services/contentful.service.ts index 070d89f6..736dfe86 100644 --- a/api/src/services/contentful.service.ts +++ b/api/src/services/contentful.service.ts @@ -436,7 +436,7 @@ const processField = ( return refs; } const id = lang_value?.sys?.id; - if(Array?.isArray(entryId?.id)){ + if(Array.isArray(entryId?.[id])){ return entryId?.[id]; } else{