From 68c32c7f6faa23c047bdac5aed75cf0c2e50c303 Mon Sep 17 00:00:00 2001 From: raj pandey Date: Sat, 2 May 2026 23:45:55 +0530 Subject: [PATCH 1/2] feat(import): publish taxonomies after import --- .../src/import/modules/base-class.ts | 11 + .../src/import/modules/taxonomies.ts | 261 ++++++++++++++++-- .../src/utils/constants.ts | 5 + .../unit/import/modules/base-class.test.ts | 54 ++++ .../unit/import/modules/taxonomies.test.ts | 141 ++++++++-- 5 files changed, 433 insertions(+), 39 deletions(-) diff --git a/packages/contentstack-import/src/import/modules/base-class.ts b/packages/contentstack-import/src/import/modules/base-class.ts index 55f8067e3..286485139 100644 --- a/packages/contentstack-import/src/import/modules/base-class.ts +++ b/packages/contentstack-import/src/import/modules/base-class.ts @@ -58,6 +58,7 @@ export type ApiModuleType = | 'create-entries' | 'update-entries' | 'publish-entries' + | 'publish-taxonomies' | 'delete-entries' | 'create-taxonomies' | 'create-terms' @@ -343,6 +344,8 @@ export default abstract class BaseClass { if ( !apiData || (entity === 'publish-entries' && !apiData.entryUid) || + (entity === 'publish-taxonomies' && + (!apiData.environments?.length || !apiData.locales?.length || !apiData.items?.length)) || (entity === 'update-extensions' && !apiData.uid) ) { return Promise.resolve(); @@ -489,6 +492,14 @@ export default abstract class BaseClass { }) .then(onSuccess) .catch(onReject); + case 'publish-taxonomies': { + const publishParams = this.importConfig.branchName ? { branch: this.importConfig.branchName } : {}; + return (this.stack as any) + .taxonomy() + .publish(apiData, '3.2', publishParams) + .then(onSuccess) + .catch(onReject); + } case 'delete-entries': return this.stack .contentType(apiData.cTUid) diff --git a/packages/contentstack-import/src/import/modules/taxonomies.ts b/packages/contentstack-import/src/import/modules/taxonomies.ts index bd9b1a87c..c57462a6c 100644 --- a/packages/contentstack-import/src/import/modules/taxonomies.ts +++ b/packages/contentstack-import/src/import/modules/taxonomies.ts @@ -1,7 +1,7 @@ import { join } from 'node:path'; import values from 'lodash/values'; import isEmpty from 'lodash/isEmpty'; -import { log, handleAndLogError } from '@contentstack/cli-utilities'; +import { log, handleAndLogError, CLIProgressManager } from '@contentstack/cli-utilities'; import { PATH_CONSTANTS } from '../../constants'; import BaseClass, { ApiOptions } from './base-class'; @@ -19,6 +19,8 @@ export default class ImportTaxonomies extends BaseClass { private termsSuccessPath: string; private termsFailsPath: string; private localesFilePath: string; + private envUidMapperPath: string; + private envUidMapper: Record = {}; private isLocaleBasedStructure: boolean = false; public createdTaxonomies: Record = {}; public failedTaxonomies: Record = {}; @@ -46,8 +48,16 @@ export default class ImportTaxonomies extends BaseClass { importConfig.modules.locales.dirName, importConfig.modules.locales.fileName, ); + this.envUidMapperPath = join( + importConfig.backupDir, + PATH_CONSTANTS.MAPPER, + PATH_CONSTANTS.MAPPER_MODULES.ENVIRONMENTS, + PATH_CONSTANTS.FILES.UID_MAPPING, + ); } + // --- Lifecycle --- + /** * @method start * @returns {Promise} Promise @@ -56,7 +66,7 @@ export default class ImportTaxonomies extends BaseClass { try { log.debug('Starting taxonomies import process...', this.importConfig.context); - const [taxonomiesCount] = await this.analyzeTaxonomies(); + const [taxonomiesCount, publishJobCount] = await this.analyzeTaxonomies(); if (taxonomiesCount === 0) { log.info('No taxonomies found to import', this.importConfig.context); return; @@ -67,8 +77,12 @@ export default class ImportTaxonomies extends BaseClass { // Check if locale-based structure exists before import this.isLocaleBasedStructure = this.detectAndScanLocaleStructure(); - const progress = this.createSimpleProgress(this.currentModuleName, taxonomiesCount); - progress.updateStatus(PROCESS_STATUS[PROCESS_NAMES.TAXONOMIES_IMPORT].IMPORTING); + const progress = this.createNestedProgress(this.currentModuleName); + this.initializeTaxonomiesProgress(progress, taxonomiesCount, publishJobCount); + + progress + .startProcess(PROCESS_NAMES.TAXONOMIES_IMPORT) + .updateStatus(PROCESS_STATUS[PROCESS_NAMES.TAXONOMIES_IMPORT].IMPORTING, PROCESS_NAMES.TAXONOMIES_IMPORT); log.debug('Starting taxonomies import', this.importConfig.context); if (this.isLocaleBasedStructure) { @@ -79,6 +93,19 @@ export default class ImportTaxonomies extends BaseClass { await this.importTaxonomiesLegacy(); } + progress.completeProcess(PROCESS_NAMES.TAXONOMIES_IMPORT, true); + + if (publishJobCount > 0) { + progress + .startProcess(PROCESS_NAMES.TAXONOMIES_PUBLISH) + .updateStatus( + PROCESS_STATUS[PROCESS_NAMES.TAXONOMIES_PUBLISH].PUBLISHING, + PROCESS_NAMES.TAXONOMIES_PUBLISH, + ); + await this.processTaxonomyPublishing(); + progress.completeProcess(PROCESS_NAMES.TAXONOMIES_PUBLISH, true); + } + this.createSuccessAndFailedFile(); this.completeProgressWithMessage(); } catch (error) { @@ -87,6 +114,8 @@ export default class ImportTaxonomies extends BaseClass { } } + // --- Import --- + /** * create taxonomy and enter success & failure related data into taxonomies mapper file * @method importTaxonomies @@ -344,6 +373,191 @@ export default class ImportTaxonomies extends BaseClass { return true; } + // --- Progress --- + + /** + * Registers nested progress for taxonomy import and optional taxonomy publish when publish jobs exist. + */ + initializeTaxonomiesProgress(progress: CLIProgressManager, taxonomyCount: number, publishJobCount: number): void { + progress.addProcess(PROCESS_NAMES.TAXONOMIES_IMPORT, taxonomyCount); + if (publishJobCount > 0) { + progress.addProcess(PROCESS_NAMES.TAXONOMIES_PUBLISH, publishJobCount); + } + } + + // --- Publish --- + + /** + * Reads source env UID → destination stack env UID map produced during environments import. + */ + private readEnvUidMapperSync(): Record { + if (!fileHelper.fileExistsSync(this.envUidMapperPath)) { + log.debug(`Environment UID mapper not found at ${this.envUidMapperPath}`, this.importConfig.context); + return {}; + } + + try { + const raw = fsUtil.readFile(this.envUidMapperPath, true) as Record; + const out: Record = {}; + for (const [k, v] of Object.entries(raw || {})) { + if (v !== undefined && v !== null && String(v).trim() !== '') { + out[k] = String(v); + } + } + return out; + } catch { + log.debug('Failed to read environment UID mapper', this.importConfig.context); + return {}; + } + } + + private countPublishEligibleTaxonomies(envMapper: Record): number { + let count = 0; + for (const key of Object.keys(this.taxonomies || {})) { + const meta = this.taxonomies[key] as Record; + const taxonomyUid = meta?.uid || key; + const filePath = this.findTaxonomyFilePath(taxonomyUid); + if (!filePath) continue; + + const details = this.loadTaxonomyFile(filePath); + const tax = details?.taxonomy as Record | undefined; + if (!tax?.publish_details?.length || !tax?.locale) continue; + + const hasMapped = (tax.publish_details as any[]).some( + (p: any) => p?.environment && envMapper[String(p.environment)], + ); + if (hasMapped) count++; + } + return count; + } + + private collectTaxonomyPublishJobs(): Array<{ taxonomy: Record }> { + const jobs: Array<{ taxonomy: Record }> = []; + const seen = new Set(); + + for (const key of Object.keys(this.taxonomies || {})) { + const meta = this.taxonomies[key] as Record; + const taxonomyUid = meta?.uid || key; + if (seen.has(taxonomyUid)) continue; + + const filePath = this.findTaxonomyFilePath(taxonomyUid); + if (!filePath) continue; + + const details = this.loadTaxonomyFile(filePath); + const tax = details?.taxonomy as Record | undefined; + if (!tax?.publish_details?.length || !tax?.locale) continue; + + seen.add(taxonomyUid); + jobs.push({ taxonomy: tax }); + } + + return jobs; + } + + private loadEnvUidMapper(): void { + this.envUidMapper = this.readEnvUidMapperSync(); + if (isEmpty(this.envUidMapper)) { + log.warn( + 'Environment UID mapper is empty; taxonomy publishing is skipped. Import environments first or ensure mapper/environments/uid-mapping.json exists.', + this.importConfig.context, + ); + } + } + + async processTaxonomyPublishing(): Promise { + this.loadEnvUidMapper(); + const jobs = this.collectTaxonomyPublishJobs(); + + if (jobs.length === 0) { + log.debug('No taxonomies with publish_details to publish', this.importConfig.context); + return; + } + + log.info('Starting taxonomy publishing process', this.importConfig.context); + + const onSuccess = ({ apiData }: any) => { + const taxonomyUid = apiData?.items?.[0]?.uid; + this.progressManager?.tick( + true, + `taxonomy published: ${taxonomyUid}`, + null, + PROCESS_NAMES.TAXONOMIES_PUBLISH, + ); + log.success(`Published taxonomy '${taxonomyUid}'`, this.importConfig.context); + }; + + const onReject = ({ error, apiData }: any) => { + const taxonomyUid = apiData?.items?.[0]?.uid; + handleAndLogError( + error, + { ...this.importConfig.context, taxonomyUid }, + `Failed to publish taxonomy '${taxonomyUid}'`, + ); + this.progressManager?.tick( + false, + `taxonomy publish: ${taxonomyUid}`, + (error as Error)?.message || `Failed to publish taxonomy '${taxonomyUid}'`, + PROCESS_NAMES.TAXONOMIES_PUBLISH, + ); + }; + + await this.makeConcurrentCall( + { + apiContent: jobs as unknown as Record[], + processName: 'publish taxonomies', + apiParams: { + serializeData: this.serializePublishTaxonomies.bind(this), + reject: onReject, + resolve: onSuccess, + entity: 'publish-taxonomies', + includeParamOnCompletion: true, + }, + concurrencyLimit: this.importConfig.concurrency || this.importConfig.fetchConcurrency || 1, + }, + undefined, + false, + ); + } + + /** + * Builds taxonomy publish payload: destination env UIDs from mapper, locales from taxonomy.locale, items: [{ uid }]. + */ + serializePublishTaxonomies(apiOptions: ApiOptions): ApiOptions { + const job = apiOptions.apiData as { taxonomy?: Record }; + const taxonomy = job?.taxonomy; + + if (!taxonomy?.publish_details?.length || !taxonomy?.locale) { + apiOptions.apiData = undefined; + return apiOptions; + } + + const environments: string[] = []; + for (const pub of taxonomy.publish_details as any[]) { + const sourceEnvUid = pub?.environment; + if (!sourceEnvUid) continue; + const destUid = this.envUidMapper[String(sourceEnvUid)]; + if (destUid && !environments.includes(destUid)) { + environments.push(destUid); + } + } + + if (environments.length === 0) { + apiOptions.apiData = undefined; + return apiOptions; + } + + const locales = [String(taxonomy.locale)]; + apiOptions.apiData = { + environments, + locales, + items: [{ uid: taxonomy.uid }], + }; + + return apiOptions; + } + + // --- Mapper output --- + /** * create taxonomies success and fail in (mapper/taxonomies) * create terms success and fail in (mapper/taxonomies/terms) @@ -396,25 +610,36 @@ export default class ImportTaxonomies extends BaseClass { } } - private async analyzeTaxonomies(): Promise<[number]> { + // --- Analyze & prepare --- + + private async analyzeTaxonomies(): Promise<[number, number]> { return this.withLoadingSpinner('TAXONOMIES: Analyzing import data...', async () => { log.debug('Checking for taxonomies folder existence', this.importConfig.context); - if (fileHelper.fileExistsSync(this.taxonomiesFolderPath)) { - log.debug(`Found taxonomies folder: ${this.taxonomiesFolderPath}`, this.importConfig.context); - - this.taxonomies = fsUtil.readFile(join(this.taxonomiesFolderPath, 'taxonomies.json'), true) as Record< - string, - unknown - >; - - const taxonomyCount = Object.keys(this.taxonomies || {}).length; - log.debug(`Loaded ${taxonomyCount} taxonomy items from file`, this.importConfig.context); - return [taxonomyCount]; - } else { + if (!fileHelper.fileExistsSync(this.taxonomiesFolderPath)) { log.info(`No Taxonomies Found! - '${this.taxonomiesFolderPath}'`, this.importConfig.context); - return [0]; + return [0, 0]; } + + log.debug(`Found taxonomies folder: ${this.taxonomiesFolderPath}`, this.importConfig.context); + + this.taxonomies = fsUtil.readFile(join(this.taxonomiesFolderPath, 'taxonomies.json'), true) as Record< + string, + unknown + >; + + this.isLocaleBasedStructure = this.detectAndScanLocaleStructure(); + + const taxonomyCount = Object.keys(this.taxonomies || {}).length; + const envMapper = this.readEnvUidMapperSync(); + const publishJobCount = this.countPublishEligibleTaxonomies(envMapper); + + log.debug( + `Loaded ${taxonomyCount} taxonomy items; ${publishJobCount} eligible for publish (mapped environments).`, + this.importConfig.context, + ); + + return [taxonomyCount, publishJobCount]; }); } diff --git a/packages/contentstack-import/src/utils/constants.ts b/packages/contentstack-import/src/utils/constants.ts index c751bcfc8..80e36f7b5 100644 --- a/packages/contentstack-import/src/utils/constants.ts +++ b/packages/contentstack-import/src/utils/constants.ts @@ -57,6 +57,7 @@ export const PROCESS_NAMES = { CONTENT_TYPES_EXT_UPDATE: 'Content Types Ext Update', WEBHOOKS_IMPORT: 'Webhooks Import', TAXONOMIES_IMPORT: 'Taxonomies Import', + TAXONOMIES_PUBLISH: 'Taxonomies Publish', PERSONALIZE_PROJECTS: 'Projects', } as const; @@ -267,6 +268,10 @@ export const PROCESS_STATUS = { IMPORTING: 'Importing taxonomies...', FAILED: 'Failed to import taxonomies.', }, + [PROCESS_NAMES.TAXONOMIES_PUBLISH]: { + PUBLISHING: 'Publishing taxonomies...', + FAILED: 'Failed to publish taxonomies.', + }, [PROCESS_NAMES.PERSONALIZE_PROJECTS]: { IMPORTING: 'Importing personalization projects...', FAILED: 'Failed to import personalization projects.', diff --git a/packages/contentstack-import/test/unit/import/modules/base-class.test.ts b/packages/contentstack-import/test/unit/import/modules/base-class.test.ts index 43ad5ac72..bc8ea9219 100644 --- a/packages/contentstack-import/test/unit/import/modules/base-class.test.ts +++ b/packages/contentstack-import/test/unit/import/modules/base-class.test.ts @@ -74,6 +74,7 @@ describe('BaseClass', () => { create: sinon.stub().resolves({ uid: 'term-123' }), }), import: sinon.stub().resolves({ uid: 'import-123' }), + publish: sinon.stub().resolves({ notice: 'queued' }), }), globalField: sinon.stub().returns({ create: sinon.stub().resolves({ uid: 'gf-123' }), @@ -858,6 +859,59 @@ describe('BaseClass', () => { expect(result).to.be.undefined; expect(mockStackClient.taxonomy().import.called).to.be.false; }); + + it('should handle publish-taxonomies with api version 3.2', async () => { + const payload = { + environments: ['blt-env-dest'], + locales: ['en-us'], + items: [{ uid: 'tax-uid' }], + }; + mockApiOptions.entity = 'publish-taxonomies' as any; + mockApiOptions.apiData = payload; + + await testClass.makeAPICall(mockApiOptions); + + expect(mockStackClient.taxonomy().publish.calledOnce).to.be.true; + expect(mockStackClient.taxonomy().publish.firstCall.args[0]).to.deep.equal(payload); + expect(mockStackClient.taxonomy().publish.firstCall.args[1]).to.equal('3.2'); + expect(mockStackClient.taxonomy().publish.firstCall.args[2]).to.deep.equal({}); + expect(mockApiOptions.resolve.calledOnce).to.be.true; + }); + + it('should pass branch to publish-taxonomies when branchName is set', async () => { + const payload = { + environments: ['e1'], + locales: ['en-us'], + items: [{ uid: 't1' }], + }; + mockImportConfig.branchName = 'main-branch'; + testClass = new TestBaseClass({ + importConfig: mockImportConfig, + stackAPIClient: mockStackClient, + }); + + mockApiOptions.entity = 'publish-taxonomies' as any; + mockApiOptions.apiData = payload; + + await testClass.makeAPICall(mockApiOptions); + + expect(mockStackClient.taxonomy().publish.firstCall.args[2]).to.deep.equal({ branch: 'main-branch' }); + delete mockImportConfig.branchName; + }); + + it('should skip publish-taxonomies when environments empty', async () => { + mockApiOptions.entity = 'publish-taxonomies' as any; + mockApiOptions.apiData = { + environments: [], + locales: ['en-us'], + items: [{ uid: 't' }], + }; + + await testClass.makeAPICall(mockApiOptions); + + expect(mockStackClient.taxonomy().publish.called).to.be.false; + expect(mockApiOptions.resolve.called).to.be.false; + }); }); }); diff --git a/packages/contentstack-import/test/unit/import/modules/taxonomies.test.ts b/packages/contentstack-import/test/unit/import/modules/taxonomies.test.ts index 4ef453fd9..6744a4f85 100644 --- a/packages/contentstack-import/test/unit/import/modules/taxonomies.test.ts +++ b/packages/contentstack-import/test/unit/import/modules/taxonomies.test.ts @@ -5,6 +5,16 @@ import values from 'lodash/values'; import ImportTaxonomies from '../../../../src/import/modules/taxonomies'; import { fsUtil, fileHelper } from '../../../../src/utils'; +function nestedProgressMock(sb: sinon.SinonSandbox) { + return { + addProcess: sb.stub().returnsThis(), + startProcess: sb.stub().returnsThis(), + updateStatus: sb.stub().returnsThis(), + completeProcess: sb.stub().returnsThis(), + getFailureCount: sb.stub().returns(0), + }; +} + describe('ImportTaxonomies', () => { let importTaxonomies: ImportTaxonomies; let mockStackClient: any; @@ -67,11 +77,8 @@ describe('ImportTaxonomies', () => { sandbox.stub(importTaxonomies as any, 'withLoadingSpinner').callsFake(async (msg: string, fn: () => Promise) => { return await fn(); }); - sandbox.stub(importTaxonomies as any, 'analyzeTaxonomies').resolves([1]); - const mockProgress = { - updateStatus: sandbox.stub() - }; - sandbox.stub(importTaxonomies as any, 'createSimpleProgress').returns(mockProgress); + sandbox.stub(importTaxonomies as any, 'analyzeTaxonomies').resolves([1, 0]); + sandbox.stub(importTaxonomies as any, 'createNestedProgress').returns(nestedProgressMock(sandbox)); sandbox.stub(importTaxonomies as any, 'prepareMapperDirectories').resolves(); sandbox.stub(importTaxonomies as any, 'createSuccessAndFailedFile').resolves(); sandbox.stub(importTaxonomies as any, 'completeProgress').resolves(); @@ -104,6 +111,9 @@ describe('ImportTaxonomies', () => { expect((importTaxonomies as any).localesFilePath).to.equal( join(testBackupDir, 'locales', 'locales.json'), ); + expect((importTaxonomies as any).envUidMapperPath).to.equal( + join(testBackupDir, 'mapper', 'environments', 'uid-mapping.json'), + ); }); it('should set context module to taxonomies', () => { @@ -135,9 +145,7 @@ describe('ImportTaxonomies', () => { sandbox.stub(importTaxonomies as any, 'withLoadingSpinner').callsFake(async (msg: string, fn: () => Promise) => { return await fn(); }); - sandbox.stub(importTaxonomies as any, 'createSimpleProgress').returns({ - updateStatus: sinon.stub() - }); + sandbox.stub(importTaxonomies as any, 'createNestedProgress').returns(nestedProgressMock(sandbox)); const prepareMapperDirectoriesStub = sandbox.stub(importTaxonomies as any, 'prepareMapperDirectories').resolves(); const importTaxonomiesStub = sandbox.stub(importTaxonomies as any, 'importTaxonomies').resolves(); sandbox.stub(importTaxonomies as any, 'createSuccessAndFailedFile').resolves(); @@ -189,7 +197,7 @@ describe('ImportTaxonomies', () => { sandbox.stub(importTaxonomies as any, 'withLoadingSpinner').callsFake(async (msg: string, fn: () => Promise) => { return await fn(); }); - const analyzeTaxonomiesStub = sandbox.stub(importTaxonomies as any, 'analyzeTaxonomies').resolves([0]); + const analyzeTaxonomiesStub = sandbox.stub(importTaxonomies as any, 'analyzeTaxonomies').resolves([0, 0]); sandbox.stub(importTaxonomies as any, 'completeProgress').resolves(); await importTaxonomies.start(); @@ -211,10 +219,8 @@ describe('ImportTaxonomies', () => { sandbox.stub(importTaxonomies as any, 'withLoadingSpinner').callsFake(async (msg: string, fn: () => Promise) => { return await fn(); }); - sandbox.stub(importTaxonomies as any, 'analyzeTaxonomies').resolves([0]); // 0 taxonomies - sandbox.stub(importTaxonomies as any, 'createSimpleProgress').returns({ - updateStatus: sinon.stub() - }); + sandbox.stub(importTaxonomies as any, 'analyzeTaxonomies').resolves([0, 0]); // 0 taxonomies + sandbox.stub(importTaxonomies as any, 'createNestedProgress').returns(nestedProgressMock(sandbox)); sandbox.stub(importTaxonomies as any, 'prepareMapperDirectories').resolves(); sandbox.stub(importTaxonomies as any, 'importTaxonomies').resolves(); sandbox.stub(importTaxonomies as any, 'createSuccessAndFailedFile').resolves(); @@ -241,10 +247,8 @@ describe('ImportTaxonomies', () => { sandbox.stub(importTaxonomies as any, 'withLoadingSpinner').callsFake(async (msg: string, fn: () => Promise) => { return await fn(); }); - sandbox.stub(importTaxonomies as any, 'analyzeTaxonomies').resolves([1]); // 1 taxonomy - sandbox.stub(importTaxonomies as any, 'createSimpleProgress').returns({ - updateStatus: sinon.stub() - }); + sandbox.stub(importTaxonomies as any, 'analyzeTaxonomies').resolves([1, 0]); // 1 taxonomy + sandbox.stub(importTaxonomies as any, 'createNestedProgress').returns(nestedProgressMock(sandbox)); sandbox.stub(importTaxonomies as any, 'prepareMapperDirectories').resolves(); sandbox.stub(importTaxonomies as any, 'importTaxonomies').callsFake(async () => { (importTaxonomies as any).createdTaxonomies = { 'taxonomy_1': { uid: 'taxonomy_1' } }; @@ -392,6 +396,103 @@ describe('ImportTaxonomies', () => { }); }); + describe('serializePublishTaxonomies', () => { + it('maps source environment UIDs to destination UIDs and sets items to uid only', () => { + (importTaxonomies as any).envUidMapper = { bltSrc: 'bltDest' }; + const apiOptions: any = { + entity: 'publish-taxonomies', + apiData: { + taxonomy: { + uid: 'tax1', + locale: 'en-us', + publish_details: [{ environment: 'bltSrc', time: '', user: '' }], + }, + }, + resolve: sandbox.stub(), + reject: sandbox.stub(), + }; + + const result = (importTaxonomies as any).serializePublishTaxonomies(apiOptions); + + expect(result.apiData).to.deep.equal({ + environments: ['bltDest'], + locales: ['en-us'], + items: [{ uid: 'tax1' }], + }); + }); + + it('dedupes multiple publish_details environments', () => { + (importTaxonomies as any).envUidMapper = { e1: 'd1', e2: 'd2' }; + const apiOptions: any = { + entity: 'publish-taxonomies', + apiData: { + taxonomy: { + uid: 'tax2', + locale: 'fr-fr', + publish_details: [{ environment: 'e1' }, { environment: 'e2' }, { environment: 'e1' }], + }, + }, + resolve: sandbox.stub(), + reject: sandbox.stub(), + }; + + const result = (importTaxonomies as any).serializePublishTaxonomies(apiOptions); + + expect(result.apiData.environments).to.deep.equal(['d1', 'd2']); + expect(result.apiData.locales).to.deep.equal(['fr-fr']); + expect(result.apiData.items).to.deep.equal([{ uid: 'tax2' }]); + }); + + it('returns undefined when publish_details empty', () => { + (importTaxonomies as any).envUidMapper = { x: 'y' }; + const apiOptions: any = { + entity: 'publish-taxonomies', + apiData: { + taxonomy: { uid: 't', locale: 'en-us', publish_details: [] }, + }, + resolve: sandbox.stub(), + reject: sandbox.stub(), + }; + + expect((importTaxonomies as any).serializePublishTaxonomies(apiOptions).apiData).to.be.undefined; + }); + + it('returns undefined when no env mapping resolves', () => { + (importTaxonomies as any).envUidMapper = {}; + const apiOptions: any = { + entity: 'publish-taxonomies', + apiData: { + taxonomy: { + uid: 'tax1', + locale: 'en-us', + publish_details: [{ environment: 'missing' }], + }, + }, + resolve: sandbox.stub(), + reject: sandbox.stub(), + }; + + expect((importTaxonomies as any).serializePublishTaxonomies(apiOptions).apiData).to.be.undefined; + }); + + it('returns undefined when taxonomy.locale missing', () => { + (importTaxonomies as any).envUidMapper = { e: 'd' }; + const apiOptions: any = { + entity: 'publish-taxonomies', + apiData: { + taxonomy: { + uid: 'tax1', + publish_details: [{ environment: 'e' }], + }, + }, + resolve: sandbox.stub(), + reject: sandbox.stub(), + }; + + expect((importTaxonomies as any).serializePublishTaxonomies(apiOptions).apiData).to.be.undefined; + }); + }); + describe('createSuccessAndFailedFile', () => { it('should write all four files when data exists', () => { (importTaxonomies as any).createSuccessAndFailedFile.restore(); @@ -1123,10 +1224,8 @@ describe('ImportTaxonomies', () => { sandbox.stub(importTaxonomies as any, 'withLoadingSpinner').callsFake(async (msg: string, fn: () => Promise) => { return await fn(); }); - sandbox.stub(importTaxonomies as any, 'analyzeTaxonomies').resolves([1]); - sandbox.stub(importTaxonomies as any, 'createSimpleProgress').returns({ - updateStatus: sinon.stub() - }); + sandbox.stub(importTaxonomies as any, 'analyzeTaxonomies').resolves([1, 0]); + sandbox.stub(importTaxonomies as any, 'createNestedProgress').returns(nestedProgressMock(sandbox)); // Make prepareMapperDirectories reject with the error sandbox.stub(importTaxonomies as any, 'prepareMapperDirectories').rejects(new Error('Directory creation failed')); From 20a3e9c9f3a5bf3cb35eebd7b9feb3178bb820f9 Mon Sep 17 00:00:00 2001 From: raj pandey Date: Mon, 11 May 2026 14:48:18 +0530 Subject: [PATCH 2/2] refactored taxonomy publish import --- .../src/import/modules/taxonomies.ts | 91 +++----------- .../contentstack-import/src/utils/index.ts | 5 + .../src/utils/taxonomy-publish-utils.ts | 78 ++++++++++++ .../unit/import/modules/taxonomies.test.ts | 97 --------------- .../unit/utils/taxonomy-publish-utils.test.ts | 113 ++++++++++++++++++ 5 files changed, 211 insertions(+), 173 deletions(-) create mode 100644 packages/contentstack-import/src/utils/taxonomy-publish-utils.ts create mode 100644 packages/contentstack-import/test/unit/utils/taxonomy-publish-utils.test.ts diff --git a/packages/contentstack-import/src/import/modules/taxonomies.ts b/packages/contentstack-import/src/import/modules/taxonomies.ts index c57462a6c..402905647 100644 --- a/packages/contentstack-import/src/import/modules/taxonomies.ts +++ b/packages/contentstack-import/src/import/modules/taxonomies.ts @@ -5,7 +5,17 @@ import { log, handleAndLogError, CLIProgressManager } from '@contentstack/cli-ut import { PATH_CONSTANTS } from '../../constants'; import BaseClass, { ApiOptions } from './base-class'; -import { fsUtil, fileHelper, MODULE_CONTEXTS, MODULE_NAMES, PROCESS_STATUS, PROCESS_NAMES } from '../../utils'; +import { + fsUtil, + fileHelper, + MODULE_CONTEXTS, + MODULE_NAMES, + PROCESS_STATUS, + PROCESS_NAMES, + readEnvUidMapperSync, + warnIfEnvMapperEmpty, + serializePublishTaxonomies, +} from '../../utils'; import { ModuleClassParams, TaxonomiesConfig } from '../../types'; export default class ImportTaxonomies extends BaseClass { @@ -20,7 +30,6 @@ export default class ImportTaxonomies extends BaseClass { private termsFailsPath: string; private localesFilePath: string; private envUidMapperPath: string; - private envUidMapper: Record = {}; private isLocaleBasedStructure: boolean = false; public createdTaxonomies: Record = {}; public failedTaxonomies: Record = {}; @@ -387,30 +396,6 @@ export default class ImportTaxonomies extends BaseClass { // --- Publish --- - /** - * Reads source env UID → destination stack env UID map produced during environments import. - */ - private readEnvUidMapperSync(): Record { - if (!fileHelper.fileExistsSync(this.envUidMapperPath)) { - log.debug(`Environment UID mapper not found at ${this.envUidMapperPath}`, this.importConfig.context); - return {}; - } - - try { - const raw = fsUtil.readFile(this.envUidMapperPath, true) as Record; - const out: Record = {}; - for (const [k, v] of Object.entries(raw || {})) { - if (v !== undefined && v !== null && String(v).trim() !== '') { - out[k] = String(v); - } - } - return out; - } catch { - log.debug('Failed to read environment UID mapper', this.importConfig.context); - return {}; - } - } - private countPublishEligibleTaxonomies(envMapper: Record): number { let count = 0; for (const key of Object.keys(this.taxonomies || {})) { @@ -454,18 +439,9 @@ export default class ImportTaxonomies extends BaseClass { return jobs; } - private loadEnvUidMapper(): void { - this.envUidMapper = this.readEnvUidMapperSync(); - if (isEmpty(this.envUidMapper)) { - log.warn( - 'Environment UID mapper is empty; taxonomy publishing is skipped. Import environments first or ensure mapper/environments/uid-mapping.json exists.', - this.importConfig.context, - ); - } - } - async processTaxonomyPublishing(): Promise { - this.loadEnvUidMapper(); + const envUidMapper = readEnvUidMapperSync(this.envUidMapperPath, this.importConfig.context); + warnIfEnvMapperEmpty(envUidMapper, this.importConfig.context); const jobs = this.collectTaxonomyPublishJobs(); if (jobs.length === 0) { @@ -506,7 +482,7 @@ export default class ImportTaxonomies extends BaseClass { apiContent: jobs as unknown as Record[], processName: 'publish taxonomies', apiParams: { - serializeData: this.serializePublishTaxonomies.bind(this), + serializeData: (opts: ApiOptions) => serializePublishTaxonomies(opts, envUidMapper), reject: onReject, resolve: onSuccess, entity: 'publish-taxonomies', @@ -519,43 +495,6 @@ export default class ImportTaxonomies extends BaseClass { ); } - /** - * Builds taxonomy publish payload: destination env UIDs from mapper, locales from taxonomy.locale, items: [{ uid }]. - */ - serializePublishTaxonomies(apiOptions: ApiOptions): ApiOptions { - const job = apiOptions.apiData as { taxonomy?: Record }; - const taxonomy = job?.taxonomy; - - if (!taxonomy?.publish_details?.length || !taxonomy?.locale) { - apiOptions.apiData = undefined; - return apiOptions; - } - - const environments: string[] = []; - for (const pub of taxonomy.publish_details as any[]) { - const sourceEnvUid = pub?.environment; - if (!sourceEnvUid) continue; - const destUid = this.envUidMapper[String(sourceEnvUid)]; - if (destUid && !environments.includes(destUid)) { - environments.push(destUid); - } - } - - if (environments.length === 0) { - apiOptions.apiData = undefined; - return apiOptions; - } - - const locales = [String(taxonomy.locale)]; - apiOptions.apiData = { - environments, - locales, - items: [{ uid: taxonomy.uid }], - }; - - return apiOptions; - } - // --- Mapper output --- /** @@ -631,7 +570,7 @@ export default class ImportTaxonomies extends BaseClass { this.isLocaleBasedStructure = this.detectAndScanLocaleStructure(); const taxonomyCount = Object.keys(this.taxonomies || {}).length; - const envMapper = this.readEnvUidMapperSync(); + const envMapper = readEnvUidMapperSync(this.envUidMapperPath, this.importConfig.context); const publishJobCount = this.countPublishEligibleTaxonomies(envMapper); log.debug( diff --git a/packages/contentstack-import/src/utils/index.ts b/packages/contentstack-import/src/utils/index.ts index 5d64eb515..d32128232 100644 --- a/packages/contentstack-import/src/utils/index.ts +++ b/packages/contentstack-import/src/utils/index.ts @@ -31,4 +31,9 @@ export { } from './entries-helper'; export * from './common-helper'; export { lookUpTaxonomy, lookUpTerms } from './taxonomies-helper'; +export { + readEnvUidMapperSync, + warnIfEnvMapperEmpty, + serializePublishTaxonomies, +} from './taxonomy-publish-utils'; export { MODULE_CONTEXTS, MODULE_NAMES, PROCESS_NAMES, PROCESS_STATUS } from './constants'; diff --git a/packages/contentstack-import/src/utils/taxonomy-publish-utils.ts b/packages/contentstack-import/src/utils/taxonomy-publish-utils.ts new file mode 100644 index 000000000..4aa48ddca --- /dev/null +++ b/packages/contentstack-import/src/utils/taxonomy-publish-utils.ts @@ -0,0 +1,78 @@ +import isEmpty from 'lodash/isEmpty'; +import { log } from '@contentstack/cli-utilities'; +import { ApiOptions } from '../import/modules/base-class'; +import type { Context } from '../types'; +import { fsUtil, fileExistsSync } from './file-helper'; + +/** + * Reads source env UID → destination stack env UID map produced during environments import. + */ +export function readEnvUidMapperSync(envUidMapperPath: string, context: Context): Record { + if (!fileExistsSync(envUidMapperPath)) { + log.debug(`Environment UID mapper not found at ${envUidMapperPath}`, context); + return {}; + } + + try { + const raw = fsUtil.readFile(envUidMapperPath, true) as Record; + const out: Record = {}; + for (const [k, v] of Object.entries(raw || {})) { + if (v !== undefined && v !== null && String(v).trim() !== '') { + out[k] = String(v); + } + } + return out; + } catch { + log.debug('Failed to read environment UID mapper', context); + return {}; + } +} + +export function warnIfEnvMapperEmpty(envUidMapper: Record, context: Context): void { + if (isEmpty(envUidMapper)) { + log.warn( + 'Environment UID mapper is empty; taxonomy publishing is skipped. Import environments first or ensure mapper/environments/uid-mapping.json exists.', + context, + ); + } +} + +/** + * Builds taxonomy publish payload: destination env UIDs from mapper, locales from taxonomy.locale, items: [{ uid }]. + */ +export function serializePublishTaxonomies( + apiOptions: ApiOptions, + envUidMapper: Record, +): ApiOptions { + const job = apiOptions.apiData as { taxonomy?: Record }; + const taxonomy = job?.taxonomy; + + if (!taxonomy?.publish_details?.length || !taxonomy?.locale) { + apiOptions.apiData = undefined; + return apiOptions; + } + + const environments: string[] = []; + for (const pub of taxonomy.publish_details as any[]) { + const sourceEnvUid = pub?.environment; + if (!sourceEnvUid) continue; + const destUid = envUidMapper[String(sourceEnvUid)]; + if (destUid && !environments.includes(destUid)) { + environments.push(destUid); + } + } + + if (environments.length === 0) { + apiOptions.apiData = undefined; + return apiOptions; + } + + const locales = [String(taxonomy.locale)]; + apiOptions.apiData = { + environments, + locales, + items: [{ uid: taxonomy.uid }], + }; + + return apiOptions; +} diff --git a/packages/contentstack-import/test/unit/import/modules/taxonomies.test.ts b/packages/contentstack-import/test/unit/import/modules/taxonomies.test.ts index 6744a4f85..5dd22cae4 100644 --- a/packages/contentstack-import/test/unit/import/modules/taxonomies.test.ts +++ b/packages/contentstack-import/test/unit/import/modules/taxonomies.test.ts @@ -396,103 +396,6 @@ describe('ImportTaxonomies', () => { }); }); - describe('serializePublishTaxonomies', () => { - it('maps source environment UIDs to destination UIDs and sets items to uid only', () => { - (importTaxonomies as any).envUidMapper = { bltSrc: 'bltDest' }; - const apiOptions: any = { - entity: 'publish-taxonomies', - apiData: { - taxonomy: { - uid: 'tax1', - locale: 'en-us', - publish_details: [{ environment: 'bltSrc', time: '', user: '' }], - }, - }, - resolve: sandbox.stub(), - reject: sandbox.stub(), - }; - - const result = (importTaxonomies as any).serializePublishTaxonomies(apiOptions); - - expect(result.apiData).to.deep.equal({ - environments: ['bltDest'], - locales: ['en-us'], - items: [{ uid: 'tax1' }], - }); - }); - - it('dedupes multiple publish_details environments', () => { - (importTaxonomies as any).envUidMapper = { e1: 'd1', e2: 'd2' }; - const apiOptions: any = { - entity: 'publish-taxonomies', - apiData: { - taxonomy: { - uid: 'tax2', - locale: 'fr-fr', - publish_details: [{ environment: 'e1' }, { environment: 'e2' }, { environment: 'e1' }], - }, - }, - resolve: sandbox.stub(), - reject: sandbox.stub(), - }; - - const result = (importTaxonomies as any).serializePublishTaxonomies(apiOptions); - - expect(result.apiData.environments).to.deep.equal(['d1', 'd2']); - expect(result.apiData.locales).to.deep.equal(['fr-fr']); - expect(result.apiData.items).to.deep.equal([{ uid: 'tax2' }]); - }); - - it('returns undefined when publish_details empty', () => { - (importTaxonomies as any).envUidMapper = { x: 'y' }; - const apiOptions: any = { - entity: 'publish-taxonomies', - apiData: { - taxonomy: { uid: 't', locale: 'en-us', publish_details: [] }, - }, - resolve: sandbox.stub(), - reject: sandbox.stub(), - }; - - expect((importTaxonomies as any).serializePublishTaxonomies(apiOptions).apiData).to.be.undefined; - }); - - it('returns undefined when no env mapping resolves', () => { - (importTaxonomies as any).envUidMapper = {}; - const apiOptions: any = { - entity: 'publish-taxonomies', - apiData: { - taxonomy: { - uid: 'tax1', - locale: 'en-us', - publish_details: [{ environment: 'missing' }], - }, - }, - resolve: sandbox.stub(), - reject: sandbox.stub(), - }; - - expect((importTaxonomies as any).serializePublishTaxonomies(apiOptions).apiData).to.be.undefined; - }); - - it('returns undefined when taxonomy.locale missing', () => { - (importTaxonomies as any).envUidMapper = { e: 'd' }; - const apiOptions: any = { - entity: 'publish-taxonomies', - apiData: { - taxonomy: { - uid: 'tax1', - publish_details: [{ environment: 'e' }], - }, - }, - resolve: sandbox.stub(), - reject: sandbox.stub(), - }; - - expect((importTaxonomies as any).serializePublishTaxonomies(apiOptions).apiData).to.be.undefined; - }); - }); - describe('createSuccessAndFailedFile', () => { it('should write all four files when data exists', () => { (importTaxonomies as any).createSuccessAndFailedFile.restore(); diff --git a/packages/contentstack-import/test/unit/utils/taxonomy-publish-utils.test.ts b/packages/contentstack-import/test/unit/utils/taxonomy-publish-utils.test.ts new file mode 100644 index 000000000..de834c865 --- /dev/null +++ b/packages/contentstack-import/test/unit/utils/taxonomy-publish-utils.test.ts @@ -0,0 +1,113 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { serializePublishTaxonomies } from '../../../src/utils/taxonomy-publish-utils'; +import type { ApiOptions } from '../../../src/import/modules/base-class'; + +describe('taxonomy-publish-utils', () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('serializePublishTaxonomies', () => { + it('maps source environment UIDs to destination UIDs and sets items to uid only', () => { + const envUidMapper = { bltSrc: 'bltDest' }; + const apiOptions: ApiOptions = { + entity: 'publish-taxonomies', + apiData: { + taxonomy: { + uid: 'tax1', + locale: 'en-us', + publish_details: [{ environment: 'bltSrc', time: '', user: '' }], + }, + }, + resolve: sandbox.stub(), + reject: sandbox.stub(), + }; + + const result = serializePublishTaxonomies(apiOptions, envUidMapper); + + expect(result.apiData).to.deep.equal({ + environments: ['bltDest'], + locales: ['en-us'], + items: [{ uid: 'tax1' }], + }); + }); + + it('dedupes multiple publish_details environments', () => { + const envUidMapper = { e1: 'd1', e2: 'd2' }; + const apiOptions: ApiOptions = { + entity: 'publish-taxonomies', + apiData: { + taxonomy: { + uid: 'tax2', + locale: 'fr-fr', + publish_details: [{ environment: 'e1' }, { environment: 'e2' }, { environment: 'e1' }], + }, + }, + resolve: sandbox.stub(), + reject: sandbox.stub(), + }; + + const result = serializePublishTaxonomies(apiOptions, envUidMapper); + + expect((result.apiData as any).environments).to.deep.equal(['d1', 'd2']); + expect((result.apiData as any).locales).to.deep.equal(['fr-fr']); + expect((result.apiData as any).items).to.deep.equal([{ uid: 'tax2' }]); + }); + + it('returns undefined when publish_details empty', () => { + const envUidMapper = { x: 'y' }; + const apiOptions: ApiOptions = { + entity: 'publish-taxonomies', + apiData: { + taxonomy: { uid: 't', locale: 'en-us', publish_details: [] }, + }, + resolve: sandbox.stub(), + reject: sandbox.stub(), + }; + + expect(serializePublishTaxonomies(apiOptions, envUidMapper).apiData).to.be.undefined; + }); + + it('returns undefined when no env mapping resolves', () => { + const envUidMapper = {}; + const apiOptions: ApiOptions = { + entity: 'publish-taxonomies', + apiData: { + taxonomy: { + uid: 'tax1', + locale: 'en-us', + publish_details: [{ environment: 'missing' }], + }, + }, + resolve: sandbox.stub(), + reject: sandbox.stub(), + }; + + expect(serializePublishTaxonomies(apiOptions, envUidMapper).apiData).to.be.undefined; + }); + + it('returns undefined when taxonomy.locale missing', () => { + const envUidMapper = { e: 'd' }; + const apiOptions: ApiOptions = { + entity: 'publish-taxonomies', + apiData: { + taxonomy: { + uid: 'tax1', + publish_details: [{ environment: 'e' }], + }, + }, + resolve: sandbox.stub(), + reject: sandbox.stub(), + }; + + expect(serializePublishTaxonomies(apiOptions, envUidMapper).apiData).to.be.undefined; + }); + }); +});