Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
cs-raj marked this conversation as resolved.
);
return;
} else {
log.debug(`Loaded ${Object.keys(this.envs).length} environments.`, this.importConfig.context);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
29 changes: 20 additions & 9 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 @@ -119,10 +117,12 @@ export default class Experiences extends PersonalizationAdapter<ImportConfig> {
const experiences = fsUtil.readFile(this.experiencesPath, true) as ExperienceStruct[];
log.info(`Found ${experiences.length} experiences to import`, this.config.context);

const experienceUidsWithVariants = new Set<string>();

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
Expand All @@ -135,7 +135,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 @@ -145,7 +147,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 @@ -175,7 +177,7 @@ export default class Experiences extends PersonalizationAdapter<ImportConfig> {
/**
* 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<boolean> {
log.debug(`Importing versions for experience: ${oldExperienceUid}`, this.config.context);

const versionsPath = resolve(
Expand All @@ -186,7 +188,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 @@ -207,12 +209,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 mapping — variants may have had no audiences or all audiences were unmapped`, this.config.context);
Comment thread
cs-raj marked this conversation as resolved.
Outdated
}
});

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);
Comment thread
cs-raj marked this conversation as resolved.
return true;
}

// Helper method to handle version update or creation logic
Expand Down Expand Up @@ -333,6 +340,10 @@ export default class Experiences extends PersonalizationAdapter<ImportConfig> {
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export default class VariantEntries extends VariantAdapter<VariantHttpClient<Imp

log.debug(`Checking for variant entry data file: ${filePath}`, this.config.context);
if (!existsSync(filePath)) {
log.warn(`Variant entry data file not found at path: ${filePath}`, this.config.context);
log.debug(`No variant entries to import (data-for-variant-entry.json not found at: ${filePath})`, this.config.context);
return;
Comment thread
cs-raj marked this conversation as resolved.
}

Expand Down
251 changes: 251 additions & 0 deletions packages/contentstack-variants/test/unit/import/experiences.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
'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);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"exp-valid": [
{ "uid": "ct1", "status": "linked" }
],
"exp-mixed": [
{ "uid": "ct1", "status": "linked" },
{ "uid": "ct2", "status": "linked" }
]
}
Loading
Loading