diff --git a/packages/contentstack-variants/src/import/experiences.ts b/packages/contentstack-variants/src/import/experiences.ts index b4693a667..534fda64c 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 { @@ -145,6 +143,8 @@ export default class Experiences extends PersonalizationAdapter { log.info(`Processing ${experiencesCount} experiences for import`, this.config.context); + const experienceUidsWithVariants = new Set(); + for (const experience of this.experiences) { const { uid, ...restExperienceData } = experience; log.debug(`Processing experience: ${uid}`, this.config.context); @@ -162,7 +162,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}`); } @@ -184,7 +186,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); @@ -258,7 +260,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[]; @@ -279,12 +281,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/event mapping`, 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 @@ -414,6 +421,10 @@ export default class Experiences extends PersonalizationAdapter { ); 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/test/helpers/mocha-root-hooks.js b/packages/contentstack-variants/test/helpers/mocha-root-hooks.js index 77d5715d0..3c709f9e6 100644 --- a/packages/contentstack-variants/test/helpers/mocha-root-hooks.js +++ b/packages/contentstack-variants/test/helpers/mocha-root-hooks.js @@ -1,11 +1,12 @@ /** * @contentstack/cli-utilities uses lazy-loaded Chalk 5; preload before tests that hit cliux. */ -const { loadChalk } = require('@contentstack/cli-utilities'); +const cliUtils = require('@contentstack/cli-utilities'); +const loadChalk = cliUtils.loadChalk; exports.mochaHooks = { beforeAll() { this.timeout(30_000); - return loadChalk(); + if (typeof loadChalk === 'function') return loadChalk(); }, }; diff --git a/packages/contentstack-variants/test/unit/import/experiences.test.ts b/packages/contentstack-variants/test/unit/import/experiences.test.ts index f25989901..4c2638275 100644 --- a/packages/contentstack-variants/test/unit/import/experiences.test.ts +++ b/packages/contentstack-variants/test/unit/import/experiences.test.ts @@ -1,5 +1,8 @@ +import sinon from 'sinon'; import { expect } from 'chai'; +import * as fs from 'fs'; import { Import, ImportConfig } from '../../../src'; +import { fsUtil } from '../../../src/utils'; const makeImportConfig = (branchName?: string): ImportConfig => ({ @@ -27,6 +30,217 @@ const makeImportConfig = (branchName?: string): ImportConfig => ...(branchName ? { branchName } : {}), } as unknown as ImportConfig); +// ─── helpers ──────────────────────────────────────────────────────────────── + +function makeExperience(uid: string, latestVersion = 1) { + return { uid, latestVersion, name: `Experience ${uid}` }; +} + +function makeVersion(status: string, audiences: string[] = ['aud-001']) { + return { + uid: `ver-${status.toLowerCase()}`, + status, + variants: [{ __type: 'SegmentedVariant', audiences }], + }; +} + +// ─── importExperienceVersions ───────────────────────────────────────────────── + +describe('ImportExperiences — importExperienceVersions (DX-9469 fix)', () => { + let instance: any; + let tmpDir: string; + + beforeEach(() => { + instance = new Import.Experiences(makeImportConfig()); + instance.audiencesUid = { 'aud-001': 'aud-new-001' }; + instance.eventsUid = {}; + tmpDir = fs.mkdtempSync(require('os').tmpdir() + '/exp-test-'); + fs.mkdirSync(require('path').join(tmpDir, 'versions'), { recursive: true }); + instance.experiencesDirPath = tmpDir; + }); + + afterEach(() => { + sinon.restore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeVersionFile(uid: string, versions: any[]) { + fs.writeFileSync( + require('path').join(tmpDir, 'versions', `${uid}.json`), + JSON.stringify(versions), + ); + } + + it('returns false (not undefined) when versions file does not exist', async () => { + // no file written → path does not exist + const result = await instance.importExperienceVersions(makeExperience('exp-001'), 'exp-no-file'); + expect(result).to.equal(false); + }); + + it('returns false when all versions have Lytics-only audiences (versionMap all-undefined)', async () => { + const lyticsVersion = { + uid: 'ver-active', + status: 'ACTIVE', + variants: [{ __type: 'SegmentedVariant', lyticsAudiences: ['lytics-123'], audiences: [] }], + }; + writeVersionFile('exp-002', [lyticsVersion]); + const handleStub = sinon.stub(instance, 'handleVersionUpdateOrCreate').resolves(); + + const result = await instance.importExperienceVersions(makeExperience('exp-002'), 'exp-002'); + + expect(result).to.equal(false); + expect(handleStub.called).to.be.false; + }); + + it('returns false when all versions have unmapped CS audiences (variants stripped to empty)', async () => { + writeVersionFile('exp-003', [makeVersion('ACTIVE', ['aud-unmapped'])]); + const handleStub = sinon.stub(instance, 'handleVersionUpdateOrCreate').resolves(); + + const result = await instance.importExperienceVersions(makeExperience('exp-003'), 'exp-003'); + + expect(result).to.equal(false); + expect(handleStub.called).to.be.false; + }); + + it('returns true when at least one valid version exists and calls handleVersionUpdateOrCreate', async () => { + writeVersionFile('exp-004', [makeVersion('ACTIVE')]); + const handleStub = sinon.stub(instance, 'handleVersionUpdateOrCreate').resolves(); + + const result = await instance.importExperienceVersions(makeExperience('exp-004'), 'exp-004'); + + expect(result).to.equal(true); + expect(handleStub.calledOnce).to.be.true; + }); + + it('returns true when only DRAFT version is valid (ACTIVE missing)', async () => { + writeVersionFile('exp-005', [makeVersion('DRAFT')]); + const handleStub = sinon.stub(instance, 'handleVersionUpdateOrCreate').resolves(); + + const result = await instance.importExperienceVersions(makeExperience('exp-005'), 'exp-005'); + + expect(result).to.equal(true); + expect(handleStub.calledOnce).to.be.true; + }); +}); + +// ─── import() — Set-based pending list (DX-9469 fix) ───────────────────────── + +describe('ImportExperiences — pending list only includes experiences with variants', () => { + let instance: any; + + afterEach(() => sinon.restore()); + + it('empty experiences list → pendingVariantAndVariantGrpForExperience stays empty', async () => { + instance = new Import.Experiences(makeImportConfig()); + sinon.stub(instance, 'analyzeExperiences' as any).resolves([false, 0]); + + await instance.import(); + + expect(instance.pendingVariantAndVariantGrpForExperience).to.deep.equal([]); + }); + + it('experience with no valid versions is NOT added to pending list', async () => { + instance = new Import.Experiences(makeImportConfig()); + sinon.stub(instance, 'analyzeExperiences' as any).resolves([true, 1]); + sinon.stub(instance, 'init' as any).resolves(); + sinon.stub(fsUtil, 'makeDirectory').resolves(); + instance.experiences = [makeExperience('old-exp-a')]; + sinon.stub(instance, 'createExperience' as any).resolves(makeExperience('new-exp-a')); + // importExperienceVersions returns false → UID must NOT enter the Set + sinon.stub(instance, 'importExperienceVersions' as any).resolves(false); + sinon.stub(instance, 'validateVariantGroupAndVariantsCreated').resolves(true); + sinon.stub(instance, 'attachCTsInExperience').resolves(); + sinon.stub(instance, 'createVariantIdMapper').resolves(); + sinon.stub(fsUtil, 'writeFile').returns(undefined); + sinon.stub(instance, 'updateProgress' as any).returns(undefined); + sinon.stub(instance, 'createSimpleProgress' as any).returns({ tick: () => {}, complete: () => {} }); + sinon.stub(instance, 'completeProgress' as any).returns(undefined); + + await instance.import(); + + expect(instance.pendingVariantAndVariantGrpForExperience).to.deep.equal([]); + }); + + it('experience with valid versions IS added to pending list', async () => { + instance = new Import.Experiences(makeImportConfig()); + sinon.stub(instance, 'analyzeExperiences' as any).resolves([true, 1]); + sinon.stub(instance, 'init' as any).resolves(); + sinon.stub(fsUtil, 'makeDirectory').resolves(); + instance.experiences = [makeExperience('old-exp-b')]; + sinon.stub(instance, 'createExperience' as any).resolves(makeExperience('new-exp-b')); + // importExperienceVersions returns true → UID MUST enter the Set + sinon.stub(instance, 'importExperienceVersions' as any).resolves(true); + sinon.stub(instance, 'validateVariantGroupAndVariantsCreated').resolves(true); + sinon.stub(instance, 'attachCTsInExperience').resolves(); + sinon.stub(instance, 'createVariantIdMapper').resolves(); + sinon.stub(fsUtil, 'writeFile').returns(undefined); + sinon.stub(instance, 'updateProgress' as any).returns(undefined); + sinon.stub(instance, 'createSimpleProgress' as any).returns({ tick: () => {}, complete: () => {} }); + sinon.stub(instance, 'completeProgress' as any).returns(undefined); + + await instance.import(); + + expect(instance.pendingVariantAndVariantGrpForExperience).to.deep.equal(['new-exp-b']); + }); +}); + +// ─── validateVariantGroupAndVariantsCreated — empty pending list ────────────── + +describe('ImportExperiences — validateVariantGroupAndVariantsCreated with empty pending list', () => { + afterEach(() => sinon.restore()); + + it('resolves true immediately when pendingVariantAndVariantGrpForExperience is empty', async () => { + const instance = new Import.Experiences(makeImportConfig()); + instance.pendingVariantAndVariantGrpForExperience = []; + const getExpStub = sinon.stub(instance, 'getExperience' as any).resolves({}); + + const result = await instance.validateVariantGroupAndVariantsCreated(); + + expect(result).to.equal(true); + expect(getExpStub.called).to.be.false; + }); +}); + +// ─── attachCTsInExperience — null guard (DX-9469 fix) ──────────────────────── + +describe('ImportExperiences — attachCTsInExperience null guard', () => { + afterEach(() => sinon.restore()); + + it('skips CT attachment gracefully when getVariantGroup returns no variantGroup', async () => { + const instance = new Import.Experiences(makeImportConfig()); + instance.experiencesUidMapper = { 'old-exp': 'new-exp' }; + sinon.stub(fsUtil, 'readFile') + .onFirstCall().returns(['ct-uid-1']) + .onSecondCall().returns({ 'old-exp': [{ uid: 'ct-uid-1', status: 'linked' }] }); + // getVariantGroup returns empty variant_groups → variantGroup is undefined + sinon.stub(instance, 'getVariantGroup' as any).resolves({ variant_groups: [] }); + const updateStub = sinon.stub(instance, 'updateVariantGroup' as any).resolves(); + + await instance.attachCTsInExperience(); + + // Must not throw; updateVariantGroup must NOT be called since variantGroup is missing + expect(updateStub.called).to.be.false; + }); + + it('attaches CTs normally when variantGroup exists', async () => { + const instance = new Import.Experiences(makeImportConfig()); + instance.experiencesUidMapper = { 'old-exp': 'new-exp' }; + sinon.stub(fsUtil, 'readFile') + .onFirstCall().returns(['ct-uid-1']) + .onSecondCall().returns({ 'old-exp': [{ uid: 'ct-uid-1', status: 'linked' }] }); + const fakeGroup = { uid: 'vg-001', content_types: [] }; + sinon.stub(instance, 'getVariantGroup' as any).resolves({ variant_groups: [fakeGroup] }); + const updateStub = sinon.stub(instance, 'updateVariantGroup' as any).resolves(); + + await instance.attachCTsInExperience(); + + expect(updateStub.calledOnce).to.be.true; + expect(fakeGroup.content_types).to.deep.equal([{ uid: 'ct-uid-1', status: 'linked' }]); + }); +}); + +// ─── branch header (pre-existing) ──────────────────────────────────────────── + describe('ImportExperiences — branch header', () => { describe('constructor (cmaConfig headers)', () => { it('includes branch header in cmaConfig.headers when branchName is set', () => {