Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions packages/contentstack-variants/src/import/experiences.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -145,6 +143,8 @@ export default class Experiences extends PersonalizationAdapter<ImportConfig> {

log.info(`Processing ${experiencesCount} experiences for import`, this.config.context);

const experienceUidsWithVariants = new Set<string>();

for (const experience of this.experiences) {
const { uid, ...restExperienceData } = experience;
log.debug(`Processing experience: ${uid}`, this.config.context);
Expand All @@ -162,7 +162,9 @@ export default class Experiences extends PersonalizationAdapter<ImportConfig> {

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}`);
}
Expand All @@ -184,7 +186,7 @@ export default class Experiences extends PersonalizationAdapter<ImportConfig> {
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);
Expand Down Expand Up @@ -258,7 +260,7 @@ export default class Experiences extends PersonalizationAdapter<ImportConfig> {

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[];
Expand All @@ -279,12 +281,17 @@ export default class Experiences extends PersonalizationAdapter<ImportConfig> {
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
Expand Down Expand Up @@ -414,6 +421,10 @@ export default class Experiences extends PersonalizationAdapter<ImportConfig> {
);
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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
},
};
214 changes: 214 additions & 0 deletions packages/contentstack-variants/test/unit/import/experiences.test.ts
Original file line number Diff line number Diff line change
@@ -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 =>
({
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading