Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,14 @@ export class ImportSpaces {
try {
const workspaceImporter = new ImportWorkspace(apiConfig, importContext);
workspaceImporter.setParentProgressManager(progress);
const result = await workspaceImporter.start(spaceUid, spaceDir, existingSpaceUids, spaceProcess);
const result = await workspaceImporter.start(
spaceUid,
spaceDir,
existingSpaceUids,
spaceProcess,
configOptions.targetDefaultSpaceUid,
configOptions.targetDefaultWorkspaceUid,
);

// Newly created spaces get a new uid — add so later iterations in this run see it.
existingSpaceUids.add(result.newSpaceUid);
Expand Down
16 changes: 16 additions & 0 deletions packages/contentstack-asset-management/src/import/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export default class ImportWorkspace extends AssetManagementImportAdapter {
spaceDir: string,
existingSpaceUids: Set<string> = new Set(),
spaceProcessName?: string,
targetDefaultSpaceUid?: string,
targetDefaultWorkspaceUid?: string,
): Promise<WorkspaceResult> {
await this.init();

Expand Down Expand Up @@ -64,6 +66,20 @@ export default class ImportWorkspace extends AssetManagementImportAdapter {
assetsImporter.setProcessName(spaceProcessName);
}

// Map source default space → existing target default space (cross-org migration).
// The caller supplies the uid of the pre-existing target default space; we upload
// source assets into it instead of creating a new space.
if (isDefault && targetDefaultSpaceUid) {
const newSpaceUid = targetDefaultSpaceUid;
const resolvedWorkspaceUid = targetDefaultWorkspaceUid ?? workspaceUid;
log.info(
`Source default space "${oldSpaceUid}" mapped to existing target default space "${newSpaceUid}".`,
this.importContext.context,
);
const { uidMap, urlMap } = await assetsImporter.start(newSpaceUid, spaceDir);
return { oldSpaceUid, newSpaceUid, workspaceUid: resolvedWorkspaceUid, isDefault: true, uidMap, urlMap };
}

// Reuse: target org already has a space with the same uid as the export directory.
if (existingSpaceUids.has(oldSpaceUid)) {
log.info(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,16 @@ export type ImportSpacesOptions = {
mapperUidFileName?: string;
mapperUrlFileName?: string;
mapperSpaceUidFileName?: string;
/**
* UID of the already-existing default space in the target org.
* When set, the source default space is imported into this space instead of creating a new one.
*/
targetDefaultSpaceUid?: string;
/**
* Workspace link UID of the existing default workspace in the target branch's `am_v2.linked_workspaces`.
* Returned in SpaceMapping.workspaceUid so downstream branch-linking logic can identify the entry correctly.
*/
targetDefaultWorkspaceUid?: string;
};

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { expect } from 'chai';
import sinon from 'sinon';
import { CLIProgressManager, configHandler } from '@contentstack/cli-utilities';

import { ImportSpaces } from '../../../src/import/spaces';
import ImportWorkspace from '../../../src/import/workspaces';
import ImportFields from '../../../src/import/fields';
import ImportAssetTypes from '../../../src/import/asset-types';
import { AssetManagementAdapter } from '../../../src/utils/asset-management-api-adapter';
import { AssetManagementImportAdapter } from '../../../src/import/base';
import { PROCESS_NAMES } from '../../../src/constants/index';

import type { ImportSpacesOptions } from '../../../src/types/asset-management-api';

describe('ImportSpaces', () => {
const baseOptions: ImportSpacesOptions = {
contentDir: '/tmp/import',
assetManagementUrl: 'https://am.example.com',
org_uid: 'org-1',
apiKey: 'api-key-1',
host: 'https://api.contentstack.io/v3',
};

const fakeProgress = {
addProcess: sinon.stub().returnsThis(),
startProcess: sinon.stub().returnsThis(),
updateStatus: sinon.stub().returnsThis(),
tick: sinon.stub(),
completeProcess: sinon.stub(),
};

beforeEach(() => {
sinon.stub(configHandler, 'get').returns({ showConsoleLogs: false });
sinon.stub(CLIProgressManager, 'createNested').returns(fakeProgress as any);
// init and listSpaces live on AssetManagementAdapter (the common base).
// Stubbing the base once covers both the adapter used for listSpaces and ImportWorkspace.
sinon.stub(AssetManagementAdapter.prototype, 'init' as any).resolves();
sinon.stub(AssetManagementAdapter.prototype, 'listSpaces' as any).resolves({ spaces: [] });
sinon.stub(ImportFields.prototype, 'start').resolves();
sinon.stub(ImportFields.prototype, 'setParentProgressManager');
sinon.stub(ImportAssetTypes.prototype, 'start').resolves();
sinon.stub(ImportAssetTypes.prototype, 'setParentProgressManager');
sinon.stub(ImportWorkspace.prototype, 'setParentProgressManager');

fakeProgress.addProcess.resetHistory();
fakeProgress.addProcess.returnsThis();
fakeProgress.startProcess.resetHistory();
fakeProgress.startProcess.returnsThis();
fakeProgress.completeProcess.reset();
fakeProgress.tick.reset();
});

afterEach(() => {
sinon.restore();
});

const stubSpaceDirs = (dirs: string[]) => {
const fsMock = require('node:fs');
sinon.stub(fsMock, 'readdirSync').returns(dirs as any);
sinon.stub(fsMock, 'statSync').returns({ isDirectory: () => true } as any);
};

describe('targetDefaultSpaceUid threading', () => {
it('should pass targetDefaultSpaceUid and targetDefaultWorkspaceUid to ImportWorkspace.start()', async () => {
stubSpaceDirs(['am-space-1']);
const startStub = sinon
.stub(ImportWorkspace.prototype, 'start')
.resolves({ oldSpaceUid: 'am-space-1', newSpaceUid: 'target-space-3', workspaceUid: 'ws-3', isDefault: true, uidMap: {}, urlMap: {} });

const options: ImportSpacesOptions = {
...baseOptions,
targetDefaultSpaceUid: 'target-space-3',
targetDefaultWorkspaceUid: 'ws-3',
};
const importer = new ImportSpaces(options);
await importer.start();

expect(startStub.callCount).to.equal(1);
const args = startStub.firstCall.args;
expect(args[4]).to.equal('target-space-3');
expect(args[5]).to.equal('ws-3');
});

it('should pass undefined to ImportWorkspace when targetDefaultSpaceUid is not set', async () => {
stubSpaceDirs(['am-space-1']);
const startStub = sinon
.stub(ImportWorkspace.prototype, 'start')
.resolves({ oldSpaceUid: 'am-space-1', newSpaceUid: 'new-space', workspaceUid: 'main', isDefault: false, uidMap: {}, urlMap: {} });

const importer = new ImportSpaces(baseOptions);
await importer.start();

expect(startStub.callCount).to.equal(1);
expect(startStub.firstCall.args[4]).to.be.undefined;
expect(startStub.firstCall.args[5]).to.be.undefined;
});

it('should record the correct spaceUidMap entry when default space is remapped', async () => {
stubSpaceDirs(['am-space-1']);
sinon
.stub(ImportWorkspace.prototype, 'start')
.resolves({ oldSpaceUid: 'am-space-1', newSpaceUid: 'target-space-3', workspaceUid: 'ws-3', isDefault: true, uidMap: {}, urlMap: {} });

const options: ImportSpacesOptions = {
...baseOptions,
targetDefaultSpaceUid: 'target-space-3',
};
const importer = new ImportSpaces(options);
const result = await importer.start();

expect(result.spaceUidMap['am-space-1']).to.equal('target-space-3');
expect(result.spaceMappings[0].newSpaceUid).to.equal('target-space-3');
expect(result.spaceMappings[0].isDefault).to.equal(true);
});

it('should process non-default spaces normally alongside the remapped default space', async () => {
stubSpaceDirs(['am-space-1', 'am-space-2']);
const startStub = sinon.stub(ImportWorkspace.prototype, 'start');
startStub.onFirstCall().resolves({
oldSpaceUid: 'am-space-1', newSpaceUid: 'target-space-3', workspaceUid: 'ws-3', isDefault: true, uidMap: {}, urlMap: {},
});
startStub.onSecondCall().resolves({
oldSpaceUid: 'am-space-2', newSpaceUid: 'brand-new-space', workspaceUid: 'main', isDefault: false, uidMap: {}, urlMap: {},
});

const options: ImportSpacesOptions = {
...baseOptions,
targetDefaultSpaceUid: 'target-space-3',
targetDefaultWorkspaceUid: 'ws-3',
};
const importer = new ImportSpaces(options);
const result = await importer.start();

expect(result.spaceMappings).to.have.lengthOf(2);
expect(result.spaceUidMap['am-space-1']).to.equal('target-space-3');
expect(result.spaceUidMap['am-space-2']).to.equal('brand-new-space');
});
});

describe('no spaces scenario', () => {
it('should return empty maps when spaces directory has no am* dirs', async () => {
stubSpaceDirs([]);

const importer = new ImportSpaces(baseOptions);
const result = await importer.start();

expect(result.spaceMappings).to.deep.equal([]);
expect(result.spaceUidMap).to.deep.equal({});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { expect } from 'chai';
import sinon from 'sinon';

import ImportWorkspace from '../../../src/import/workspaces';
import ImportAssets from '../../../src/import/assets';
import { AssetManagementImportAdapter } from '../../../src/import/base';

import type { AssetManagementAPIConfig, ImportContext } from '../../../src/types/asset-management-api';

describe('ImportWorkspace', () => {
const apiConfig: AssetManagementAPIConfig = {
baseURL: 'https://am.example.com',
headers: { organization_uid: 'org-1' },
};

const importContext: ImportContext = {
spacesRootPath: '/tmp/import/spaces',
apiKey: 'api-key-1',
host: 'https://api.contentstack.io/v3',
org_uid: 'org-1',
};

const spaceDir = '/tmp/import/spaces/am-space-1';

const stubMetadata = (metadata: Record<string, unknown>) => {
const fs = require('node:fs');
sinon.stub(fs, 'readFileSync').returns(JSON.stringify(metadata));
};

beforeEach(() => {
sinon.stub(AssetManagementImportAdapter.prototype, 'init' as any).resolves();
sinon.stub(AssetManagementImportAdapter.prototype, 'tick' as any);
sinon.stub(ImportAssets.prototype, 'setParentProgressManager');
sinon.stub(ImportAssets.prototype, 'setProcessName' as any);
});

afterEach(() => {
sinon.restore();
});

describe('default-space mapping path', () => {
it('should use targetDefaultSpaceUid and skip createSpace when isDefault=true', async () => {
stubMetadata({ title: 'Source Default Space', is_default: true });
const createSpaceStub = sinon.stub(AssetManagementImportAdapter.prototype, 'createSpace' as any);
const assetsStartStub = sinon.stub(ImportAssets.prototype, 'start').resolves({ uidMap: {}, urlMap: {} });

const importer = new ImportWorkspace(apiConfig, importContext);
const result = await importer.start(
'am-space-1',
spaceDir,
new Set(),
undefined,
'target-default-space-uid',
'target-ws-uid',
);

expect(createSpaceStub.callCount).to.equal(0);
expect(assetsStartStub.callCount).to.equal(1);
expect(assetsStartStub.firstCall.args[0]).to.equal('target-default-space-uid');
expect(result.newSpaceUid).to.equal('target-default-space-uid');
expect(result.workspaceUid).to.equal('target-ws-uid');
expect(result.isDefault).to.equal(true);
expect(result.oldSpaceUid).to.equal('am-space-1');
});

it('should upload assets into the existing target default space (not identity-map)', async () => {
stubMetadata({ title: 'Source Default Space', is_default: true });
sinon.stub(AssetManagementImportAdapter.prototype, 'createSpace' as any);
const assetsStartStub = sinon.stub(ImportAssets.prototype, 'start').resolves({
uidMap: { 'old-asset-1': 'new-asset-1' },
urlMap: { 'old-url-1': 'new-url-1' },
});

const importer = new ImportWorkspace(apiConfig, importContext);
const result = await importer.start('am-space-1', spaceDir, new Set(), undefined, 'target-space-3');

expect(assetsStartStub.firstCall.args[0]).to.equal('target-space-3');
expect(result.uidMap).to.deep.equal({ 'old-asset-1': 'new-asset-1' });
expect(result.urlMap).to.deep.equal({ 'old-url-1': 'new-url-1' });
});

it('should fall back to "main" as workspaceUid when targetDefaultWorkspaceUid is not provided', async () => {
stubMetadata({ is_default: true });
sinon.stub(AssetManagementImportAdapter.prototype, 'createSpace' as any);
sinon.stub(ImportAssets.prototype, 'start').resolves({ uidMap: {}, urlMap: {} });

const importer = new ImportWorkspace(apiConfig, importContext);
const result = await importer.start('am-space-1', spaceDir, new Set(), undefined, 'target-space-3');

expect(result.workspaceUid).to.equal('main');
});

it('should NOT use the default-space path when isDefault=false even if targetDefaultSpaceUid is set', async () => {
stubMetadata({ title: 'Non-default Space', is_default: false });
const createSpaceStub = sinon
.stub(AssetManagementImportAdapter.prototype, 'createSpace' as any)
.resolves({ space: { uid: 'new-space-uid' } });
sinon.stub(ImportAssets.prototype, 'start').resolves({ uidMap: {}, urlMap: {} });

const importer = new ImportWorkspace(apiConfig, importContext);
const result = await importer.start('am-space-2', spaceDir, new Set(), undefined, 'target-space-3');

expect(createSpaceStub.callCount).to.equal(1);
expect(result.newSpaceUid).to.equal('new-space-uid');
});

it('should NOT use the default-space path when targetDefaultSpaceUid is undefined', async () => {
stubMetadata({ title: 'Source Default Space', is_default: true });
const createSpaceStub = sinon
.stub(AssetManagementImportAdapter.prototype, 'createSpace' as any)
.resolves({ space: { uid: 'brand-new-uid' } });
sinon.stub(ImportAssets.prototype, 'start').resolves({ uidMap: {}, urlMap: {} });

const importer = new ImportWorkspace(apiConfig, importContext);
const result = await importer.start('am-space-1', spaceDir, new Set());

expect(createSpaceStub.callCount).to.equal(1);
expect(result.newSpaceUid).to.equal('brand-new-uid');
});
});

describe('identity-reuse path (existing uid match)', () => {
it('should reuse existing space uid and call buildIdentityMappersFromExport', async () => {
stubMetadata({ title: 'Space', is_default: false });
const identityStub = sinon
.stub(ImportAssets.prototype, 'buildIdentityMappersFromExport')
.resolves({ uidMap: { a: 'a' }, urlMap: {} });
sinon.stub(AssetManagementImportAdapter.prototype, 'createSpace' as any);

const importer = new ImportWorkspace(apiConfig, importContext);
const result = await importer.start('am-space-existing', spaceDir, new Set(['am-space-existing']));

expect(identityStub.callCount).to.equal(1);
expect(result.newSpaceUid).to.equal('am-space-existing');
});
});

describe('create new space path', () => {
it('should create a new space and upload assets for non-default non-existing space', async () => {
stubMetadata({ title: 'Source Space 2', is_default: false });
const createStub = sinon
.stub(AssetManagementImportAdapter.prototype, 'createSpace' as any)
.resolves({ space: { uid: 'new-space-2-uid' } });
sinon.stub(ImportAssets.prototype, 'start').resolves({ uidMap: {}, urlMap: {} });

const importer = new ImportWorkspace(apiConfig, importContext);
const result = await importer.start('am-space-2', spaceDir, new Set());

expect(createStub.callCount).to.equal(1);
expect(result.newSpaceUid).to.equal('new-space-2-uid');
expect(result.isDefault).to.equal(false);
});
});
});
Loading
Loading