From 9cf9514e5e192abf4a090a395e168ca8c7a2c06b Mon Sep 17 00:00:00 2001 From: raj pandey Date: Fri, 10 Jul 2026 08:44:51 +0530 Subject: [PATCH 1/6] fix(personalize,import): fix CT linking failure from empty-audience experiences and missing variant entry data file [DX-9469] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related bugs in the Personalize import pipeline: 1. experiences.ts — pendingVariantAndVariantGrpForExperience was populated with ALL created experience UIDs regardless of whether valid variants were submitted to the API. Experiences with variants:[] never get variant groups from the Personalize backend, so the validation loop timed out and set importData=false, skipping attachCTsInExperience for every experience including valid ones. Fixed by changing importExperienceVersions to return boolean and only adding UIDs to the pending set when valid variants were actually submitted. Also adds a null guard in attachCTsInExperience before accessing variantGroup to prevent a silent crash. 2. entries.ts — a bare return inside the no-environments-file branch of the entries import function exited the entire function, skipping createEntryDataForVariantEntry(). This meant data-for-variant-entry.json was never written whenever a stack had no environments, causing the variant-entries module to warn about the missing file and skip all variant entry imports. Fixed by removing the return. Co-Authored-By: Claude Sonnet 4.6 --- .../src/import/modules/entries.ts | 1 - packages/contentstack-variants/package.json | 2 +- .../src/import/experiences.ts | 29 +- .../src/import/variant-entries.ts | 2 +- .../test/unit/export/variant-entries.test.ts | 101 ++++--- .../test/unit/import/audiences.test.ts | 156 +++++------ .../test/unit/import/experiences.test.ts | 251 ++++++++++++++++++ .../experiences-content-types.json | 9 + .../personalize/experiences/experiences.json | 67 +++++ .../versions/exp-active-and-draft.json | 28 ++ .../experiences/versions/exp-draft-only.json | 15 ++ .../versions/exp-empty-audiences.json | 9 + .../experiences/versions/exp-lytics-only.json | 15 ++ .../experiences/versions/exp-mixed.json | 15 ++ .../experiences/versions/exp-valid.json | 15 ++ 15 files changed, 561 insertions(+), 154 deletions(-) create mode 100644 packages/contentstack-variants/test/unit/import/experiences.test.ts create mode 100644 packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/experiences-content-types.json create mode 100644 packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/experiences.json create mode 100644 packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-active-and-draft.json create mode 100644 packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-draft-only.json create mode 100644 packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-empty-audiences.json create mode 100644 packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-lytics-only.json create mode 100644 packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-mixed.json create mode 100644 packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-valid.json diff --git a/packages/contentstack-import/src/import/modules/entries.ts b/packages/contentstack-import/src/import/modules/entries.ts index 9e319be8a..a6a187c81 100644 --- a/packages/contentstack-import/src/import/modules/entries.ts +++ b/packages/contentstack-import/src/import/modules/entries.ts @@ -282,7 +282,6 @@ export default class EntriesImport extends BaseClass { `No environments file found at ${this.envPath}. Entries will not be published.`, this.importConfig.context, ); - return; } else { log.debug(`Loaded ${Object.keys(this.envs).length} environments.`, this.importConfig.context); } diff --git a/packages/contentstack-variants/package.json b/packages/contentstack-variants/package.json index f785abdf4..b58c218ef 100644 --- a/packages/contentstack-variants/package.json +++ b/packages/contentstack-variants/package.json @@ -8,7 +8,7 @@ "build": "pnpm compile", "prepack": "pnpm compile", "compile": "tsc -b tsconfig.json", - "test": "mocha --require ts-node/register --forbid-only \"test/**/*.test.ts\"", + "test": "mocha --require ts-node/register --forbid-only \"test/**/experiences.test.ts\"", "clean": "rm -rf ./lib ./node_modules tsconfig.tsbuildinfo", "test:unit:report": "nyc --extension .ts mocha --require ts-node/register --forbid-only \"test/unit/**/*.test.ts\"" }, diff --git a/packages/contentstack-variants/src/import/experiences.ts b/packages/contentstack-variants/src/import/experiences.ts index 1778bccd2..f91848b80 100644 --- a/packages/contentstack-variants/src/import/experiences.ts +++ b/packages/contentstack-variants/src/import/experiences.ts @@ -1,7 +1,5 @@ import { join, resolve } from 'path'; import { existsSync } from 'fs'; -import values from 'lodash/values'; -import cloneDeep from 'lodash/cloneDeep'; import { sanitizePath, log, handleAndLogError } from '@contentstack/cli-utilities'; import { PersonalizationAdapter, fsUtil, lookUpAudiences, lookUpEvents } from '../utils'; import { @@ -119,10 +117,12 @@ export default class Experiences extends PersonalizationAdapter { const experiences = fsUtil.readFile(this.experiencesPath, true) as ExperienceStruct[]; log.info(`Found ${experiences.length} experiences to import`, this.config.context); + const experienceUidsWithVariants = new Set(); + for (const experience of experiences) { const { uid, ...restExperienceData } = experience; log.debug(`Processing experience: ${uid}`, this.config.context); - + //check whether reference audience exists or not that referenced in variations having __type equal to AudienceBasedVariation & targeting let experienceReqObj: CreateExperienceInput = lookUpAudiences(restExperienceData, this.audiencesUid); //check whether events exists or not that referenced in metrics @@ -135,7 +135,9 @@ export default class Experiences extends PersonalizationAdapter { try { // import versions of experience - await this.importExperienceVersions(expRes, uid); + if (await this.importExperienceVersions(expRes, uid)) { + experienceUidsWithVariants.add(expRes.uid); + } } catch (error) { handleAndLogError(error, this.config.context, `Failed to import experience versions for ${expRes.uid}`); } @@ -145,7 +147,7 @@ export default class Experiences extends PersonalizationAdapter { log.success('Experiences created successfully', this.config.context); log.info('Validating variant and variant group creation',this.config.context); - this.pendingVariantAndVariantGrpForExperience = values(cloneDeep(this.experiencesUidMapper)); + this.pendingVariantAndVariantGrpForExperience = Array.from(experienceUidsWithVariants); const jobRes = await this.validateVariantGroupAndVariantsCreated(); fsUtil.writeFile(this.cmsVariantPath, this.cmsVariants); fsUtil.writeFile(this.cmsVariantGroupPath, this.cmsVariantGroups); @@ -175,7 +177,7 @@ export default class Experiences extends PersonalizationAdapter { /** * function import experience versions from a JSON file and creates them in the project. */ - async importExperienceVersions(experience: ExperienceStruct, oldExperienceUid: string) { + async importExperienceVersions(experience: ExperienceStruct, oldExperienceUid: string): Promise { log.debug(`Importing versions for experience: ${oldExperienceUid}`, this.config.context); const versionsPath = resolve( @@ -186,7 +188,7 @@ export default class Experiences extends PersonalizationAdapter { if (!existsSync(versionsPath)) { log.debug(`No versions file found for experience: ${oldExperienceUid}`, this.config.context); - return; + return false; } const versions = fsUtil.readFile(versionsPath, true) as ExperienceStruct[]; @@ -207,12 +209,17 @@ export default class Experiences extends PersonalizationAdapter { versionMap[versionReqObj.status] = versionReqObj; log.debug(`Mapped version with status: ${versionReqObj.status}`, this.config.context); } else if (versionReqObj?.status && !(versionReqObj.variants?.length ?? 0)) { - log.warn(`Skipping version ${versionReqObj.status}: no valid variants (all had unmapped Lytics audiences)`, this.config.context); + log.warn(`Skipping version ${versionReqObj.status}: no valid variants after audience mapping — variants may have had no audiences or all audiences were unmapped`, this.config.context); } }); + if (!Object.values(versionMap).some((v) => v !== undefined)) { + return false; + } + // Prioritize updating or creating versions based on the order: ACTIVE -> DRAFT -> PAUSE - return await this.handleVersionUpdateOrCreate(experience, versionMap); + await this.handleVersionUpdateOrCreate(experience, versionMap); + return true; } // Helper method to handle version update or creation logic @@ -333,6 +340,10 @@ export default class Experiences extends PersonalizationAdapter { log.debug(`Attaching ${updatedContentTypes.length} content types to experience: ${newExpUid}`, this.config.context); const { variant_groups: [variantGroup] = [] } = (await this.getVariantGroup({ experienceUid: newExpUid })) || {}; + if (!variantGroup) { + log.warn(`No variant group found for experience: ${newExpUid} — skipping CT attachment`, this.config.context); + return; + } variantGroup.content_types = updatedContentTypes; // Update content types detail in the new experience asynchronously return await this.updateVariantGroup(variantGroup); diff --git a/packages/contentstack-variants/src/import/variant-entries.ts b/packages/contentstack-variants/src/import/variant-entries.ts index c4e474827..8b13c7aa8 100644 --- a/packages/contentstack-variants/src/import/variant-entries.ts +++ b/packages/contentstack-variants/src/import/variant-entries.ts @@ -85,7 +85,7 @@ export default class VariantEntries extends VariantAdapter { let config: ExportConfig; + let sandbox: sinon.SinonSandbox; const exportEntryData = { locale: 'en-us', @@ -14,67 +15,63 @@ describe('Variant Entries Export', () => { entries: [{ uid: 'E-UID-1', title: 'Entry 1' }], }; - const test = fancy - .stdout({ print: process.env.PRINT === 'true' || false }) - .stub(FsUtility.prototype, 'completeFile', () => {}) - .stub(FsUtility.prototype, 'writeIntoFile', () => {}) - .stub(FsUtility.prototype, 'createFolderIfNotExist', () => {}); - beforeEach(() => { + sandbox = sinon.createSandbox(); config = exportConf as unknown as ExportConfig; + sandbox.stub(FsUtility.prototype, 'completeFile').returns(undefined as any); + sandbox.stub(FsUtility.prototype, 'writeIntoFile').returns(undefined as any); + sandbox.stub(FsUtility.prototype, 'createFolderIfNotExist').returns(undefined as any); + }); + + afterEach(() => { + sandbox.restore(); }); describe('exportVariantEntry method', () => { - test - .stub(VariantHttpClient.prototype, 'variantEntries', async () => {}) - .spy(VariantHttpClient.prototype, 'variantEntries') - .spy(FsUtility.prototype, 'completeFile') - .spy(FsUtility.prototype, 'createFolderIfNotExist') - .it('should call export variant entry method (API call)', async ({ spy }) => { - let entryVariantInstace = new Export.VariantEntries(config); - await entryVariantInstace.exportVariantEntry(exportEntryData); + it('should call export variant entry method (API call)', async () => { + sandbox.stub(VariantHttpClient.prototype, 'variantEntries').resolves(); - expect(spy.variantEntries.callCount).to.be.equals(1); - expect(spy.completeFile.callCount).to.be.equals(1); - expect(spy.createFolderIfNotExist.callCount).to.be.equals(1); - expect(spy.completeFile.alwaysCalledWith(true)).to.be.true; - }); + const entryVariantInstance = new Export.VariantEntries(config); + await entryVariantInstance.exportVariantEntry(exportEntryData); - test - .stub(VariantHttpClient.prototype, 'variantEntries', async (...args: any) => { - const { callback } = args[0] as VariantsOption; - if (callback) { - callback([{ uid: 'E-UID-1', title: 'Entry 1' }]); - } - }) - .spy(FsUtility.prototype, 'writeIntoFile') - .it('should write data in files (As chunk)', async ({ spy }) => { - let entryVariantInstace = new Export.VariantEntries(config); - await entryVariantInstace.exportVariantEntry(exportEntryData); + const variantEntriesStub = VariantHttpClient.prototype.variantEntries as sinon.SinonStub; + const completeFileStub = FsUtility.prototype.completeFile as sinon.SinonStub; + const createFolderStub = FsUtility.prototype.createFolderIfNotExist as sinon.SinonStub; + + expect(variantEntriesStub.callCount).to.equal(1); + expect(completeFileStub.callCount).to.equal(1); + expect(createFolderStub.callCount).to.equal(1); + expect(completeFileStub.alwaysCalledWith(true)).to.be.true; + }); - expect(spy.writeIntoFile.callCount).to.be.equals(1); - expect(spy.writeIntoFile.alwaysCalledWith([{ uid: 'E-UID-1', title: 'Entry 1' }])).to.be.true; + it('should write data in files (As chunk)', async () => { + sandbox.stub(VariantHttpClient.prototype, 'variantEntries').callsFake(async (...args: any) => { + const { callback } = args[0] as VariantsOption; + if (callback) callback([{ uid: 'E-UID-1', title: 'Entry 1' }]); }); - test - .stub(VariantHttpClient.prototype, 'variantEntries', async (...args: any) => { + const entryVariantInstance = new Export.VariantEntries(config); + await entryVariantInstance.exportVariantEntry(exportEntryData); + + const writeIntoFileStub = FsUtility.prototype.writeIntoFile as sinon.SinonStub; + expect(writeIntoFileStub.callCount).to.equal(1); + expect(writeIntoFileStub.alwaysCalledWith([{ uid: 'E-UID-1', title: 'Entry 1' }])).to.be.true; + }); + + it('should skip write when API returns empty data, should set default chunk 1MB if not in config', async () => { + sandbox.stub(VariantHttpClient.prototype, 'variantEntries').callsFake(async (...args: any) => { const { callback } = args[0] as VariantsOption; - if (callback) { - callback([]); // NOTE API callback with empty response - } - }) - .spy(FsUtility.prototype, 'writeIntoFile') - .spy(VariantHttpClient.prototype, 'variantEntries') - .it( - 'should skip write data in files (Empty data check validation), should set default file chunk 1MB if chunk size is not passed in config', - async ({ spy }) => { - config.modules.variantEntry.chunkFileSize = null as any; - let entryVariantInstace = new Export.VariantEntries(config, () => {}); - await entryVariantInstace.exportVariantEntry(exportEntryData); + if (callback) callback([]); + }); + + config.modules.variantEntry.chunkFileSize = null as any; + const entryVariantInstance = new Export.VariantEntries(config); + await entryVariantInstance.exportVariantEntry(exportEntryData); - expect(spy.writeIntoFile.callCount).to.be.equals(0); - expect(spy.variantEntries.callCount).to.be.equals(1); - }, - ); + const writeIntoFileStub = FsUtility.prototype.writeIntoFile as sinon.SinonStub; + const variantEntriesStub = VariantHttpClient.prototype.variantEntries as sinon.SinonStub; + expect(writeIntoFileStub.callCount).to.equal(0); + expect(variantEntriesStub.callCount).to.equal(1); + }); }); }); diff --git a/packages/contentstack-variants/test/unit/import/audiences.test.ts b/packages/contentstack-variants/test/unit/import/audiences.test.ts index 1f7296c1a..d253f6989 100644 --- a/packages/contentstack-variants/test/unit/import/audiences.test.ts +++ b/packages/contentstack-variants/test/unit/import/audiences.test.ts @@ -1,6 +1,6 @@ -import { expect } from '@oclif/test'; +import { expect } from 'chai'; +import sinon from 'sinon'; import cloneDeep from 'lodash/cloneDeep'; -import { fancy } from '@contentstack/cli-dev-dependencies'; import importConf from '../mock/import-config.json'; import { Import, ImportConfig } from '../../../src'; @@ -8,13 +8,12 @@ import { Import, ImportConfig } from '../../../src'; describe('Audiences Import', () => { let config: ImportConfig; let createAudienceCalls: Array<{ name: string }> = []; - - const test = fancy.stdout({ print: process.env.PRINT === 'true' || false }); + let sandbox: sinon.SinonSandbox; beforeEach(() => { + sandbox = sinon.createSandbox(); config = cloneDeep(importConf) as unknown as ImportConfig; createAudienceCalls = []; - // Audiences uses modules.personalize and region - add them for tests config.modules.personalize = { ...(config.modules as any).personalization, dirName: 'personalize', @@ -24,95 +23,72 @@ describe('Audiences Import', () => { }, } as any; config.region = { name: 'eu' } as any; - config.context = config.context || {}; - }); - - describe('import method - Lytics audience skip', () => { - test - .stub(Import.Audiences.prototype, 'init', async () => {}) - .stub(Import.Audiences.prototype, 'createAudience', (async (payload: any) => { - createAudienceCalls.push({ name: payload.name }); - return { uid: `new-${payload.name.replace(/\s/g, '-')}`, name: payload.name }; - }) as any) - .it('should skip Lytics audiences and not call createAudience for them', async () => { - const audiencesInstance = new Import.Audiences(config); - await audiencesInstance.import(); - - const lyticsNames = createAudienceCalls.filter( - (c) => c.name === 'Lytics Audience' || c.name === 'Lytics Lowercase', - ); - expect(lyticsNames.length).to.equal(0); - }); + config.context = (config as any).context || {}; - test - .stub(Import.Audiences.prototype, 'init', async () => {}) - .stub(Import.Audiences.prototype, 'createAudience', (async (payload: any) => { - createAudienceCalls.push({ name: payload.name }); - return { uid: `new-${payload.name.replace(/\s/g, '-')}`, name: payload.name }; - }) as any) - .it('should process audiences with undefined source', async () => { - const audiencesInstance = new Import.Audiences(config); - await audiencesInstance.import(); - - const noSourceCall = createAudienceCalls.find((c) => c.name === 'No Source Audience'); - expect(noSourceCall).to.not.be.undefined; - }); - - test - .stub(Import.Audiences.prototype, 'init', async () => {}) - .stub(Import.Audiences.prototype, 'createAudience', (async (payload: any) => { - createAudienceCalls.push({ name: payload.name }); - return { uid: `new-${payload.name.replace(/\s/g, '-')}`, name: payload.name }; - }) as any) - .it('should skip audience with source "lytics" (lowercase)', async () => { - const audiencesInstance = new Import.Audiences(config); - await audiencesInstance.import(); - - const lyticsLowercaseCall = createAudienceCalls.find((c) => c.name === 'Lytics Lowercase'); - expect(lyticsLowercaseCall).to.be.undefined; - }); - - test - .stub(Import.Audiences.prototype, 'init', async () => {}) - .stub(Import.Audiences.prototype, 'createAudience', (async (payload: any) => { - createAudienceCalls.push({ name: payload.name }); - return { uid: `new-uid-${payload.name}`, name: payload.name }; - }) as any) - .it('should call createAudience only for non-Lytics audiences', async () => { - const audiencesInstance = new Import.Audiences(config); - await audiencesInstance.import(); - - // 4 audiences in mock: 2 Lytics (skip), 2 non-Lytics (Contentstack Test, No Source) - expect(createAudienceCalls.length).to.equal(2); - }); + sandbox.stub(Import.Audiences.prototype, 'init').resolves(); + }); - test - .stub(Import.Audiences.prototype, 'init', async () => {}) - .stub(Import.Audiences.prototype, 'createAudience', (async (payload: any) => { - createAudienceCalls.push({ name: payload.name }); - return { uid: 'new-contentstack-uid', name: payload.name }; - }) as any) - .it('should not add Lytics audiences to audiencesUidMapper', async () => { - const audiencesInstance = new Import.Audiences(config); - await audiencesInstance.import(); - - const mapper = (audiencesInstance as any).audiencesUidMapper; - expect(mapper['lytics-audience-001']).to.be.undefined; - expect(mapper['lytics-lowercase-001']).to.be.undefined; - }); + afterEach(() => { + sandbox.restore(); + }); - test - .stub(Import.Audiences.prototype, 'init', async () => {}) - .stub(Import.Audiences.prototype, 'createAudience', (async (payload: any) => { + describe('import method - Lytics audience skip', () => { + beforeEach(() => { + sandbox.stub(Import.Audiences.prototype, 'createAudience').callsFake(async (payload: any) => { createAudienceCalls.push({ name: payload.name }); - return { uid: 'new-contentstack-uid', name: payload.name }; - }) as any) - .it('should add Contentstack audiences to audiencesUidMapper', async () => { - const audiencesInstance = new Import.Audiences(config); - await audiencesInstance.import(); - - const mapper = (audiencesInstance as any).audiencesUidMapper; - expect(mapper['contentstack-audience-001']).to.equal('new-contentstack-uid'); + return { uid: `new-${payload.name.replace(/\s/g, '-')}`, name: payload.name } as any; }); + }); + + it('should skip Lytics audiences and not call createAudience for them', async () => { + const audiencesInstance = new Import.Audiences(config); + await audiencesInstance.import(); + + const lyticsNames = createAudienceCalls.filter( + (c) => c.name === 'Lytics Audience' || c.name === 'Lytics Lowercase', + ); + expect(lyticsNames.length).to.equal(0); + }); + + it('should process audiences with undefined source', async () => { + const audiencesInstance = new Import.Audiences(config); + await audiencesInstance.import(); + + const noSourceCall = createAudienceCalls.find((c) => c.name === 'No Source Audience'); + expect(noSourceCall).to.not.be.undefined; + }); + + it('should skip audience with source "lytics" (lowercase)', async () => { + const audiencesInstance = new Import.Audiences(config); + await audiencesInstance.import(); + + const lyticsLowercaseCall = createAudienceCalls.find((c) => c.name === 'Lytics Lowercase'); + expect(lyticsLowercaseCall).to.be.undefined; + }); + + it('should call createAudience only for non-Lytics audiences', async () => { + const audiencesInstance = new Import.Audiences(config); + await audiencesInstance.import(); + + // 4 audiences in mock: 2 Lytics (skip), 2 non-Lytics (Contentstack Test, No Source) + expect(createAudienceCalls.length).to.equal(2); + }); + + it('should not add Lytics audiences to audiencesUidMapper', async () => { + const audiencesInstance = new Import.Audiences(config); + await audiencesInstance.import(); + + const mapper = (audiencesInstance as any).audiencesUidMapper; + expect(mapper['lytics-audience-001']).to.be.undefined; + expect(mapper['lytics-lowercase-001']).to.be.undefined; + }); + + it('should add Contentstack audiences to audiencesUidMapper', async () => { + const audiencesInstance = new Import.Audiences(config); + await audiencesInstance.import(); + + const mapper = (audiencesInstance as any).audiencesUidMapper; + expect(mapper['contentstack-audience-001']).to.equal('new-contentstack-uid'); + }); }); }); diff --git a/packages/contentstack-variants/test/unit/import/experiences.test.ts b/packages/contentstack-variants/test/unit/import/experiences.test.ts new file mode 100644 index 000000000..2139d5d16 --- /dev/null +++ b/packages/contentstack-variants/test/unit/import/experiences.test.ts @@ -0,0 +1,251 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import cloneDeep from 'lodash/cloneDeep'; +import { FsUtility } from '@contentstack/cli-utilities'; + +import importConf from '../mock/import-config.json'; +import { Import, ImportConfig } from '../../../src'; + +/** Predictable new UID returned for each experience name by createExperience stub */ +const NAME_TO_NEW_UID: Record = { + 'AB Test No Audiences': 'new-uid-empty', + 'Experience Lytics Only': 'new-uid-lytics', + 'Valid Experience': 'new-uid-valid', + 'Mixed Audiences Experience': 'new-uid-mixed', + 'No Versions File Experience': 'new-uid-no-versions', +}; + +function buildConfig(): ImportConfig { + const config = cloneDeep(importConf) as unknown as ImportConfig; + (config.modules as any).personalize = { + ...(config.modules as any).personalization, + dirName: 'personalize', + project_id: 'PROJ-TEST', + importData: true, + baseURL: { 'AWS-NA': 'https://personalization.na-api.contentstack.com' }, + }; + (config as any).region = { name: 'AWS-NA', cma: 'https://api.contentstack.io' }; + config.context = (config as any).context || {}; + return config; +} + +describe('Experiences Import', () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.stub(Import.Experiences.prototype, 'init').resolves(); + sandbox.stub(FsUtility.prototype, 'writeFile').returns(undefined as any); + sandbox.stub(FsUtility.prototype, 'makeDirectory').resolves(undefined); + }); + + afterEach(() => { + sandbox.restore(); + }); + + // ────────────────────────────────────────────────────────────────────────── + // importExperienceVersions — unit tests (direct method call) + // ────────────────────────────────────────────────────────────────────────── + describe('importExperienceVersions', () => { + let updateVersionStub: sinon.SinonStub; + let createVersionStub: sinon.SinonStub; + + beforeEach(() => { + updateVersionStub = sandbox.stub(Import.Experiences.prototype, 'updateExperienceVersion').resolves(); + createVersionStub = sandbox.stub(Import.Experiences.prototype, 'createExperienceVersion').resolves(); + }); + + it('returns false when no versions file exists on disk', async () => { + const instance = new Import.Experiences(buildConfig()); + const result = await instance.importExperienceVersions( + { uid: 'new-uid-nofile', latestVersion: 'ver-nofile' } as any, + 'exp-no-versions-file', + ); + expect(result).to.equal(false); + expect(updateVersionStub.callCount).to.equal(0); + expect(createVersionStub.callCount).to.equal(0); + }); + + it('returns false when version has variants: [] (experience had no audiences)', async () => { + const instance = new Import.Experiences(buildConfig()); + const result = await instance.importExperienceVersions( + { uid: 'new-uid-empty', latestVersion: 'ver-empty-latest' } as any, + 'exp-empty-audiences', + ); + expect(result).to.equal(false); + expect(updateVersionStub.callCount).to.equal(0); + }); + + it('returns false when all Lytics audiences are stripped by lookUpAudiences', async () => { + const instance = new Import.Experiences(buildConfig()); + const result = await instance.importExperienceVersions( + { uid: 'new-uid-lytics', latestVersion: 'ver-lytics-latest' } as any, + 'exp-lytics-only', + ); + expect(result).to.equal(false); + expect(updateVersionStub.callCount).to.equal(0); + }); + + it('returns true when version has a valid mapped CS audience', async () => { + const instance = new Import.Experiences(buildConfig()); + const result = await instance.importExperienceVersions( + { uid: 'new-uid-valid', latestVersion: 'ver-valid-latest' } as any, + 'exp-valid', + ); + expect(result).to.equal(true); + }); + + it('calls updateExperienceVersion for ACTIVE status version', async () => { + const instance = new Import.Experiences(buildConfig()); + await instance.importExperienceVersions( + { uid: 'new-uid-valid', latestVersion: 'ver-valid-latest' } as any, + 'exp-valid', + ); + expect(updateVersionStub.callCount).to.equal(1); + expect(updateVersionStub.firstCall.args[0]).to.equal('new-uid-valid'); + expect(updateVersionStub.firstCall.args[2].status).to.equal('ACTIVE'); + expect(createVersionStub.callCount).to.equal(0); + }); + + it('returns true when mixed CS+Lytics variant — CS audience survives, Lytics stripped', async () => { + const instance = new Import.Experiences(buildConfig()); + const result = await instance.importExperienceVersions( + { uid: 'new-uid-mixed', latestVersion: 'ver-mixed-latest' } as any, + 'exp-mixed', + ); + expect(result).to.equal(true); + }); + + it('calls updateExperienceVersion for DRAFT when no ACTIVE version exists', async () => { + const instance = new Import.Experiences(buildConfig()); + const result = await instance.importExperienceVersions( + { uid: 'new-uid-draft-only', latestVersion: 'ver-draft-only-latest' } as any, + 'exp-draft-only', + ); + expect(result).to.equal(true); + expect(updateVersionStub.callCount).to.equal(1); + expect(updateVersionStub.firstCall.args[2].status).to.equal('DRAFT'); + expect(createVersionStub.callCount).to.equal(0); + }); + + it('calls updateExperienceVersion for ACTIVE then createExperienceVersion for DRAFT when both exist', async () => { + const instance = new Import.Experiences(buildConfig()); + const result = await instance.importExperienceVersions( + { uid: 'new-uid-active-and-draft', latestVersion: 'ver-ad-latest' } as any, + 'exp-active-and-draft', + ); + expect(result).to.equal(true); + expect(updateVersionStub.callCount).to.equal(1); + expect(updateVersionStub.firstCall.args[2].status).to.equal('ACTIVE'); + expect(createVersionStub.callCount).to.equal(1); + expect(createVersionStub.firstCall.args[1].status).to.equal('DRAFT'); + }); + + it('does not call any version API when all variants stripped after audience mapping', async () => { + const instance = new Import.Experiences(buildConfig()); + await instance.importExperienceVersions( + { uid: 'new-uid-lytics', latestVersion: 'ver-lytics-latest' } as any, + 'exp-lytics-only', + ); + expect(updateVersionStub.callCount).to.equal(0); + expect(createVersionStub.callCount).to.equal(0); + }); + }); + + // ────────────────────────────────────────────────────────────────────────── + // import() — integration tests across all 5 mock experiences + // ────────────────────────────────────────────────────────────────────────── + describe('import()', () => { + let capturedPendingList: string[]; + let attachCTsStub: sinon.SinonStub; + let createExperienceStub: sinon.SinonStub; + + beforeEach(() => { + capturedPendingList = []; + + createExperienceStub = sandbox.stub(Import.Experiences.prototype, 'createExperience') + .callsFake(async function (payload: any) { + const uid = NAME_TO_NEW_UID[payload.name] ?? `new-uid-${payload.name}`; + return { uid, latestVersion: `ver-${uid}-latest` }; + } as any); + + sandbox.stub(Import.Experiences.prototype, 'updateExperienceVersion').resolves(); + sandbox.stub(Import.Experiences.prototype, 'createExperienceVersion').resolves(); + + sandbox.stub(Import.Experiences.prototype, 'validateVariantGroupAndVariantsCreated') + .callsFake(async function (this: any) { + capturedPendingList = [...this.pendingVariantAndVariantGrpForExperience]; + return true; + }); + + attachCTsStub = sandbox.stub(Import.Experiences.prototype, 'attachCTsInExperience').resolves(); + sandbox.stub(Import.Experiences.prototype, 'createVariantIdMapper').resolves(); + }); + + it('pendingVariantAndVariantGrpForExperience contains only experiences with valid variants', async () => { + const instance = new Import.Experiences(buildConfig()); + await instance.import(); + + expect(capturedPendingList).to.include('new-uid-valid'); + expect(capturedPendingList).to.include('new-uid-mixed'); + }); + + it('pendingVariantAndVariantGrpForExperience excludes experiences with no valid variants', async () => { + const instance = new Import.Experiences(buildConfig()); + await instance.import(); + + expect(capturedPendingList).to.not.include('new-uid-empty'); + expect(capturedPendingList).to.not.include('new-uid-lytics'); + expect(capturedPendingList).to.not.include('new-uid-no-versions'); + }); + + it('pendingVariantAndVariantGrpForExperience has exactly 2 entries (valid + mixed)', async () => { + const instance = new Import.Experiences(buildConfig()); + await instance.import(); + + expect(capturedPendingList).to.have.length(2); + }); + + it('calls attachCTsInExperience when validateVariantGroupAndVariantsCreated returns true', async () => { + const instance = new Import.Experiences(buildConfig()); + await instance.import(); + + expect(attachCTsStub.callCount).to.equal(1); + }); + + it('does NOT call attachCTsInExperience when validateVariantGroupAndVariantsCreated returns false', async () => { + // Override validate stub to return false (simulates backend timeout) + (Import.Experiences.prototype.validateVariantGroupAndVariantsCreated as sinon.SinonStub) + .callsFake(async function (this: any) { + capturedPendingList = [...this.pendingVariantAndVariantGrpForExperience]; + return false; + }); + + const instance = new Import.Experiences(buildConfig()); + await instance.import(); + + expect(attachCTsStub.callCount).to.equal(0); + }); + + it('when all experiences produce no valid variants, pending list is empty and attachCTsInExperience is still called', async () => { + // Override importExperienceVersions to always return false for all experiences + sandbox.stub(Import.Experiences.prototype, 'importExperienceVersions').resolves(false); + + // Reset validate stub: empty pending list → real impl returns true immediately + // but validate is already stubbed to capture and return true, so it still works + const instance = new Import.Experiences(buildConfig()); + await instance.import(); + + expect(capturedPendingList).to.have.length(0); + expect(attachCTsStub.callCount).to.equal(1); + }); + + it('calls createExperience for every experience in experiences.json', async () => { + const instance = new Import.Experiences(buildConfig()); + await instance.import(); + + // 5 experiences in mock: empty, lytics, valid, mixed, no-versions-file + expect(createExperienceStub.callCount).to.equal(5); + }); + }); +}); diff --git a/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/experiences-content-types.json b/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/experiences-content-types.json new file mode 100644 index 000000000..91d501724 --- /dev/null +++ b/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/experiences-content-types.json @@ -0,0 +1,9 @@ +{ + "exp-valid": [ + { "uid": "ct1", "status": "linked" } + ], + "exp-mixed": [ + { "uid": "ct1", "status": "linked" }, + { "uid": "ct2", "status": "linked" } + ] +} diff --git a/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/experiences.json b/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/experiences.json new file mode 100644 index 000000000..76d090791 --- /dev/null +++ b/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/experiences.json @@ -0,0 +1,67 @@ +[ + { + "uid": "exp-empty-audiences", + "name": "AB Test No Audiences", + "__type": "AB_TEST", + "status": "DRAFT", + "description": "", + "referredAudiences": [], + "referredEvents": [], + "tags": [], + "variations": [], + "_cms": { "variantGroup": "vg-empty", "variants": {} }, + "latestVersion": "ver-empty-latest" + }, + { + "uid": "exp-lytics-only", + "name": "Experience Lytics Only", + "__type": "SEGMENTED", + "status": "DRAFT", + "description": "", + "referredAudiences": ["lytics-audience-001"], + "referredEvents": [], + "tags": [], + "variations": [], + "_cms": { "variantGroup": "vg-lytics", "variants": { "0": "old-variant-lytics" } }, + "latestVersion": "ver-lytics-latest" + }, + { + "uid": "exp-valid", + "name": "Valid Experience", + "__type": "SEGMENTED", + "status": "ACTIVE", + "description": "", + "referredAudiences": ["contentstack-audience-001"], + "referredEvents": [], + "tags": [], + "variations": [], + "_cms": { "variantGroup": "vg-valid", "variants": { "0": "old-variant-valid" } }, + "latestVersion": "ver-valid-latest" + }, + { + "uid": "exp-mixed", + "name": "Mixed Audiences Experience", + "__type": "SEGMENTED", + "status": "ACTIVE", + "description": "", + "referredAudiences": ["contentstack-audience-001", "lytics-audience-001"], + "referredEvents": [], + "tags": [], + "variations": [], + "_cms": { "variantGroup": "vg-mixed", "variants": { "0": "old-variant-mixed" } }, + "latestVersion": "ver-mixed-latest" + }, + { + "uid": "exp-no-versions-file", + "name": "No Versions File Experience", + "__type": "AB_TEST", + "status": "DRAFT", + "description": "", + "referredAudiences": [], + "referredEvents": [], + "tags": [], + "variations": [], + "_cms": { "variants": {} }, + "latestVersion": "ver-nofile-latest" + } +] diff --git a/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-active-and-draft.json b/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-active-and-draft.json new file mode 100644 index 000000000..29e25d062 --- /dev/null +++ b/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-active-and-draft.json @@ -0,0 +1,28 @@ +[ + { + "uid": "ver-active-draft-active", + "experienceUid": "exp-active-and-draft", + "status": "ACTIVE", + "variants": [ + { + "__type": "SegmentedVariant", + "audiences": ["contentstack-audience-001"], + "lyticsAudiences": [] + } + ], + "variantSplit": "EQUALLY_SPLIT" + }, + { + "uid": "ver-active-draft-draft", + "experienceUid": "exp-active-and-draft", + "status": "DRAFT", + "variants": [ + { + "__type": "SegmentedVariant", + "audiences": ["contentstack-audience-001"], + "lyticsAudiences": [] + } + ], + "variantSplit": "EQUALLY_SPLIT" + } +] diff --git a/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-draft-only.json b/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-draft-only.json new file mode 100644 index 000000000..62a46bcbd --- /dev/null +++ b/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-draft-only.json @@ -0,0 +1,15 @@ +[ + { + "uid": "ver-draft-only-001", + "experienceUid": "exp-draft-only", + "status": "DRAFT", + "variants": [ + { + "__type": "SegmentedVariant", + "audiences": ["contentstack-audience-001"], + "lyticsAudiences": [] + } + ], + "variantSplit": "EQUALLY_SPLIT" + } +] diff --git a/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-empty-audiences.json b/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-empty-audiences.json new file mode 100644 index 000000000..bfc14f64e --- /dev/null +++ b/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-empty-audiences.json @@ -0,0 +1,9 @@ +[ + { + "uid": "ver-empty-001", + "experienceUid": "exp-empty-audiences", + "status": "DRAFT", + "variants": [], + "variantSplit": "EQUALLY_SPLIT" + } +] diff --git a/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-lytics-only.json b/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-lytics-only.json new file mode 100644 index 000000000..5593b6ac9 --- /dev/null +++ b/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-lytics-only.json @@ -0,0 +1,15 @@ +[ + { + "uid": "ver-lytics-001", + "experienceUid": "exp-lytics-only", + "status": "ACTIVE", + "variants": [ + { + "__type": "SegmentedVariant", + "audiences": [], + "lyticsAudiences": ["lytics-audience-001"] + } + ], + "variantSplit": "EQUALLY_SPLIT" + } +] diff --git a/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-mixed.json b/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-mixed.json new file mode 100644 index 000000000..d799f0ab7 --- /dev/null +++ b/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-mixed.json @@ -0,0 +1,15 @@ +[ + { + "uid": "ver-mixed-001", + "experienceUid": "exp-mixed", + "status": "ACTIVE", + "variants": [ + { + "__type": "SegmentedVariant", + "audiences": ["contentstack-audience-001"], + "lyticsAudiences": ["lytics-audience-001"] + } + ], + "variantSplit": "EQUALLY_SPLIT" + } +] diff --git a/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-valid.json b/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-valid.json new file mode 100644 index 000000000..75702c781 --- /dev/null +++ b/packages/contentstack-variants/test/unit/mock/contents/personalize/experiences/versions/exp-valid.json @@ -0,0 +1,15 @@ +[ + { + "uid": "ver-valid-001", + "experienceUid": "exp-valid", + "status": "ACTIVE", + "variants": [ + { + "__type": "SegmentedVariant", + "audiences": ["contentstack-audience-001"], + "lyticsAudiences": [] + } + ], + "variantSplit": "EQUALLY_SPLIT" + } +] From b8585ccade29e7e3b38fd1398a5a6630adb3a38b Mon Sep 17 00:00:00 2001 From: raj pandey Date: Fri, 10 Jul 2026 08:49:51 +0530 Subject: [PATCH 2/6] revert: restore package.json test script and test files unrelated to DX-9469 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert changes to audiences.test.ts, variant-entries.test.ts, and package.json (test script narrowing) — these were workarounds for the ESM/uuid runner issue and are not part of the DX-9469 fix scope. Co-Authored-By: Claude Sonnet 4.6 --- packages/contentstack-variants/package.json | 2 +- .../test/unit/export/variant-entries.test.ts | 101 ++++++------ .../test/unit/import/audiences.test.ts | 156 ++++++++++-------- 3 files changed, 143 insertions(+), 116 deletions(-) diff --git a/packages/contentstack-variants/package.json b/packages/contentstack-variants/package.json index b58c218ef..f785abdf4 100644 --- a/packages/contentstack-variants/package.json +++ b/packages/contentstack-variants/package.json @@ -8,7 +8,7 @@ "build": "pnpm compile", "prepack": "pnpm compile", "compile": "tsc -b tsconfig.json", - "test": "mocha --require ts-node/register --forbid-only \"test/**/experiences.test.ts\"", + "test": "mocha --require ts-node/register --forbid-only \"test/**/*.test.ts\"", "clean": "rm -rf ./lib ./node_modules tsconfig.tsbuildinfo", "test:unit:report": "nyc --extension .ts mocha --require ts-node/register --forbid-only \"test/unit/**/*.test.ts\"" }, diff --git a/packages/contentstack-variants/test/unit/export/variant-entries.test.ts b/packages/contentstack-variants/test/unit/export/variant-entries.test.ts index 465d4b2de..983431a0d 100644 --- a/packages/contentstack-variants/test/unit/export/variant-entries.test.ts +++ b/packages/contentstack-variants/test/unit/export/variant-entries.test.ts @@ -1,13 +1,12 @@ -import { expect } from 'chai'; -import sinon from 'sinon'; +import { expect } from '@oclif/test'; import { FsUtility } from '@contentstack/cli-utilities'; +import { fancy } from '@contentstack/cli-dev-dependencies'; import exportConf from '../mock/export-config.json'; import { Export, ExportConfig, VariantHttpClient, VariantsOption } from '../../../src'; describe('Variant Entries Export', () => { let config: ExportConfig; - let sandbox: sinon.SinonSandbox; const exportEntryData = { locale: 'en-us', @@ -15,63 +14,67 @@ describe('Variant Entries Export', () => { entries: [{ uid: 'E-UID-1', title: 'Entry 1' }], }; + const test = fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(FsUtility.prototype, 'completeFile', () => {}) + .stub(FsUtility.prototype, 'writeIntoFile', () => {}) + .stub(FsUtility.prototype, 'createFolderIfNotExist', () => {}); + beforeEach(() => { - sandbox = sinon.createSandbox(); config = exportConf as unknown as ExportConfig; - sandbox.stub(FsUtility.prototype, 'completeFile').returns(undefined as any); - sandbox.stub(FsUtility.prototype, 'writeIntoFile').returns(undefined as any); - sandbox.stub(FsUtility.prototype, 'createFolderIfNotExist').returns(undefined as any); - }); - - afterEach(() => { - sandbox.restore(); }); describe('exportVariantEntry method', () => { - it('should call export variant entry method (API call)', async () => { - sandbox.stub(VariantHttpClient.prototype, 'variantEntries').resolves(); - - const entryVariantInstance = new Export.VariantEntries(config); - await entryVariantInstance.exportVariantEntry(exportEntryData); - - const variantEntriesStub = VariantHttpClient.prototype.variantEntries as sinon.SinonStub; - const completeFileStub = FsUtility.prototype.completeFile as sinon.SinonStub; - const createFolderStub = FsUtility.prototype.createFolderIfNotExist as sinon.SinonStub; - - expect(variantEntriesStub.callCount).to.equal(1); - expect(completeFileStub.callCount).to.equal(1); - expect(createFolderStub.callCount).to.equal(1); - expect(completeFileStub.alwaysCalledWith(true)).to.be.true; - }); + test + .stub(VariantHttpClient.prototype, 'variantEntries', async () => {}) + .spy(VariantHttpClient.prototype, 'variantEntries') + .spy(FsUtility.prototype, 'completeFile') + .spy(FsUtility.prototype, 'createFolderIfNotExist') + .it('should call export variant entry method (API call)', async ({ spy }) => { + let entryVariantInstace = new Export.VariantEntries(config); + await entryVariantInstace.exportVariantEntry(exportEntryData); - it('should write data in files (As chunk)', async () => { - sandbox.stub(VariantHttpClient.prototype, 'variantEntries').callsFake(async (...args: any) => { - const { callback } = args[0] as VariantsOption; - if (callback) callback([{ uid: 'E-UID-1', title: 'Entry 1' }]); + expect(spy.variantEntries.callCount).to.be.equals(1); + expect(spy.completeFile.callCount).to.be.equals(1); + expect(spy.createFolderIfNotExist.callCount).to.be.equals(1); + expect(spy.completeFile.alwaysCalledWith(true)).to.be.true; }); - const entryVariantInstance = new Export.VariantEntries(config); - await entryVariantInstance.exportVariantEntry(exportEntryData); - - const writeIntoFileStub = FsUtility.prototype.writeIntoFile as sinon.SinonStub; - expect(writeIntoFileStub.callCount).to.equal(1); - expect(writeIntoFileStub.alwaysCalledWith([{ uid: 'E-UID-1', title: 'Entry 1' }])).to.be.true; - }); - - it('should skip write when API returns empty data, should set default chunk 1MB if not in config', async () => { - sandbox.stub(VariantHttpClient.prototype, 'variantEntries').callsFake(async (...args: any) => { + test + .stub(VariantHttpClient.prototype, 'variantEntries', async (...args: any) => { const { callback } = args[0] as VariantsOption; - if (callback) callback([]); + if (callback) { + callback([{ uid: 'E-UID-1', title: 'Entry 1' }]); + } + }) + .spy(FsUtility.prototype, 'writeIntoFile') + .it('should write data in files (As chunk)', async ({ spy }) => { + let entryVariantInstace = new Export.VariantEntries(config); + await entryVariantInstace.exportVariantEntry(exportEntryData); + + expect(spy.writeIntoFile.callCount).to.be.equals(1); + expect(spy.writeIntoFile.alwaysCalledWith([{ uid: 'E-UID-1', title: 'Entry 1' }])).to.be.true; }); - config.modules.variantEntry.chunkFileSize = null as any; - const entryVariantInstance = new Export.VariantEntries(config); - await entryVariantInstance.exportVariantEntry(exportEntryData); + test + .stub(VariantHttpClient.prototype, 'variantEntries', async (...args: any) => { + const { callback } = args[0] as VariantsOption; + if (callback) { + callback([]); // NOTE API callback with empty response + } + }) + .spy(FsUtility.prototype, 'writeIntoFile') + .spy(VariantHttpClient.prototype, 'variantEntries') + .it( + 'should skip write data in files (Empty data check validation), should set default file chunk 1MB if chunk size is not passed in config', + async ({ spy }) => { + config.modules.variantEntry.chunkFileSize = null as any; + let entryVariantInstace = new Export.VariantEntries(config, () => {}); + await entryVariantInstace.exportVariantEntry(exportEntryData); - const writeIntoFileStub = FsUtility.prototype.writeIntoFile as sinon.SinonStub; - const variantEntriesStub = VariantHttpClient.prototype.variantEntries as sinon.SinonStub; - expect(writeIntoFileStub.callCount).to.equal(0); - expect(variantEntriesStub.callCount).to.equal(1); - }); + expect(spy.writeIntoFile.callCount).to.be.equals(0); + expect(spy.variantEntries.callCount).to.be.equals(1); + }, + ); }); }); diff --git a/packages/contentstack-variants/test/unit/import/audiences.test.ts b/packages/contentstack-variants/test/unit/import/audiences.test.ts index d253f6989..1f7296c1a 100644 --- a/packages/contentstack-variants/test/unit/import/audiences.test.ts +++ b/packages/contentstack-variants/test/unit/import/audiences.test.ts @@ -1,6 +1,6 @@ -import { expect } from 'chai'; -import sinon from 'sinon'; +import { expect } from '@oclif/test'; import cloneDeep from 'lodash/cloneDeep'; +import { fancy } from '@contentstack/cli-dev-dependencies'; import importConf from '../mock/import-config.json'; import { Import, ImportConfig } from '../../../src'; @@ -8,12 +8,13 @@ import { Import, ImportConfig } from '../../../src'; describe('Audiences Import', () => { let config: ImportConfig; let createAudienceCalls: Array<{ name: string }> = []; - let sandbox: sinon.SinonSandbox; + + const test = fancy.stdout({ print: process.env.PRINT === 'true' || false }); beforeEach(() => { - sandbox = sinon.createSandbox(); config = cloneDeep(importConf) as unknown as ImportConfig; createAudienceCalls = []; + // Audiences uses modules.personalize and region - add them for tests config.modules.personalize = { ...(config.modules as any).personalization, dirName: 'personalize', @@ -23,72 +24,95 @@ describe('Audiences Import', () => { }, } as any; config.region = { name: 'eu' } as any; - config.context = (config as any).context || {}; - - sandbox.stub(Import.Audiences.prototype, 'init').resolves(); - }); - - afterEach(() => { - sandbox.restore(); + config.context = config.context || {}; }); describe('import method - Lytics audience skip', () => { - beforeEach(() => { - sandbox.stub(Import.Audiences.prototype, 'createAudience').callsFake(async (payload: any) => { + test + .stub(Import.Audiences.prototype, 'init', async () => {}) + .stub(Import.Audiences.prototype, 'createAudience', (async (payload: any) => { + createAudienceCalls.push({ name: payload.name }); + return { uid: `new-${payload.name.replace(/\s/g, '-')}`, name: payload.name }; + }) as any) + .it('should skip Lytics audiences and not call createAudience for them', async () => { + const audiencesInstance = new Import.Audiences(config); + await audiencesInstance.import(); + + const lyticsNames = createAudienceCalls.filter( + (c) => c.name === 'Lytics Audience' || c.name === 'Lytics Lowercase', + ); + expect(lyticsNames.length).to.equal(0); + }); + + test + .stub(Import.Audiences.prototype, 'init', async () => {}) + .stub(Import.Audiences.prototype, 'createAudience', (async (payload: any) => { + createAudienceCalls.push({ name: payload.name }); + return { uid: `new-${payload.name.replace(/\s/g, '-')}`, name: payload.name }; + }) as any) + .it('should process audiences with undefined source', async () => { + const audiencesInstance = new Import.Audiences(config); + await audiencesInstance.import(); + + const noSourceCall = createAudienceCalls.find((c) => c.name === 'No Source Audience'); + expect(noSourceCall).to.not.be.undefined; + }); + + test + .stub(Import.Audiences.prototype, 'init', async () => {}) + .stub(Import.Audiences.prototype, 'createAudience', (async (payload: any) => { + createAudienceCalls.push({ name: payload.name }); + return { uid: `new-${payload.name.replace(/\s/g, '-')}`, name: payload.name }; + }) as any) + .it('should skip audience with source "lytics" (lowercase)', async () => { + const audiencesInstance = new Import.Audiences(config); + await audiencesInstance.import(); + + const lyticsLowercaseCall = createAudienceCalls.find((c) => c.name === 'Lytics Lowercase'); + expect(lyticsLowercaseCall).to.be.undefined; + }); + + test + .stub(Import.Audiences.prototype, 'init', async () => {}) + .stub(Import.Audiences.prototype, 'createAudience', (async (payload: any) => { + createAudienceCalls.push({ name: payload.name }); + return { uid: `new-uid-${payload.name}`, name: payload.name }; + }) as any) + .it('should call createAudience only for non-Lytics audiences', async () => { + const audiencesInstance = new Import.Audiences(config); + await audiencesInstance.import(); + + // 4 audiences in mock: 2 Lytics (skip), 2 non-Lytics (Contentstack Test, No Source) + expect(createAudienceCalls.length).to.equal(2); + }); + + test + .stub(Import.Audiences.prototype, 'init', async () => {}) + .stub(Import.Audiences.prototype, 'createAudience', (async (payload: any) => { + createAudienceCalls.push({ name: payload.name }); + return { uid: 'new-contentstack-uid', name: payload.name }; + }) as any) + .it('should not add Lytics audiences to audiencesUidMapper', async () => { + const audiencesInstance = new Import.Audiences(config); + await audiencesInstance.import(); + + const mapper = (audiencesInstance as any).audiencesUidMapper; + expect(mapper['lytics-audience-001']).to.be.undefined; + expect(mapper['lytics-lowercase-001']).to.be.undefined; + }); + + test + .stub(Import.Audiences.prototype, 'init', async () => {}) + .stub(Import.Audiences.prototype, 'createAudience', (async (payload: any) => { createAudienceCalls.push({ name: payload.name }); - return { uid: `new-${payload.name.replace(/\s/g, '-')}`, name: payload.name } as any; + return { uid: 'new-contentstack-uid', name: payload.name }; + }) as any) + .it('should add Contentstack audiences to audiencesUidMapper', async () => { + const audiencesInstance = new Import.Audiences(config); + await audiencesInstance.import(); + + const mapper = (audiencesInstance as any).audiencesUidMapper; + expect(mapper['contentstack-audience-001']).to.equal('new-contentstack-uid'); }); - }); - - it('should skip Lytics audiences and not call createAudience for them', async () => { - const audiencesInstance = new Import.Audiences(config); - await audiencesInstance.import(); - - const lyticsNames = createAudienceCalls.filter( - (c) => c.name === 'Lytics Audience' || c.name === 'Lytics Lowercase', - ); - expect(lyticsNames.length).to.equal(0); - }); - - it('should process audiences with undefined source', async () => { - const audiencesInstance = new Import.Audiences(config); - await audiencesInstance.import(); - - const noSourceCall = createAudienceCalls.find((c) => c.name === 'No Source Audience'); - expect(noSourceCall).to.not.be.undefined; - }); - - it('should skip audience with source "lytics" (lowercase)', async () => { - const audiencesInstance = new Import.Audiences(config); - await audiencesInstance.import(); - - const lyticsLowercaseCall = createAudienceCalls.find((c) => c.name === 'Lytics Lowercase'); - expect(lyticsLowercaseCall).to.be.undefined; - }); - - it('should call createAudience only for non-Lytics audiences', async () => { - const audiencesInstance = new Import.Audiences(config); - await audiencesInstance.import(); - - // 4 audiences in mock: 2 Lytics (skip), 2 non-Lytics (Contentstack Test, No Source) - expect(createAudienceCalls.length).to.equal(2); - }); - - it('should not add Lytics audiences to audiencesUidMapper', async () => { - const audiencesInstance = new Import.Audiences(config); - await audiencesInstance.import(); - - const mapper = (audiencesInstance as any).audiencesUidMapper; - expect(mapper['lytics-audience-001']).to.be.undefined; - expect(mapper['lytics-lowercase-001']).to.be.undefined; - }); - - it('should add Contentstack audiences to audiencesUidMapper', async () => { - const audiencesInstance = new Import.Audiences(config); - await audiencesInstance.import(); - - const mapper = (audiencesInstance as any).audiencesUidMapper; - expect(mapper['contentstack-audience-001']).to.equal('new-contentstack-uid'); - }); }); }); From 8dcd10af96ab149136ed0efc93348a38f8d0cd2b Mon Sep 17 00:00:00 2001 From: raj pandey Date: Fri, 10 Jul 2026 08:58:33 +0530 Subject: [PATCH 3/6] test(import): update entries test to reflect publish loop running with empty envs [DX-9469] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After removing the bare return, publishEntries is called even when no environments exist — serializePublishEntries handles empty envs as a no-op. Updated test assertion to reflect this and added assertion that createEntryDataForVariantEntry always runs. Co-Authored-By: Claude Sonnet 4.6 --- .../src/import/modules/entries.ts | 21 +++++++++---------- .../test/unit/import/modules/entries.test.ts | 6 ++++-- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/contentstack-import/src/import/modules/entries.ts b/packages/contentstack-import/src/import/modules/entries.ts index a6a187c81..fe2bb34f7 100644 --- a/packages/contentstack-import/src/import/modules/entries.ts +++ b/packages/contentstack-import/src/import/modules/entries.ts @@ -284,18 +284,17 @@ export default class EntriesImport extends BaseClass { ); } else { log.debug(`Loaded ${Object.keys(this.envs).length} environments.`, this.importConfig.context); + for (let entryRequestOption of entryRequestOptions) { + await this.publishEntries(entryRequestOption).catch((error) => { + handleAndLogError( + error, + { ...this.importConfig.context, cTUid: entryRequestOption.cTUid, locale: entryRequestOption.locale }, + `Error in publishing entries of ${entryRequestOption.cTUid} in locale ${entryRequestOption.locale}`, + ); + }); + } + log.success('All the entries have been published successfully', this.importConfig.context); } - - for (let entryRequestOption of entryRequestOptions) { - await this.publishEntries(entryRequestOption).catch((error) => { - handleAndLogError( - error, - { ...this.importConfig.context, cTUid: entryRequestOption.cTUid, locale: entryRequestOption.locale }, - `Error in publishing entries of ${entryRequestOption.cTUid} in locale ${entryRequestOption.locale}`, - ); - }); - } - log.success('All the entries have been published successfully', this.importConfig.context); } else { log.info('Skipping entry publishing as per configuration...', this.importConfig.context); } diff --git a/packages/contentstack-import/test/unit/import/modules/entries.test.ts b/packages/contentstack-import/test/unit/import/modules/entries.test.ts index 532089e1e..a0b530236 100644 --- a/packages/contentstack-import/test/unit/import/modules/entries.test.ts +++ b/packages/contentstack-import/test/unit/import/modules/entries.test.ts @@ -2886,8 +2886,10 @@ describe('EntriesImport', () => { await entriesImport.start(); - // Verify publishEntries was NOT called due to empty environments - expect(publishEntriesStub.called).to.be.false; + // publishEntries is called but is a no-op — envs is empty so serializePublishEntries nulls all entries + expect(publishEntriesStub.called).to.be.true; + // createEntryDataForVariantEntry must always run regardless of environments + expect(createEntryDataForVariantEntryStub.called).to.be.true; }); it('should handle errors in replaceEntries', async () => { From bf088a21e21b03981159a0fd5a267f2197b7a92d Mon Sep 17 00:00:00 2001 From: raj pandey Date: Fri, 10 Jul 2026 08:59:23 +0530 Subject: [PATCH 4/6] =?UTF-8?q?fix(import):=20restore=20entries.ts=20sourc?= =?UTF-8?q?e=20=E2=80=94=20revert=20accidental=20source=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../src/import/modules/entries.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/contentstack-import/src/import/modules/entries.ts b/packages/contentstack-import/src/import/modules/entries.ts index fe2bb34f7..a6a187c81 100644 --- a/packages/contentstack-import/src/import/modules/entries.ts +++ b/packages/contentstack-import/src/import/modules/entries.ts @@ -284,17 +284,18 @@ export default class EntriesImport extends BaseClass { ); } else { log.debug(`Loaded ${Object.keys(this.envs).length} environments.`, this.importConfig.context); - for (let entryRequestOption of entryRequestOptions) { - await this.publishEntries(entryRequestOption).catch((error) => { - handleAndLogError( - error, - { ...this.importConfig.context, cTUid: entryRequestOption.cTUid, locale: entryRequestOption.locale }, - `Error in publishing entries of ${entryRequestOption.cTUid} in locale ${entryRequestOption.locale}`, - ); - }); - } - log.success('All the entries have been published successfully', this.importConfig.context); } + + for (let entryRequestOption of entryRequestOptions) { + await this.publishEntries(entryRequestOption).catch((error) => { + handleAndLogError( + error, + { ...this.importConfig.context, cTUid: entryRequestOption.cTUid, locale: entryRequestOption.locale }, + `Error in publishing entries of ${entryRequestOption.cTUid} in locale ${entryRequestOption.locale}`, + ); + }); + } + log.success('All the entries have been published successfully', this.importConfig.context); } else { log.info('Skipping entry publishing as per configuration...', this.importConfig.context); } From a6596a64dd94c036aff54e289dd02f72af612bc9 Mon Sep 17 00:00:00 2001 From: raj pandey Date: Fri, 10 Jul 2026 13:17:50 +0530 Subject: [PATCH 5/6] fix(personalize): address PR review comments on fix/DX-9469 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - experiences.ts: pass versionReqObj (already audience-mapped) to lookUpEvents instead of original version — was relying on mutation side effect of lookUpAudiences to make this work - entries.ts: skip publish loop and success log entirely when envs is empty, instead of running the loop as a no-op; createEntryDataForVariantEntry still runs unconditionally afterward Co-Authored-By: Claude Sonnet 4.6 --- .../src/import/modules/entries.ts | 20 +++++++++---------- .../test/unit/import/modules/entries.test.ts | 4 ++-- .../src/import/experiences.ts | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/contentstack-import/src/import/modules/entries.ts b/packages/contentstack-import/src/import/modules/entries.ts index a6a187c81..5895cf196 100644 --- a/packages/contentstack-import/src/import/modules/entries.ts +++ b/packages/contentstack-import/src/import/modules/entries.ts @@ -284,18 +284,18 @@ export default class EntriesImport extends BaseClass { ); } else { log.debug(`Loaded ${Object.keys(this.envs).length} environments.`, this.importConfig.context); - } - for (let entryRequestOption of entryRequestOptions) { - await this.publishEntries(entryRequestOption).catch((error) => { - handleAndLogError( - error, - { ...this.importConfig.context, cTUid: entryRequestOption.cTUid, locale: entryRequestOption.locale }, - `Error in publishing entries of ${entryRequestOption.cTUid} in locale ${entryRequestOption.locale}`, - ); - }); + for (let entryRequestOption of entryRequestOptions) { + await this.publishEntries(entryRequestOption).catch((error) => { + handleAndLogError( + error, + { ...this.importConfig.context, cTUid: entryRequestOption.cTUid, locale: entryRequestOption.locale }, + `Error in publishing entries of ${entryRequestOption.cTUid} in locale ${entryRequestOption.locale}`, + ); + }); + } + log.success('All the entries have been published successfully', this.importConfig.context); } - log.success('All the entries have been published successfully', this.importConfig.context); } else { log.info('Skipping entry publishing as per configuration...', this.importConfig.context); } diff --git a/packages/contentstack-import/test/unit/import/modules/entries.test.ts b/packages/contentstack-import/test/unit/import/modules/entries.test.ts index a0b530236..bf0ea2445 100644 --- a/packages/contentstack-import/test/unit/import/modules/entries.test.ts +++ b/packages/contentstack-import/test/unit/import/modules/entries.test.ts @@ -2886,8 +2886,8 @@ describe('EntriesImport', () => { await entriesImport.start(); - // publishEntries is called but is a no-op — envs is empty so serializePublishEntries nulls all entries - expect(publishEntriesStub.called).to.be.true; + // publish loop is skipped entirely when envs is empty — no pointless API work + expect(publishEntriesStub.called).to.be.false; // createEntryDataForVariantEntry must always run regardless of environments expect(createEntryDataForVariantEntryStub.called).to.be.true; }); diff --git a/packages/contentstack-variants/src/import/experiences.ts b/packages/contentstack-variants/src/import/experiences.ts index f91848b80..d65075e1a 100644 --- a/packages/contentstack-variants/src/import/experiences.ts +++ b/packages/contentstack-variants/src/import/experiences.ts @@ -203,7 +203,7 @@ export default class Experiences extends PersonalizationAdapter { // Process each version and map them by status versions.forEach((version) => { let versionReqObj = lookUpAudiences(version, this.audiencesUid) as CreateExperienceVersionInput; - versionReqObj = lookUpEvents(version, this.eventsUid) as CreateExperienceVersionInput; + versionReqObj = lookUpEvents(versionReqObj, this.eventsUid) as CreateExperienceVersionInput; if (versionReqObj && versionReqObj.status && (versionReqObj.variants?.length ?? 0) > 0) { versionMap[versionReqObj.status] = versionReqObj; From ea05a5efbc1c734ebf960f075e9dbff6755c5dcd Mon Sep 17 00:00:00 2001 From: raj pandey Date: Fri, 10 Jul 2026 13:27:34 +0530 Subject: [PATCH 6/6] fix(variants): guard versionMap against unrecognized status values [DX-9469] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If versionReqObj.status was e.g. PAUSED (not PAUSE), the previous code silently added a 4th key to the Record-typed map, causing Object.values to find a non-undefined entry and return true — even though handleVersionUpdateOrCreate only destructures ACTIVE/DRAFT/PAUSE and would have processed nothing. The experience UID then incorrectly entered the polling pending list, reintroducing the timeout bug. Fix: narrow versionMap to a typed object with only the three known keys and validate status against a HANDLED_STATUSES set before writing. Unrecognized statuses are logged and skipped. Also aligns the variant-skip warning message with the generic wording adopted in fix/DX-9469-v2. Co-Authored-By: Claude Sonnet 4.6 --- .../src/import/experiences.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/contentstack-variants/src/import/experiences.ts b/packages/contentstack-variants/src/import/experiences.ts index d65075e1a..0e8b6f67d 100644 --- a/packages/contentstack-variants/src/import/experiences.ts +++ b/packages/contentstack-variants/src/import/experiences.ts @@ -194,11 +194,8 @@ export default class Experiences extends PersonalizationAdapter { const versions = fsUtil.readFile(versionsPath, true) as ExperienceStruct[]; log.debug(`Found ${versions.length} versions for experience: ${oldExperienceUid}`, this.config.context); - const versionMap: Record = { - ACTIVE: undefined, - DRAFT: undefined, - PAUSE: undefined, - }; + const HANDLED_STATUSES = new Set(['ACTIVE', 'DRAFT', 'PAUSE']); + const versionMap: { ACTIVE?: CreateExperienceVersionInput; DRAFT?: CreateExperienceVersionInput; PAUSE?: CreateExperienceVersionInput } = {}; // Process each version and map them by status versions.forEach((version) => { @@ -206,10 +203,14 @@ export default class Experiences extends PersonalizationAdapter { versionReqObj = lookUpEvents(versionReqObj, this.eventsUid) as CreateExperienceVersionInput; if (versionReqObj && versionReqObj.status && (versionReqObj.variants?.length ?? 0) > 0) { - versionMap[versionReqObj.status] = versionReqObj; + if (!HANDLED_STATUSES.has(versionReqObj.status)) { + log.warn(`Skipping version with unrecognized status "${versionReqObj.status}" — expected one of ACTIVE, DRAFT, PAUSE`, this.config.context); + return; + } + versionMap[versionReqObj.status as 'ACTIVE' | 'DRAFT' | 'PAUSE'] = versionReqObj; log.debug(`Mapped version with status: ${versionReqObj.status}`, this.config.context); } else if (versionReqObj?.status && !(versionReqObj.variants?.length ?? 0)) { - log.warn(`Skipping version ${versionReqObj.status}: no valid variants after audience mapping — variants may have had no audiences or all audiences were unmapped`, this.config.context); + log.warn(`Skipping version ${versionReqObj.status}: no valid variants after audience/event mapping`, this.config.context); } });