Skip to content

Commit 55b4401

Browse files
authored
Merge pull request #131 from contentstack/feat/DX-7035
Default workspace support while importing
2 parents 9f6bb8f + 4279b78 commit 55b4401

9 files changed

Lines changed: 601 additions & 8 deletions

File tree

packages/contentstack-asset-management/src/import/spaces.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,14 @@ export class ImportSpaces {
158158
try {
159159
const workspaceImporter = new ImportWorkspace(apiConfig, importContext);
160160
workspaceImporter.setParentProgressManager(progress);
161-
const result = await workspaceImporter.start(spaceUid, spaceDir, existingSpaceUids, spaceProcess);
161+
const result = await workspaceImporter.start(
162+
spaceUid,
163+
spaceDir,
164+
existingSpaceUids,
165+
spaceProcess,
166+
configOptions.targetDefaultSpaceUid,
167+
configOptions.targetDefaultWorkspaceUid,
168+
);
162169

163170
// Newly created spaces get a new uid — add so later iterations in this run see it.
164171
existingSpaceUids.add(result.newSpaceUid);

packages/contentstack-asset-management/src/import/workspaces.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export default class ImportWorkspace extends AssetManagementImportAdapter {
3535
spaceDir: string,
3636
existingSpaceUids: Set<string> = new Set(),
3737
spaceProcessName?: string,
38+
targetDefaultSpaceUid?: string,
39+
targetDefaultWorkspaceUid?: string,
3840
): Promise<WorkspaceResult> {
3941
await this.init();
4042

@@ -64,6 +66,20 @@ export default class ImportWorkspace extends AssetManagementImportAdapter {
6466
assetsImporter.setProcessName(spaceProcessName);
6567
}
6668

69+
// Map source default space → existing target default space (cross-org migration).
70+
// The caller supplies the uid of the pre-existing target default space; we upload
71+
// source assets into it instead of creating a new space.
72+
if (isDefault && targetDefaultSpaceUid) {
73+
const newSpaceUid = targetDefaultSpaceUid;
74+
const resolvedWorkspaceUid = targetDefaultWorkspaceUid ?? workspaceUid;
75+
log.info(
76+
`Source default space "${oldSpaceUid}" mapped to existing target default space "${newSpaceUid}".`,
77+
this.importContext.context,
78+
);
79+
const { uidMap, urlMap } = await assetsImporter.start(newSpaceUid, spaceDir);
80+
return { oldSpaceUid, newSpaceUid, workspaceUid: resolvedWorkspaceUid, isDefault: true, uidMap, urlMap };
81+
}
82+
6783
// Reuse: target org already has a space with the same uid as the export directory.
6884
if (existingSpaceUids.has(oldSpaceUid)) {
6985
log.info(

packages/contentstack-asset-management/src/types/asset-management-api.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,16 @@ export type ImportSpacesOptions = {
243243
mapperUidFileName?: string;
244244
mapperUrlFileName?: string;
245245
mapperSpaceUidFileName?: string;
246+
/**
247+
* UID of the already-existing default space in the target org.
248+
* When set, the source default space is imported into this space instead of creating a new one.
249+
*/
250+
targetDefaultSpaceUid?: string;
251+
/**
252+
* Workspace link UID of the existing default workspace in the target branch's `am_v2.linked_workspaces`.
253+
* Returned in SpaceMapping.workspaceUid so downstream branch-linking logic can identify the entry correctly.
254+
*/
255+
targetDefaultWorkspaceUid?: string;
246256
};
247257

248258
/**
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { expect } from 'chai';
2+
import sinon from 'sinon';
3+
import { CLIProgressManager, configHandler } from '@contentstack/cli-utilities';
4+
5+
import { ImportSpaces } from '../../../src/import/spaces';
6+
import ImportWorkspace from '../../../src/import/workspaces';
7+
import ImportFields from '../../../src/import/fields';
8+
import ImportAssetTypes from '../../../src/import/asset-types';
9+
import { AssetManagementAdapter } from '../../../src/utils/asset-management-api-adapter';
10+
import { AssetManagementImportAdapter } from '../../../src/import/base';
11+
import { PROCESS_NAMES } from '../../../src/constants/index';
12+
13+
import type { ImportSpacesOptions } from '../../../src/types/asset-management-api';
14+
15+
describe('ImportSpaces', () => {
16+
const baseOptions: ImportSpacesOptions = {
17+
contentDir: '/tmp/import',
18+
assetManagementUrl: 'https://am.example.com',
19+
org_uid: 'org-1',
20+
apiKey: 'api-key-1',
21+
host: 'https://api.contentstack.io/v3',
22+
};
23+
24+
const fakeProgress = {
25+
addProcess: sinon.stub().returnsThis(),
26+
startProcess: sinon.stub().returnsThis(),
27+
updateStatus: sinon.stub().returnsThis(),
28+
tick: sinon.stub(),
29+
completeProcess: sinon.stub(),
30+
};
31+
32+
beforeEach(() => {
33+
sinon.stub(configHandler, 'get').returns({ showConsoleLogs: false });
34+
sinon.stub(CLIProgressManager, 'createNested').returns(fakeProgress as any);
35+
// init and listSpaces live on AssetManagementAdapter (the common base).
36+
// Stubbing the base once covers both the adapter used for listSpaces and ImportWorkspace.
37+
sinon.stub(AssetManagementAdapter.prototype, 'init' as any).resolves();
38+
sinon.stub(AssetManagementAdapter.prototype, 'listSpaces' as any).resolves({ spaces: [] });
39+
sinon.stub(ImportFields.prototype, 'start').resolves();
40+
sinon.stub(ImportFields.prototype, 'setParentProgressManager');
41+
sinon.stub(ImportAssetTypes.prototype, 'start').resolves();
42+
sinon.stub(ImportAssetTypes.prototype, 'setParentProgressManager');
43+
sinon.stub(ImportWorkspace.prototype, 'setParentProgressManager');
44+
45+
fakeProgress.addProcess.resetHistory();
46+
fakeProgress.addProcess.returnsThis();
47+
fakeProgress.startProcess.resetHistory();
48+
fakeProgress.startProcess.returnsThis();
49+
fakeProgress.completeProcess.reset();
50+
fakeProgress.tick.reset();
51+
});
52+
53+
afterEach(() => {
54+
sinon.restore();
55+
});
56+
57+
const stubSpaceDirs = (dirs: string[]) => {
58+
const fsMock = require('node:fs');
59+
sinon.stub(fsMock, 'readdirSync').returns(dirs as any);
60+
sinon.stub(fsMock, 'statSync').returns({ isDirectory: () => true } as any);
61+
};
62+
63+
describe('targetDefaultSpaceUid threading', () => {
64+
it('should pass targetDefaultSpaceUid and targetDefaultWorkspaceUid to ImportWorkspace.start()', async () => {
65+
stubSpaceDirs(['am-space-1']);
66+
const startStub = sinon
67+
.stub(ImportWorkspace.prototype, 'start')
68+
.resolves({ oldSpaceUid: 'am-space-1', newSpaceUid: 'target-space-3', workspaceUid: 'ws-3', isDefault: true, uidMap: {}, urlMap: {} });
69+
70+
const options: ImportSpacesOptions = {
71+
...baseOptions,
72+
targetDefaultSpaceUid: 'target-space-3',
73+
targetDefaultWorkspaceUid: 'ws-3',
74+
};
75+
const importer = new ImportSpaces(options);
76+
await importer.start();
77+
78+
expect(startStub.callCount).to.equal(1);
79+
const args = startStub.firstCall.args;
80+
expect(args[4]).to.equal('target-space-3');
81+
expect(args[5]).to.equal('ws-3');
82+
});
83+
84+
it('should pass undefined to ImportWorkspace when targetDefaultSpaceUid is not set', async () => {
85+
stubSpaceDirs(['am-space-1']);
86+
const startStub = sinon
87+
.stub(ImportWorkspace.prototype, 'start')
88+
.resolves({ oldSpaceUid: 'am-space-1', newSpaceUid: 'new-space', workspaceUid: 'main', isDefault: false, uidMap: {}, urlMap: {} });
89+
90+
const importer = new ImportSpaces(baseOptions);
91+
await importer.start();
92+
93+
expect(startStub.callCount).to.equal(1);
94+
expect(startStub.firstCall.args[4]).to.be.undefined;
95+
expect(startStub.firstCall.args[5]).to.be.undefined;
96+
});
97+
98+
it('should record the correct spaceUidMap entry when default space is remapped', async () => {
99+
stubSpaceDirs(['am-space-1']);
100+
sinon
101+
.stub(ImportWorkspace.prototype, 'start')
102+
.resolves({ oldSpaceUid: 'am-space-1', newSpaceUid: 'target-space-3', workspaceUid: 'ws-3', isDefault: true, uidMap: {}, urlMap: {} });
103+
104+
const options: ImportSpacesOptions = {
105+
...baseOptions,
106+
targetDefaultSpaceUid: 'target-space-3',
107+
};
108+
const importer = new ImportSpaces(options);
109+
const result = await importer.start();
110+
111+
expect(result.spaceUidMap['am-space-1']).to.equal('target-space-3');
112+
expect(result.spaceMappings[0].newSpaceUid).to.equal('target-space-3');
113+
expect(result.spaceMappings[0].isDefault).to.equal(true);
114+
});
115+
116+
it('should process non-default spaces normally alongside the remapped default space', async () => {
117+
stubSpaceDirs(['am-space-1', 'am-space-2']);
118+
const startStub = sinon.stub(ImportWorkspace.prototype, 'start');
119+
startStub.onFirstCall().resolves({
120+
oldSpaceUid: 'am-space-1', newSpaceUid: 'target-space-3', workspaceUid: 'ws-3', isDefault: true, uidMap: {}, urlMap: {},
121+
});
122+
startStub.onSecondCall().resolves({
123+
oldSpaceUid: 'am-space-2', newSpaceUid: 'brand-new-space', workspaceUid: 'main', isDefault: false, uidMap: {}, urlMap: {},
124+
});
125+
126+
const options: ImportSpacesOptions = {
127+
...baseOptions,
128+
targetDefaultSpaceUid: 'target-space-3',
129+
targetDefaultWorkspaceUid: 'ws-3',
130+
};
131+
const importer = new ImportSpaces(options);
132+
const result = await importer.start();
133+
134+
expect(result.spaceMappings).to.have.lengthOf(2);
135+
expect(result.spaceUidMap['am-space-1']).to.equal('target-space-3');
136+
expect(result.spaceUidMap['am-space-2']).to.equal('brand-new-space');
137+
});
138+
});
139+
140+
describe('no spaces scenario', () => {
141+
it('should return empty maps when spaces directory has no am* dirs', async () => {
142+
stubSpaceDirs([]);
143+
144+
const importer = new ImportSpaces(baseOptions);
145+
const result = await importer.start();
146+
147+
expect(result.spaceMappings).to.deep.equal([]);
148+
expect(result.spaceUidMap).to.deep.equal({});
149+
});
150+
});
151+
});
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { expect } from 'chai';
2+
import sinon from 'sinon';
3+
4+
import ImportWorkspace from '../../../src/import/workspaces';
5+
import ImportAssets from '../../../src/import/assets';
6+
import { AssetManagementImportAdapter } from '../../../src/import/base';
7+
8+
import type { AssetManagementAPIConfig, ImportContext } from '../../../src/types/asset-management-api';
9+
10+
describe('ImportWorkspace', () => {
11+
const apiConfig: AssetManagementAPIConfig = {
12+
baseURL: 'https://am.example.com',
13+
headers: { organization_uid: 'org-1' },
14+
};
15+
16+
const importContext: ImportContext = {
17+
spacesRootPath: '/tmp/import/spaces',
18+
apiKey: 'api-key-1',
19+
host: 'https://api.contentstack.io/v3',
20+
org_uid: 'org-1',
21+
};
22+
23+
const spaceDir = '/tmp/import/spaces/am-space-1';
24+
25+
const stubMetadata = (metadata: Record<string, unknown>) => {
26+
const fs = require('node:fs');
27+
sinon.stub(fs, 'readFileSync').returns(JSON.stringify(metadata));
28+
};
29+
30+
beforeEach(() => {
31+
sinon.stub(AssetManagementImportAdapter.prototype, 'init' as any).resolves();
32+
sinon.stub(AssetManagementImportAdapter.prototype, 'tick' as any);
33+
sinon.stub(ImportAssets.prototype, 'setParentProgressManager');
34+
sinon.stub(ImportAssets.prototype, 'setProcessName' as any);
35+
});
36+
37+
afterEach(() => {
38+
sinon.restore();
39+
});
40+
41+
describe('default-space mapping path', () => {
42+
it('should use targetDefaultSpaceUid and skip createSpace when isDefault=true', async () => {
43+
stubMetadata({ title: 'Source Default Space', is_default: true });
44+
const createSpaceStub = sinon.stub(AssetManagementImportAdapter.prototype, 'createSpace' as any);
45+
const assetsStartStub = sinon.stub(ImportAssets.prototype, 'start').resolves({ uidMap: {}, urlMap: {} });
46+
47+
const importer = new ImportWorkspace(apiConfig, importContext);
48+
const result = await importer.start(
49+
'am-space-1',
50+
spaceDir,
51+
new Set(),
52+
undefined,
53+
'target-default-space-uid',
54+
'target-ws-uid',
55+
);
56+
57+
expect(createSpaceStub.callCount).to.equal(0);
58+
expect(assetsStartStub.callCount).to.equal(1);
59+
expect(assetsStartStub.firstCall.args[0]).to.equal('target-default-space-uid');
60+
expect(result.newSpaceUid).to.equal('target-default-space-uid');
61+
expect(result.workspaceUid).to.equal('target-ws-uid');
62+
expect(result.isDefault).to.equal(true);
63+
expect(result.oldSpaceUid).to.equal('am-space-1');
64+
});
65+
66+
it('should upload assets into the existing target default space (not identity-map)', async () => {
67+
stubMetadata({ title: 'Source Default Space', is_default: true });
68+
sinon.stub(AssetManagementImportAdapter.prototype, 'createSpace' as any);
69+
const assetsStartStub = sinon.stub(ImportAssets.prototype, 'start').resolves({
70+
uidMap: { 'old-asset-1': 'new-asset-1' },
71+
urlMap: { 'old-url-1': 'new-url-1' },
72+
});
73+
74+
const importer = new ImportWorkspace(apiConfig, importContext);
75+
const result = await importer.start('am-space-1', spaceDir, new Set(), undefined, 'target-space-3');
76+
77+
expect(assetsStartStub.firstCall.args[0]).to.equal('target-space-3');
78+
expect(result.uidMap).to.deep.equal({ 'old-asset-1': 'new-asset-1' });
79+
expect(result.urlMap).to.deep.equal({ 'old-url-1': 'new-url-1' });
80+
});
81+
82+
it('should fall back to "main" as workspaceUid when targetDefaultWorkspaceUid is not provided', async () => {
83+
stubMetadata({ is_default: true });
84+
sinon.stub(AssetManagementImportAdapter.prototype, 'createSpace' as any);
85+
sinon.stub(ImportAssets.prototype, 'start').resolves({ uidMap: {}, urlMap: {} });
86+
87+
const importer = new ImportWorkspace(apiConfig, importContext);
88+
const result = await importer.start('am-space-1', spaceDir, new Set(), undefined, 'target-space-3');
89+
90+
expect(result.workspaceUid).to.equal('main');
91+
});
92+
93+
it('should NOT use the default-space path when isDefault=false even if targetDefaultSpaceUid is set', async () => {
94+
stubMetadata({ title: 'Non-default Space', is_default: false });
95+
const createSpaceStub = sinon
96+
.stub(AssetManagementImportAdapter.prototype, 'createSpace' as any)
97+
.resolves({ space: { uid: 'new-space-uid' } });
98+
sinon.stub(ImportAssets.prototype, 'start').resolves({ uidMap: {}, urlMap: {} });
99+
100+
const importer = new ImportWorkspace(apiConfig, importContext);
101+
const result = await importer.start('am-space-2', spaceDir, new Set(), undefined, 'target-space-3');
102+
103+
expect(createSpaceStub.callCount).to.equal(1);
104+
expect(result.newSpaceUid).to.equal('new-space-uid');
105+
});
106+
107+
it('should NOT use the default-space path when targetDefaultSpaceUid is undefined', async () => {
108+
stubMetadata({ title: 'Source Default Space', is_default: true });
109+
const createSpaceStub = sinon
110+
.stub(AssetManagementImportAdapter.prototype, 'createSpace' as any)
111+
.resolves({ space: { uid: 'brand-new-uid' } });
112+
sinon.stub(ImportAssets.prototype, 'start').resolves({ uidMap: {}, urlMap: {} });
113+
114+
const importer = new ImportWorkspace(apiConfig, importContext);
115+
const result = await importer.start('am-space-1', spaceDir, new Set());
116+
117+
expect(createSpaceStub.callCount).to.equal(1);
118+
expect(result.newSpaceUid).to.equal('brand-new-uid');
119+
});
120+
});
121+
122+
describe('identity-reuse path (existing uid match)', () => {
123+
it('should reuse existing space uid and call buildIdentityMappersFromExport', async () => {
124+
stubMetadata({ title: 'Space', is_default: false });
125+
const identityStub = sinon
126+
.stub(ImportAssets.prototype, 'buildIdentityMappersFromExport')
127+
.resolves({ uidMap: { a: 'a' }, urlMap: {} });
128+
sinon.stub(AssetManagementImportAdapter.prototype, 'createSpace' as any);
129+
130+
const importer = new ImportWorkspace(apiConfig, importContext);
131+
const result = await importer.start('am-space-existing', spaceDir, new Set(['am-space-existing']));
132+
133+
expect(identityStub.callCount).to.equal(1);
134+
expect(result.newSpaceUid).to.equal('am-space-existing');
135+
});
136+
});
137+
138+
describe('create new space path', () => {
139+
it('should create a new space and upload assets for non-default non-existing space', async () => {
140+
stubMetadata({ title: 'Source Space 2', is_default: false });
141+
const createStub = sinon
142+
.stub(AssetManagementImportAdapter.prototype, 'createSpace' as any)
143+
.resolves({ space: { uid: 'new-space-2-uid' } });
144+
sinon.stub(ImportAssets.prototype, 'start').resolves({ uidMap: {}, urlMap: {} });
145+
146+
const importer = new ImportWorkspace(apiConfig, importContext);
147+
const result = await importer.start('am-space-2', spaceDir, new Set());
148+
149+
expect(createStub.callCount).to.equal(1);
150+
expect(result.newSpaceUid).to.equal('new-space-2-uid');
151+
expect(result.isDefault).to.equal(false);
152+
});
153+
});
154+
});

0 commit comments

Comments
 (0)