diff --git a/.gitignore b/.gitignore index 838c8eabf..21e267287 100644 --- a/.gitignore +++ b/.gitignore @@ -372,3 +372,4 @@ app.json # Test coverage (global) coverage/ +export-stack/ diff --git a/api/src/utils/entry-update.utils.ts b/api/src/utils/entry-update.utils.ts index 3c871df30..79cf3a641 100644 --- a/api/src/utils/entry-update.utils.ts +++ b/api/src/utils/entry-update.utils.ts @@ -122,20 +122,21 @@ export const removeEntriesFromDatabase = async (projectId: string, loggerPath?: for (const localeDir of localeDirs) { const localeCode = localeDir.name; - // Skip delta cleanup only when this is a restart AND the locale was never migrated - // before — newly-added locales have no prior state to diff against, so the regular - // full-import pipeline should pick them up untouched. On iteration 1 we always - // process (the function may be a no-op then, but tests/legacy code can still call it). - if ( + // True when this locale is brand-new in this delta run (wasn't migrated before). + // We still need to process its entry files: source entries that already exist in + // Contentstack (migrated in a prior iteration with a different locale mapping) must + // be LOCALIZED on their existing CS UID rather than imported as duplicate new entries. + // Entries with no prior CS UID are left in the import data to be created fresh. + const isNewLocaleInDelta = (projectData?.iteration ?? 1) > 1 && - isFullMigrationForLocale(projectData ?? {}, localeCode) - ) { + isFullMigrationForLocale(projectData ?? {}, localeCode); + + if (isNewLocaleInDelta) { writeLogEntry( - `Skipping delta cleanup for new locale "${localeCode}" — full import.`, + `New locale "${localeCode}" in delta run — routing existing entries through localize path instead of re-creating.`, "removeEntriesFromDatabase", loggerPath, ); - continue; } // entry_mapper rows are tagged with the SOURCE locale code (e.g. "en-IN"); // directories on disk use the DESTINATION code (e.g. "en-in"). Translate. @@ -195,7 +196,10 @@ export const removeEntriesFromDatabase = async (projectId: string, loggerPath?: const row = (sourceLocale && rowByUidAndLang.get(`${key}::${sourceLocale}`)) || rowByUid.get(key); - if (row?.isUpdate) { + // For a new locale in a delta run, ALL entries with an existing CS UID + // must be localized (not re-created). For existing locales the user must + // explicitly mark entries with isUpdate to include them in the update pass. + if (row?.isUpdate || isNewLocaleInDelta) { const entryData = { ...data[key], __locale: localeCode, __csUid: csEntryUid }; delete entryData?.uid; @@ -203,8 +207,8 @@ export const removeEntriesFromDatabase = async (projectId: string, loggerPath?: entriesToUpdate[contentTypeName] = {}; } entriesToUpdate[contentTypeName][`${csEntryUid}::${localeCode}`] = entryData; - writeLogEntry(`Collected update entry "${csEntryUid}" (locale "${localeCode}") for content type "${contentTypeName}"`, "removeEntriesFromDatabase", loggerPath); - writeLogEntry(`Entry "${key}" has been prepared for update in Contentstack as "${csEntryUid}" (locale "${localeCode}")`, "removeEntriesFromDatabase", loggerPath); + writeLogEntry(`Collected ${isNewLocaleInDelta ? 'localize' : 'update'} entry "${csEntryUid}" (locale "${localeCode}") for content type "${contentTypeName}"`, "removeEntriesFromDatabase", loggerPath); + writeLogEntry(`Entry "${key}" has been prepared for ${isNewLocaleInDelta ? 'localization' : 'update'} in Contentstack as "${csEntryUid}" (locale "${localeCode}")`, "removeEntriesFromDatabase", loggerPath); } // Existing entry → remove from import data so it is NOT re-created. diff --git a/ui/src/components/ContentMapper/entryMapper.tsx b/ui/src/components/ContentMapper/entryMapper.tsx index 62d833c49..2dec101c1 100644 --- a/ui/src/components/ContentMapper/entryMapper.tsx +++ b/ui/src/components/ContentMapper/entryMapper.tsx @@ -661,7 +661,7 @@ const EntryMapper = ({ handleStepChange }: entryMapperProps) => { plural: `${totalCounts === 0 ? 'Count' : ''}` }} /> - {totalCounts > 0 && ( + {(totalCounts > 0 || (tableData?.length ?? 0) > 0) && (
{/* Total Entries: {totalCounts} */} @@ -670,7 +670,13 @@ const EntryMapper = ({ handleStepChange }: entryMapperProps) => { className="saveButton" onClick={handleSaveContentType} version="v2" - disabled={newMigrationData?.project_current_step > 4} + // Lock the Save button only while a migration is actively in flight. + // Using migrationStarted alone would permanently lock revisits on delta + // iterations since migrationStarted stays true after completion. + disabled={ + !!newMigrationData?.migration_execution?.migrationStarted && + !newMigrationData?.migration_execution?.migrationCompleted + } isLoading={isLoadingSaveButton} > Save diff --git a/ui/src/components/ContentMapper/index.scss b/ui/src/components/ContentMapper/index.scss index 067bda8af..6a9f37123 100644 --- a/ui/src/components/ContentMapper/index.scss +++ b/ui/src/components/ContentMapper/index.scss @@ -560,10 +560,12 @@ div .table-row { } // With pagination on, the venus pagination bar renders at the bottom of the table. - // Let the Total/Save footer flow below it (not sticky-overlapping) so the two bars - // don't collide and the Save button isn't clipped at the viewport edge. + // Pin the Total/Save footer to the viewport bottom so it stays reachable when the + // table + pagination combined exceed the available viewport height. Without this the + // Save button gets clipped off-screen on small windows (Map Entry step 4). .mapper-footer { - position: static; + position: sticky; + bottom: 0; } } diff --git a/ui/src/components/DestinationStack/Actions/LoadLanguageMapper.tsx b/ui/src/components/DestinationStack/Actions/LoadLanguageMapper.tsx index bb56cdd07..1aedfe088 100644 --- a/ui/src/components/DestinationStack/Actions/LoadLanguageMapper.tsx +++ b/ui/src/components/DestinationStack/Actions/LoadLanguageMapper.tsx @@ -127,23 +127,48 @@ const Mapper = ({ useEffect(() => { const formattedoptions = options?.filter( (item: { label: string; value: string }) => - !selectedCsOptions?.some((selected: string) => selected === item?.value) && !cmsLocaleOptions?.some((locale: {label: string, value: string}) => locale?.label === item?.value) + !selectedCsOptions?.some((selected: string) => selected === item?.value) && + !cmsLocaleOptions?.some( + (locale: { label: string; value: string }) => locale?.label === item?.value + ) ); + // Also exclude source locales already consumed by a saved mapping (delta-restart case): + // selectedSourceOption only tracks in-session picks, so on restart the previous run's + // source values (e.g. the master row's "en") don't appear there and would otherwise show + // up again as selectable in a newly-added row's source dropdown. + const mappedSourceValues = new Set( + Object.values(selectedMappings || {}).filter( + (v): v is string => typeof v === 'string' && v.length > 0 + ) + ); const adjustedOptions = sourceOptions?.filter( (item: { label: string; value: string }) => - !selectedSourceOption?.some((selected: string) => selected === item?.label) + !selectedSourceOption?.some((selected: string) => selected === item?.label) && + !mappedSourceValues.has(item?.label) ); setcsOptions(formattedoptions); setsourceoptions(adjustedOptions); // sourceOptions must be in deps: on restart iteration the parent's sourceLocales arrives // asynchronously from Redux *after* mount. Without this, sourceoptions stays stale ([]) and // the Select language dropdown renders empty until Add Language forces unrelated re-renders. - }, [selectedCsOptions, selectedSourceOption, options, sourceOptions]); + // cmsLocaleOptions must be in deps too: on restart iteration it's rehydrated with the + // previous run's saved mappings *after* mount, so without it this filter never re-runs and + // already-mapped languages keep showing up as selectable options in a newly-added row. + // selectedMappings must be in deps so a source locale saved in Redux gets excluded from + // the source dropdown on restart (in-session picks alone can miss the rehydrated master row). + }, [ + selectedCsOptions, + selectedSourceOption, + options, + sourceOptions, + cmsLocaleOptions, + selectedMappings + ]); useEffect(() => { - const updatedExistingField = {...existingField}; - const updatedExistingLocale = {...existingLocale}; + const updatedExistingField = { ...existingField }; + const updatedExistingLocale = { ...existingLocale }; // const validLabels = cmsLocaleOptions?.map((item)=> item?.label); @@ -151,37 +176,39 @@ const Mapper = ({ key?.includes('-master_locale') ); - const recentMsterLocale = cmsLocaleOptions?.find((item) => item?.value === 'master_locale')?.label; - const presentLocale = `${recentMsterLocale}-master_locale`; + const recentMasterLocale = cmsLocaleOptions?.find( + (item) => item?.value === 'master_locale' + )?.label; + const presentLocale = `${recentMasterLocale}-master_locale`; Object.keys(updatedExistingField || {})?.forEach((key) => { - if ((existingMasterID !== presentLocale) || isStackChanged) { + if (existingMasterID !== presentLocale || isStackChanged) { delete updatedExistingField[key]; } }); Object.keys(updatedExistingLocale || {})?.forEach((key) => { - if ((existingMasterID !== presentLocale) || isStackChanged) { + if (existingMasterID !== presentLocale || isStackChanged) { delete updatedExistingLocale[key]; } }); - if ( (existingMasterID !== presentLocale) || isStackChanged) { + if (existingMasterID !== presentLocale || isStackChanged) { setselectedCsOption([]); setselectedSourceOption([]); } setexistingLocale(updatedExistingLocale); - cmsLocaleOptions?.map((locale, index)=>{ + cmsLocaleOptions?.map((locale, index) => { const existingLabel = existingMasterID; const expectedLabel = `${locale?.label}-master_locale`; const isLabelMismatch = existingLabel && existingLabel?.localeCompare(expectedLabel) !== 0; - if(locale?.value === 'master_locale'){ + if (locale?.value === 'master_locale') { if (!updatedExistingField?.[index]) { updatedExistingField[index] = { label: `${locale?.label}`, - value: `${locale?.label}-master_locale`, + value: `${locale?.label}-master_locale` }; } // Reflect the saved master source locale in the row UI (the master row reads its @@ -191,11 +218,10 @@ const Mapper = ({ if (savedMasterSource && !updatedExistingLocale?.[locale?.label]) { updatedExistingLocale[locale?.label] = { label: savedMasterSource, - value: savedMasterSource, + value: savedMasterSource }; } - if (isLabelMismatch || isStackChanged) { setselectedCsOption([]); setselectedSourceOption([]); @@ -203,28 +229,56 @@ const Mapper = ({ setExistingField({}); // 🔧 FIX: Merge with existing mappings instead of replacing - setSelectedMappings(prev => ({ + setSelectedMappings((prev) => ({ ...prev, - [`${locale?.label}-master_locale`]: '', + [`${locale?.label}-master_locale`]: '' })); - - } - else if ( !isLabelMismatch && !isStackChanged ) { - const key = `${locale?.label}-master_locale` + } else if (!isLabelMismatch && !isStackChanged) { + const key = `${locale?.label}-master_locale`; // 🔧 FIX: Merge with existing mappings instead of replacing - setSelectedMappings(prev => ({ + setSelectedMappings((prev) => ({ ...prev, - [key]: prev?.[key] ? prev?.[key] : '', + [key]: prev?.[key] ? prev?.[key] : '' })); } - } - }) - + } + }); + setExistingField(updatedExistingField); - - - }, [cmsLocaleOptions]); - + }, [cmsLocaleOptions]); + + // On a delta-migration restart, `selectedMappings` can hydrate from Redux *after* the + // effect above has already run (which is what reflects the saved master-locale source into + // existingLocale). That effect only depends on cmsLocaleOptions, so it won't re-run when + // selectedMappings arrives later, leaving the master row's source dropdown blank on first + // load. Re-sync it here instead of adding selectedMappings to the effect above, since that + // effect also writes to selectedMappings and would loop. + // + // We also fall back to reading the saved master source directly from Redux's localeMapping + // when selectedMappings is empty for the master key. During the mount race on restart, the + // effect above can dispatch an empty master mapping to Redux (via its else-if branch when + // `prev[key]` is undefined at effect time) before the sync-from-Redux effect at the top of + // the component has caught up. Reading Redux directly here keeps the master source visible + // in that transient state. + const reduxLocaleMapping = newMigrationData?.destination_stack?.localeMapping; + useEffect(() => { + const masterLocale = cmsLocaleOptions?.find((item) => item?.value === 'master_locale'); + if (!masterLocale) return; + + const key = `${masterLocale.label}-master_locale`; + const savedMasterSource = + selectedMappings?.[key] || reduxLocaleMapping?.[key]; + if (!savedMasterSource) return; + + setexistingLocale((prev) => { + if (prev?.[masterLocale.label]?.label === savedMasterSource) return prev; + return { + ...prev, + [masterLocale.label]: { label: savedMasterSource, value: savedMasterSource } + }; + }); + }, [selectedMappings, cmsLocaleOptions, reduxLocaleMapping]); + // function for change select value const handleSelectedCsLocale = ( @@ -276,18 +330,34 @@ const Mapper = ({ //updatedMappings[""] = valueToKeep; } else if (type === 'csLocale' && selectedLocaleKey) { - + if(updatedMappings?.[CS_ENTRIES?.UNMAPPED_LOCALE_KEY] === existingLocale?.[index]?.label){ updatedMappings[selectedLocaleKey] = existingLocale?.[index]?.label; - delete updatedMappings?.[CS_ENTRIES?.UNMAPPED_LOCALE_KEY]; - }else{ - const oldlabel = Object?.keys?.(updatedMappings)?.[index - 1]; - - // Delete old key and assign to new key - delete updatedMappings?.[oldlabel]; - updatedMappings[selectedLocaleKey] = existingLocale?.[index]?.label - ? existingLocale?.[index]?.label - : ''; + delete updatedMappings?.[CS_ENTRIES?.UNMAPPED_LOCALE_KEY]; + } else { + // Look up the row's PRIOR CS destination via existingField (captured into + // `existingLabel` above), not by position. The previous logic used + // `Object.keys(mappings)[index - 1]`, which for index=1 grabbed the MASTER row's + // key and deleted it — corrupting localeMapping to a state with no `-master_locale` + // entry. That in turn crashed the CLI audit ("Master locale undefined ...") and + // wedged the Execute step in a "started, never completed" state on delta iterations. + const oldKey = existingLabel?.value; + // Never delete the master row's key from this handler: the master Select is + // disabled in the UI, but a defensive guard keeps a future refactor from re-opening + // the corruption path. + const isMasterKey = + typeof oldKey === 'string' && oldKey.endsWith('-master_locale'); + // Preserve any source already mapped to this row's previous destination so the + // user doesn't lose their pick when they change the CS destination. + const preservedSource = + oldKey && !isMasterKey ? updatedMappings?.[oldKey] : undefined; + if (oldKey && !isMasterKey && oldKey !== selectedLocaleKey) { + delete updatedMappings?.[oldKey]; + } + updatedMappings[selectedLocaleKey] = + (preservedSource && preservedSource.length > 0 + ? preservedSource + : existingLocale?.[index]?.label) || ''; } } @@ -344,12 +414,14 @@ const Mapper = ({ updatedMappings[existingLabel?.value] = '' } else if (selectedLocaleKey) { - // 🔧 FIX: Use the actual Contentstack locale code, or source locale in lowercase as fallback - const mappingKey = existingLabel?.value || existingLabel?.label || selectedValue?.label?.toLowerCase(); - - updatedMappings[mappingKey] = selectedValue?.label - ? selectedValue?.label - : ''; + // Only persist if a CS locale has already been selected for this row. + // If the user picks source before CS, existingLabel is unset and writing + // to a fallback key (source locale lowercased) would inflate filledMappingCount + // and incorrectly re-enable Add Language for an incomplete row. + const mappingKey = existingLabel?.value || existingLabel?.label; + if (mappingKey) { + updatedMappings[mappingKey] = selectedValue?.label ? selectedValue?.label : ''; + } } return updatedMappings; @@ -431,11 +503,25 @@ const Mapper = ({ return ( <> {cmsLocaleOptions?.length > 0 ? ( - cmsLocaleOptions?.map((locale: {label:string, value: string}, index: number) => ( - + cmsLocaleOptions?.map((locale: {label:string, value: string}, index: number) => { + // Identify master rows defensively: usually the `value` marker is `'master_locale'`, + // but some rehydration paths in this file historically wrote the raw source string + // (e.g. `'en'`) into `value` for master keys, which dropped the row into the + // editable non-master render branch. Falling back to a label match against the + // stack's master locale keeps the master row locked even if that ever regresses. + const isMasterRow = + locale?.value === 'master_locale' || + (stack?.master_locale != null && + locale?.label === stack?.master_locale); + // Lock rows only when the parent flag is set (step > 2 or iteration > 1) AND + // the row has a rebuilt value from a prior iteration. Rebuilt rows always have + // locale.value set; newly-added rows keep value='' so they stay editable until + // the user advances, letting them correct a wrong selection before saving. + const isRowLocked = isDisabled && !!locale?.value; + return (
- - {locale?.value === 'master_locale' ? ( + + {isMasterRow ? ( @@ -531,16 +617,16 @@ const Mapper = ({ version="v2" hideSelectedOptions={true} isClearable={true} - // Existing rows (have a `value` from the prior mapping) stay locked when the - // parent says disabled (e.g. restart iteration). Newly-added rows have an empty - // value and must remain editable so the user can pick their locales. - isDisabled={isDisabled && !!locale?.value} + // Row lock: rebuilt rows (have a `value` from the prior mapping) or rows whose + // mapping is already saved to Redux stay locked. Newly-added rows have an empty + // value and no Redux entry, so they remain editable for the user to fill in. + isDisabled={isRowLocked} //className="select-container" menuPlacement="auto" /> }
- {locale?.value !== 'master_locale' && (!isDisabled || !locale?.value) && ( + {!isMasterRow && !isRowLocked && (
- )) + ); + }) ) : ( { Object?.entries(newMigrationData?.destination_stack?.localeMapping || {})?.forEach( ([key, value]) => { setcmsLocaleOptions((prevList) => { + // Master-key entries (`-master_locale`) must rebuild with + // value='master_locale' — the marker string the render layer keys off to + // hardcode the CS Select as disabled and mark this row as master. Using + // the raw source value here (e.g. 'en') would drop the master row into the + // normal-row render branch, which is editable when the parent isDisabled + // prop isn't set (a race window during the Step 1 → Step 2 navigation on + // an iteration-1 revisit). Keep master rows locked by preserving the marker. + const isMasterKey = typeof key === 'string' && key.endsWith('-master_locale'); const labelKey = key?.replace(/-master_locale$/, ''); const exists = prevList?.some((item) => item?.label === labelKey); if (!exists) { @@ -702,7 +797,7 @@ const LanguageMapper = ({stack, uid} :{ stack : IDropDown, uid : string}) => { ...prevList, { label: labelKey, - value: String(value) + value: isMasterKey ? 'master_locale' : String(value) } ]; } @@ -794,13 +889,40 @@ const LanguageMapper = ({stack, uid} :{ stack : IDropDown, uid : string}) => { } // Restart iteration: button must remain available so users can add a new // destination locale before the delta run. Block when (a) there's already an - // empty in-progress row waiting to be filled (avoid stacking empties) OR (b) - // every available source locale is already mapped — otherwise clicking Add - // Language would surface a row whose source dropdown has no unmapped option + // in-progress row not yet backed by a completed mapping (avoid stacking empties) + // OR (b) every available source locale is already mapped — otherwise clicking + // Add Language would surface a row whose source dropdown has no unmapped option // to pick (e.g. a single-locale source where `en` is already in use). - const hasEmptyRow = cmsLocaleOptions?.some((o) => !o?.value); + // + // Row-completeness derives from Redux (`localeMapping`) rather than + // `cmsLocaleOptions[i].value`, because the row-object's `value` field is only + // populated on rebuild — the per-row handlers (handleSelectedCsLocale / + // handleSelectedSourceLocale) update `selectedMappings` → Redux but never write + // back into `cmsLocaleOptions`, so a freshly-filled row would otherwise still + // read as "empty" here and lock Add Language forever after one add. + // + // Treat the master row as always-filled: its value is auto-managed by the + // parent (stack master locale), and on a delta-restart race the source can be + // momentarily blank in Redux before the sync-from-Redux effect merges it back. + // Counting master as filled prevents that transient state from disabling the + // button on the very first render after restart. + const savedMapping = newMigrationData?.destination_stack?.localeMapping || {}; + // Exclude keys that are source locale codes — handleSelectedSourceLocale + // previously wrote under a fallback key (source label lowercased) when the + // user picked source before CS, inflating the count and re-enabling Add Language + // for an incomplete row. Filter those out so only proper CS locale keys count. + const sourceLocaleLabels = new Set( + sourceLocales?.map((l: { label: string }) => l.label) ?? [] + ); + const filledMappingCount = Object.entries(savedMapping).filter(([k, v]) => { + const isMasterKey = typeof k === 'string' && k.endsWith('-master_locale'); + if (isMasterKey) return true; + if (sourceLocaleLabels.has(k)) return false; + return typeof v === 'string' && v.length > 0; + }).length; + const hasIncompleteRow = (cmsLocaleOptions?.length ?? 0) > filledMappingCount; const mappedSources = new Set( - Object.values(newMigrationData?.destination_stack?.localeMapping || {}).filter( + Object.values(savedMapping).filter( (v): v is string => typeof v === 'string' && v?.length > 0 ) ); @@ -811,7 +933,7 @@ const LanguageMapper = ({stack, uid} :{ stack : IDropDown, uid : string}) => { const totalSources = sourceLocales?.length ?? 0; const sourcesNotReady = totalSources === 0; const allSourcesMapped = totalSources > 0 && mappedSources.size >= totalSources; - return hasEmptyRow || sourcesNotReady || allSourcesMapped; + return hasIncompleteRow || sourcesNotReady || allSourcesMapped; })()} > Add Language diff --git a/ui/src/pages/Migration/index.tsx b/ui/src/pages/Migration/index.tsx index 92e4581df..5b2a13d8c 100644 --- a/ui/src/pages/Migration/index.tsx +++ b/ui/src/pages/Migration/index.tsx @@ -660,8 +660,30 @@ const Migration = () => { // On a restart (iteration > 1) we must NOT skip Step 2 — that's where the user // can review/adjust locale mapping (e.g. add a new locale before a delta run). const isRestart = (newMigrationData?.iteration ?? 1) > 1; - // Otherwise, if a stack is already chosen we can jump straight to Step 3. - if (!isRestart && newMigrationData?.destination_stack?.selectedStack?.value) { + // Also require every source locale to have a filled mapping before we skip Step 2. + // Step 2 is where BOTH the destination stack AND per-locale mappings get configured; + // a project where the stack is chosen but some source locales are still unmapped + // (e.g. multi-locale source with only master mapped so far) needs a Step 2 visit to + // finish the mapping — the earlier `selectedStack.value` alone was too permissive + // and could skip users past locale mapping they hadn't completed yet. + const savedMapping = newMigrationData?.destination_stack?.localeMapping || {}; + const sourceLocales: string[] = newMigrationData?.destination_stack?.sourceLocale ?? []; + const sourceLocaleSet = new Set(sourceLocales); + const mappedSourceValues = new Set( + Object.entries(savedMapping) + .filter(([k]) => k && k.trim() && k !== CS_ENTRIES.UNMAPPED_LOCALE_KEY && !sourceLocaleSet.has(k)) + .map(([, v]) => v) + .filter((v): v is string => typeof v === 'string' && v.length > 0) + ); + const allSourceLocalesMapped = + sourceLocales.length > 0 && sourceLocales.every((loc: string) => mappedSourceValues.has(loc)); + // Otherwise, if a stack is already chosen AND every locale is mapped, we can jump + // straight to Step 3. + if ( + !isRestart && + newMigrationData?.destination_stack?.selectedStack?.value && + allSourceLocalesMapped + ) { const url = `/projects/${projectId}/migration/steps/3`; // Bump current_step a second time so backend lands on Step 3 (Content Mapping) // — we're skipping Step 2 because the destination stack is already configured. diff --git a/upload-api/migration-contentful/index.js b/upload-api/migration-contentful/index.js index babec0d9b..304304296 100644 --- a/upload-api/migration-contentful/index.js +++ b/upload-api/migration-contentful/index.js @@ -5,11 +5,13 @@ const createInitialMapper = require('./libs/createInitialMapper'); const extractLocale = require('./libs/extractLocale'); const extractTaxonomy = require('./libs/extractTaxonomy'); const extractEntries = require('./libs/extractEntries'); +const extractAssets = require('./libs/extractAssets'); module.exports = { extractContentTypes, createInitialMapper, extractLocale, extractTaxonomy, - extractEntries + extractEntries, + extractAssets }; diff --git a/upload-api/migration-contentful/libs/extractAssets.js b/upload-api/migration-contentful/libs/extractAssets.js new file mode 100644 index 000000000..a77c8f5b3 --- /dev/null +++ b/upload-api/migration-contentful/libs/extractAssets.js @@ -0,0 +1,106 @@ +'use strict'; +/* eslint-disable @typescript-eslint/no-var-requires */ + +const { readFile } = require('../utils/helper'); + +/** + * Extracts asset mapping rows from a Contentful export. + * + * Discovery mirrors extractEntries in this same package: the Contentful export + * is a single JSON file read via readFile(cleanLocalPath), with top-level + * `entries`, `locales` and `assets` arrays (standard `contentful-export` + * output). Each asset carries a stable `sys.id` and locale-keyed fields, so + * file metadata lives under `fields.file[locale]` and the title under + * `fields.title[locale]`: + * + * { + * sys: { id: '' }, + * fields: { + * title: { 'en-US': 'My Asset' }, + * file: { + * 'en-US': { + * fileName: 'my-asset.png', + * url: '//images.ctfassets.net/.../my-asset.png', + * details: { size: 12345 } + * } + * } + * } + * } + * + * We dedupe by sys.id and use it as both `id` and `otherCmsAssetUid` so the row + * stays stable across delta iterations. Returned rows are consumed by + * putTestData on the main API to populate the asset_mapper DB (AssetMapper UI), + * matching the AEM/Sitecore AssetMappingRow shape. + * + * @param {string} cleanLocalPath - Path to the Contentful export JSON file. + * @returns {Array<{id:string,otherCmsAssetUid:string,filename:string,title:string,file_size:(number|string),assetPath:string,isUpdate:boolean}>} + */ +const extractAssets = (cleanLocalPath) => { + try { + const alldata = readFile(cleanLocalPath); + const assets = alldata?.assets; + const locales = Array.isArray(alldata?.locales) + ? alldata?.locales?.map((locale) => locale?.code).filter(Boolean) + : []; + + if (!assets || !Array?.isArray(assets) || assets?.length === 0) { + console.info('No assets found in Contentful export'); + return []; + } + + const rows = []; + const seenIds = new Set(); + + // Prefer a locale-keyed value, falling back to the first available locale. + const pickLocalized = (field) => { + if (!field || typeof field !== 'object') return undefined; + for (const locale of locales) { + if (field[locale] !== undefined) return field[locale]; + } + const firstKey = Object.keys(field)[0]; + return firstKey !== undefined ? field[firstKey] : undefined; + }; + + for (const asset of assets) { + const id = asset?.sys?.id; + if (!id || seenIds.has(id)) { + continue; + } + + const file = pickLocalized(asset?.fields?.file); + const titleValue = pickLocalized(asset?.fields?.title); + const title = typeof titleValue === 'string' ? titleValue : ''; + + const filename = + (typeof file?.fileName === 'string' && file?.fileName) || title || ''; + const fileSize = file?.details?.size ?? ''; + // Contentful serves assets from a protocol-relative CDN URL ("//..."); + // normalize to https so downstream consumers get an absolute URL. + let assetPath = typeof file?.url === 'string' ? file?.url : ''; + if (assetPath.startsWith('//')) { + assetPath = `https:${assetPath}`; + } + + seenIds.add(id); + + rows.push({ + id, + otherCmsAssetUid: id, + filename, + title: title || filename, + file_size: fileSize, + assetPath, + isUpdate: false, + }); + } + + console.info(`extractAssets: Extracted ${rows.length} Contentful assets`); + + return rows; + } catch (err) { + console.error('Error extracting Contentful assets:', err); + return []; + } +}; + +module.exports = extractAssets; \ No newline at end of file diff --git a/upload-api/migration-drupal/index.js b/upload-api/migration-drupal/index.js index ec551137e..c46e7049b 100644 --- a/upload-api/migration-drupal/index.js +++ b/upload-api/migration-drupal/index.js @@ -4,11 +4,13 @@ const extractTaxonomy = require('./libs/extractTaxonomy'); const createInitialMapper = require('./libs/createInitialMapper'); const extractLocale = require('./libs/extractLocale'); const extractEntries = require('./libs/extractEntries'); +const extractAssets = require('./libs/extractAssets'); module.exports = { // extractContentTypes, extractTaxonomy, createInitialMapper, extractLocale, - extractEntries + extractEntries, + extractAssets }; diff --git a/upload-api/migration-drupal/libs/extractAssets.js b/upload-api/migration-drupal/libs/extractAssets.js new file mode 100644 index 000000000..d6878028b --- /dev/null +++ b/upload-api/migration-drupal/libs/extractAssets.js @@ -0,0 +1,109 @@ +'use strict'; +/* eslint-disable @typescript-eslint/no-var-requires */ + +/** + * Builds the assetMapping array consumed by putTestData on the main API to + * populate the asset_mapper DB shown on the AssetMapper UI — same flow/shape + * as AEM/Sitecore/Contentful/WordPress. + * + * Drupal reads from MySQL (not files). Files live in the `file_managed` table + * (fid, uuid, filename, uri, filesize, filemime). We surface every managed + * file, deduped by its Drupal file id, using the fid as the stable + * id/otherCmsAssetUid so it lines up across delta iterations. + * + * Row shape mirrors migration-sitecore/libs/extractAssets.js: + * { id, otherCmsAssetUid, filename, title, file_size, assetPath, isUpdate } + */ + +const { dbConnection } = require('../utils/helper'); + +/** + * Derive a display-friendly path from the Drupal file uri. + * e.g. "public://2023-01/photo.jpg" -> "public/2023-01" + */ +const assetPathFromUri = (uri, filename) => { + if (!uri || typeof uri !== 'string') { + return ''; + } + // Strip the stream wrapper scheme ("public://", "private://", "s3://", ...) + let cleaned = uri.replace(/^[a-z0-9]+:\/\//i, ''); + // Drop the filename to leave only the directory portion + if (filename && cleaned.endsWith(filename)) { + cleaned = cleaned.slice(0, cleaned.length - filename.length); + } + return cleaned.replace(/\/+$/, ''); +}; + +/** + * @param {Object} config - Migration config; must contain config.mysql (DB + * connection details). config.assetsConfig is accepted but unused for now. + * @returns {Promise>} assetMapping rows + */ +const extractAssets = async (config) => { + const rows = []; + let connection; + + try { + connection = dbConnection(config); + + // Bound the result set so a very large media library can't be pulled fully into + // memory. Configurable via assetsConfig.maxAssets; parameterized to keep it out of + // the SQL string. + const maxAssets = Number(config?.assetsConfig?.maxAssets) || 100000; + const query = ` + SELECT fid, uuid, filename, uri, filesize, filemime + FROM file_managed + ORDER BY fid + LIMIT ? + `; + const [results] = await connection.promise().query(query, [maxAssets]); + + if (Array.isArray(results) && results.length === maxAssets) { + console.warn( + `extractAssets (Drupal): hit maxAssets cap of ${maxAssets}; some assets may be omitted` + ); + } + + const seenIds = new Set(); + + for (const file of results || []) { + if (!file) { + continue; + } + + const id = file?.fid != null ? String(file?.fid) : ''; + if (!id || seenIds.has(id)) { + continue; + } + seenIds.add(id); + + const filename = file?.filename ? String(file?.filename) : `File ${id}`; + + rows.push({ + id, + otherCmsAssetUid: id, + filename, + title: filename, + file_size: file?.filesize != null ? String(file?.filesize) : '', + assetPath: assetPathFromUri(file?.uri, filename), + isUpdate: false + }); + } + + console.info(`extractAssets (Drupal): ${rows.length} asset(s)`); + return rows; + } catch (err) { + console.error('extractAssets (Drupal) error:', err?.message || err); + return rows; + } finally { + if (connection) { + try { + connection.end(); + } catch (endErr) { + console.error('extractAssets (Drupal) connection close error:', endErr?.message || endErr); + } + } + } +}; + +module.exports = extractAssets; \ No newline at end of file diff --git a/upload-api/migration-wordpress/index.ts b/upload-api/migration-wordpress/index.ts index 7b4b9eaf7..86192d64f 100644 --- a/upload-api/migration-wordpress/index.ts +++ b/upload-api/migration-wordpress/index.ts @@ -2,10 +2,12 @@ import extractContentTypes from './libs/contentTypes'; //import contentTypeMaker from './libs/contentTypeMapper'; import extractLocale from './libs/extractLocale'; import extractEntries from './libs/extractEntries'; +import extractAssets from './libs/extractAssets'; export { extractContentTypes, //contentTypeMaker, extractLocale, - extractEntries + extractEntries, + extractAssets } \ No newline at end of file diff --git a/upload-api/migration-wordpress/libs/extractAssets.ts b/upload-api/migration-wordpress/libs/extractAssets.ts new file mode 100644 index 000000000..35bfd0e46 --- /dev/null +++ b/upload-api/migration-wordpress/libs/extractAssets.ts @@ -0,0 +1,97 @@ +import fs from 'fs'; + +export interface AssetMappingRow { + id: string; + otherCmsAssetUid: string; + filename: string; + title: string; + file_size: number | string; + assetPath: string; + isUpdate: boolean; +} + +const normalizeArray = (value: T | T[] | undefined): T[] => { + if (!value) return []; + return Array.isArray(value) ? value : [value]; +}; + +/** + * WordPress WXR exports are parsed upstream into JSON before reaching here, so + * we load the file exactly like extractEntries/extractItems do: read the file + * and JSON.parse it, then walk rss.channel.item. Media are `item` elements with + * `wp:post_type === 'attachment'`. + */ +const getAssetId = (item: any): string => { + const postId = item?.['wp:post_id']; + if (postId != null && String(postId).trim() !== '') { + return String(postId).trim(); + } + const guid = item?.guid?.text ?? item?.guid; + return guid != null ? String(guid).trim() : ''; +}; + +const getAssetUrl = (item: any): string => { + const url = item?.['wp:attachment_url'] ?? item?.guid?.text ?? item?.guid; + return url != null ? String(url).trim() : ''; +}; + +const getFilename = (item: any, assetUrl: string): string => { + const fromUrl = assetUrl?.split?.('/')?.pop?.(); + if (typeof fromUrl === 'string' && fromUrl.trim() !== '') { + return fromUrl.trim(); + } + const title = item?.title?.text ?? item?.title; + return typeof title === 'string' ? title.trim() : ''; +}; + +const getTitle = (item: any, filename: string): string => { + const title = item?.title?.text ?? item?.title; + if (typeof title === 'string' && title.trim() !== '') { + return title.trim(); + } + return filename.split('.').slice(0, -1).join('.') || filename; +}; + +const extractAssets = async (filePath: string): Promise => { + const rows: AssetMappingRow[] = []; + try { + const rawData = await fs.promises.readFile(filePath, 'utf8'); + const jsonData = JSON.parse(rawData); + const items = normalizeArray(jsonData?.rss?.channel?.item); + + const seenIds = new Set(); + + for (const item of items) { + if (item?.['wp:post_type'] !== 'attachment') { + continue; + } + + const id = getAssetId(item); + if (!id || seenIds.has(id)) { + continue; + } + seenIds.add(id); + + const assetPath = getAssetUrl(item); + const filename = getFilename(item, assetPath); + const title = getTitle(item, filename); + + rows.push({ + id, + otherCmsAssetUid: id, + filename, + title, + file_size: '', + assetPath, + isUpdate: false, + }); + } + + return rows; + } catch (error: any) { + console.error('Error while extracting WordPress assets:', error?.message || error); + return rows; + } +}; + +export default extractAssets; \ No newline at end of file diff --git a/upload-api/src/controllers/wordpress/index.ts b/upload-api/src/controllers/wordpress/index.ts index bf86c1361..73a1f4465 100644 --- a/upload-api/src/controllers/wordpress/index.ts +++ b/upload-api/src/controllers/wordpress/index.ts @@ -2,7 +2,7 @@ import axios from "axios"; import logger from "../../utils/logger"; import { HTTP_CODES, HTTP_TEXTS, MIGRATION_DATA_CONFIG } from "../../constants"; // eslint-disable-next-line @typescript-eslint/no-var-requires -import { extractContentTypes, extractLocale, extractEntries } from 'migration-wordpress'; +import { extractContentTypes, extractLocale, extractEntries, extractAssets } from 'migration-wordpress'; import { deleteFolderSync } from "../../helper"; import path from "path"; @@ -44,7 +44,8 @@ const createWordpressMapper = async (filePath: string = "", projectId: string | } if(contentTypeData){ - const fieldMapping: any = { contentTypes: [], extractPath: filePath }; + const assetMapping = await extractAssets(filePath); + const fieldMapping: any = { contentTypes: [], extractPath: filePath, assetMapping }; contentTypeData.forEach((contentType: any) => { const jsonfileContent = contentType; jsonfileContent.type = "content_type"; diff --git a/upload-api/src/services/contentful/index.ts b/upload-api/src/services/contentful/index.ts index 0f02c2338..81e57a23d 100644 --- a/upload-api/src/services/contentful/index.ts +++ b/upload-api/src/services/contentful/index.ts @@ -7,12 +7,13 @@ import logger from '../../utils/logger'; import { HTTP_CODES, HTTP_TEXTS } from '../../constants'; import { Config } from '../../models/types'; -const { +import { extractContentTypes, createInitialMapper, extractLocale, - extractTaxonomy -} = require('migration-contentful'); + extractTaxonomy, + extractAssets +} from 'migration-contentful'; const createContentfulMapper = async ( projectId: string | string[], @@ -23,7 +24,7 @@ const createContentfulMapper = async ( try { const { localPath } = config; const cleanLocalPath = localPath?.replace?.(/\/$/, ''); - const fetchedLocales: [] = await extractLocale(cleanLocalPath); + const fetchedLocales: string[] = await extractLocale(cleanLocalPath); const mapperConfig = { method: 'post', @@ -46,11 +47,14 @@ const createContentfulMapper = async ( }); } - await extractContentTypes(cleanLocalPath, affix); - const initialMapper = await createInitialMapper(cleanLocalPath, affix); + await extractContentTypes(cleanLocalPath, affix as string); + const initialMapper = await createInitialMapper(cleanLocalPath, affix as string); // Must run after createInitialMapper: that step deletes contentfulMigrationData (contentfulSchema) and would remove taxonomy files written earlier. await extractTaxonomy(cleanLocalPath); + // Asset mapping rows for the AssetMapper UI (same flow as AEM/Sitecore). + const assetMapping = await extractAssets(cleanLocalPath); + let taxonomies: any[] = []; try { const taxonomyPath = path.join( @@ -77,7 +81,8 @@ const createContentfulMapper = async ( }, data: JSON.stringify({ ...initialMapper, - taxonomies + taxonomies, + assetMapping }) }; const { data} = await axios.request(req); diff --git a/upload-api/src/services/drupal/index.ts b/upload-api/src/services/drupal/index.ts index 63b33ad0f..ec95bf4da 100644 --- a/upload-api/src/services/drupal/index.ts +++ b/upload-api/src/services/drupal/index.ts @@ -7,7 +7,7 @@ import logger from '../../utils/logger'; import { HTTP_CODES, HTTP_TEXTS } from '../../constants'; import { Config } from '../../models/types'; -const { createInitialMapper, extractLocale, extractTaxonomy } = require('migration-drupal'); +import { createInitialMapper, extractLocale, extractTaxonomy, extractAssets } from 'migration-drupal'; const createDrupalMapper = async ( config: Config, @@ -52,10 +52,13 @@ const createDrupalMapper = async ( } // Extract taxonomy vocabularies and save to drupalMigrationData - await extractTaxonomy(config?.mysql); + await extractTaxonomy(config?.mysql as object); const initialMapper = await createInitialMapper(config, affix); + // Asset mapping rows for the AssetMapper UI (same flow as AEM/Sitecore/Contentful/WordPress). + const assetMapping = await extractAssets(config); + // Read extracted taxonomies from file let taxonomies: any[] = []; try { @@ -84,7 +87,8 @@ const createDrupalMapper = async ( contentTypes: initialMapper.contentTypes, // All content types (no profile) assetsConfig: config.assetsConfig, mySQLDetails: config.mysql, - taxonomies: taxonomies // Add taxonomies to payload + taxonomies: taxonomies, // Add taxonomies to payload + assetMapping // Asset rows for the AssetMapper UI }; const req = { diff --git a/upload-api/tests/__mocks__/migration-contentful.ts b/upload-api/tests/__mocks__/migration-contentful.ts index 21764d913..02ec5690f 100644 --- a/upload-api/tests/__mocks__/migration-contentful.ts +++ b/upload-api/tests/__mocks__/migration-contentful.ts @@ -3,3 +3,5 @@ import { vi } from 'vitest'; export const extractLocale = vi.fn().mockResolvedValue([]); export const extractContentTypes = vi.fn().mockResolvedValue(undefined); export const createInitialMapper = vi.fn().mockResolvedValue({ contentTypes: [] }); +export const extractTaxonomy = vi.fn().mockResolvedValue(undefined); +export const extractAssets = vi.fn().mockReturnValue([]); diff --git a/upload-api/tests/__mocks__/migration-drupal.ts b/upload-api/tests/__mocks__/migration-drupal.ts index 386318efd..4df9c9204 100644 --- a/upload-api/tests/__mocks__/migration-drupal.ts +++ b/upload-api/tests/__mocks__/migration-drupal.ts @@ -3,3 +3,4 @@ import { vi } from 'vitest'; export const extractLocale = vi.fn().mockResolvedValue(new Set()); export const extractTaxonomy = vi.fn().mockResolvedValue(undefined); export const createInitialMapper = vi.fn().mockResolvedValue({ contentTypes: [] }); +export const extractAssets = vi.fn().mockResolvedValue([]); diff --git a/upload-api/tests/unit/controllers/wordpress.controller.test.ts b/upload-api/tests/unit/controllers/wordpress.controller.test.ts index c15351e25..e3e8aef5d 100644 --- a/upload-api/tests/unit/controllers/wordpress.controller.test.ts +++ b/upload-api/tests/unit/controllers/wordpress.controller.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { mockAxiosRequest, mockDeleteFolderSync, mockExtractLocale, mockExtractContentTypes, mockExtractEntries } = vi.hoisted(() => ({ +const { mockAxiosRequest, mockDeleteFolderSync, mockExtractLocale, mockExtractContentTypes, mockExtractEntries, mockExtractAssets } = vi.hoisted(() => ({ mockAxiosRequest: vi.fn(), mockDeleteFolderSync: vi.fn(), mockExtractLocale: vi.fn().mockResolvedValue([]), @@ -8,12 +8,15 @@ const { mockAxiosRequest, mockDeleteFolderSync, mockExtractLocale, mockExtractCo // `extractEntries` is called between extractContentTypes and the POST to enrich each // content type with its entry list. Default: pass the input through unchanged. mockExtractEntries: vi.fn().mockImplementation((_p: string, ct: any) => ct), + // `extractAssets` builds the assetMapping rows sent with the createDummyData payload. + mockExtractAssets: vi.fn().mockResolvedValue([]), })); vi.mock('migration-wordpress', () => ({ extractLocale: mockExtractLocale, extractContentTypes: mockExtractContentTypes, extractEntries: mockExtractEntries, + extractAssets: mockExtractAssets, })); vi.mock('axios', () => ({ default: { request: mockAxiosRequest } })); diff --git a/upload-api/tests/unit/services/contentful.service.test.ts b/upload-api/tests/unit/services/contentful.service.test.ts index 8c1aa57ec..66af0546b 100644 --- a/upload-api/tests/unit/services/contentful.service.test.ts +++ b/upload-api/tests/unit/services/contentful.service.test.ts @@ -1,13 +1,27 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { mockAxiosRequest } = vi.hoisted(() => ({ +const { mockAxiosRequest, mockExtractLocale, mockExtractContentTypes, mockCreateInitialMapper, mockExtractTaxonomy, mockExtractAssets } = vi.hoisted(() => ({ mockAxiosRequest: vi.fn(), + mockExtractLocale: vi.fn().mockResolvedValue([]), + mockExtractContentTypes: vi.fn().mockResolvedValue(undefined), + mockCreateInitialMapper: vi.fn().mockResolvedValue({ contentTypes: [] }), + mockExtractTaxonomy: vi.fn().mockResolvedValue(undefined), + mockExtractAssets: vi.fn().mockReturnValue([]), })); +// Mock the connector explicitly (the service uses require('migration-contentful')). +vi.mock('migration-contentful', () => ({ + extractLocale: mockExtractLocale, + extractContentTypes: mockExtractContentTypes, + createInitialMapper: mockCreateInitialMapper, + extractTaxonomy: mockExtractTaxonomy, + extractAssets: mockExtractAssets, +})); vi.mock('axios', () => ({ default: { request: mockAxiosRequest } })); vi.mock('../../../src/utils/logger', () => ({ default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() } })); import createContentfulMapper from '../../../src/services/contentful/index'; +const extractAssets = mockExtractAssets; describe('createContentfulMapper', () => { beforeEach(() => { @@ -46,4 +60,26 @@ describe('createContentfulMapper', () => { createContentfulMapper('proj-1', 'token', 'csm', config) ).resolves.toBeUndefined(); }); + + it('sends the extracted assetMapping in the createDummyData payload', async () => { + const config: any = { localPath: '/tmp/export.json' }; + const assetRows = [ + { id: 'a1', otherCmsAssetUid: 'a1', filename: 'a.png', title: 'a', file_size: 1, assetPath: 'https://x/a.png', isUpdate: false }, + ]; + (extractAssets as any).mockReturnValueOnce(assetRows); + // localeMapper POST first, createDummyData POST second. + mockAxiosRequest + .mockResolvedValueOnce({ status: 200, data: {} }) + .mockResolvedValueOnce({ data: { data: { content_mapper: [1] } } }); + + await createContentfulMapper('proj-1', 'token', 'csm', config); + + expect(extractAssets).toHaveBeenCalledWith('/tmp/export.json'); + const dummyDataCall = mockAxiosRequest.mock.calls.find( + ([req]) => typeof req?.url === 'string' && req.url.includes('createDummyData') + ); + expect(dummyDataCall).toBeTruthy(); + const payload = JSON.parse((dummyDataCall as any[])[0].data as string); + expect(payload.assetMapping).toEqual(assetRows); + }); }); diff --git a/upload-api/tests/unit/services/drupal.service.test.ts b/upload-api/tests/unit/services/drupal.service.test.ts index 774ddeccf..69edd799b 100644 --- a/upload-api/tests/unit/services/drupal.service.test.ts +++ b/upload-api/tests/unit/services/drupal.service.test.ts @@ -1,9 +1,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { mockAxiosRequest } = vi.hoisted(() => ({ +const { mockAxiosRequest, mockExtractLocale, mockExtractTaxonomy, mockCreateInitialMapper, mockExtractAssets } = vi.hoisted(() => ({ mockAxiosRequest: vi.fn(), + mockExtractLocale: vi.fn().mockResolvedValue(new Set()), + mockExtractTaxonomy: vi.fn().mockResolvedValue(undefined), + mockCreateInitialMapper: vi.fn().mockResolvedValue({ contentTypes: [] }), + mockExtractAssets: vi.fn().mockResolvedValue([]), })); +// Mock the connector explicitly (the service imports from 'migration-drupal'). +vi.mock('migration-drupal', () => ({ + extractLocale: mockExtractLocale, + extractTaxonomy: mockExtractTaxonomy, + createInitialMapper: mockCreateInitialMapper, + extractAssets: mockExtractAssets, +})); vi.mock('axios', () => ({ default: { request: mockAxiosRequest } })); vi.mock('../../../src/utils/logger', () => ({ default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() } })); vi.mock('fs', () => ({ @@ -13,6 +24,7 @@ vi.mock('fs', () => ({ })); import createDrupalMapper from '../../../src/services/drupal/index'; +const extractAssets = mockExtractAssets; describe('createDrupalMapper', () => { const config: any = { @@ -54,4 +66,25 @@ describe('createDrupalMapper', () => { createDrupalMapper(config, 'proj-1', 'token', 'csm') ).resolves.toBeUndefined(); }); + + it('sends the extracted assetMapping in the createDummyData payload', async () => { + const assetRows = [ + { id: '5', otherCmsAssetUid: '5', filename: 'a.jpg', title: 'a.jpg', file_size: '10', assetPath: '2023', isUpdate: false }, + ]; + (extractAssets as any).mockResolvedValueOnce(assetRows); + // localeMapper POST first, createDummyData POST second. + mockAxiosRequest + .mockResolvedValueOnce({ status: 200, data: {} }) + .mockResolvedValueOnce({ data: { data: { content_mapper: [1] } } }); + + await createDrupalMapper(config, 'proj-1', 'token', 'csm'); + + expect(extractAssets).toHaveBeenCalledWith(config); + const dummyDataCall = mockAxiosRequest.mock.calls.find( + ([req]) => typeof req?.url === 'string' && req.url.includes('createDummyData') + ); + expect(dummyDataCall).toBeTruthy(); + const payload = JSON.parse((dummyDataCall as any[])[0].data as string); + expect(payload.assetMapping).toEqual(assetRows); + }); });