diff --git a/packages/contentstack-asset-management/src/import/spaces.ts b/packages/contentstack-asset-management/src/import/spaces.ts index 13706ffad..01c309fba 100644 --- a/packages/contentstack-asset-management/src/import/spaces.ts +++ b/packages/contentstack-asset-management/src/import/spaces.ts @@ -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); diff --git a/packages/contentstack-asset-management/src/import/workspaces.ts b/packages/contentstack-asset-management/src/import/workspaces.ts index d685cc3d2..e2143f56f 100644 --- a/packages/contentstack-asset-management/src/import/workspaces.ts +++ b/packages/contentstack-asset-management/src/import/workspaces.ts @@ -35,6 +35,8 @@ export default class ImportWorkspace extends AssetManagementImportAdapter { spaceDir: string, existingSpaceUids: Set = new Set(), spaceProcessName?: string, + targetDefaultSpaceUid?: string, + targetDefaultWorkspaceUid?: string, ): Promise { await this.init(); @@ -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( diff --git a/packages/contentstack-asset-management/src/types/asset-management-api.ts b/packages/contentstack-asset-management/src/types/asset-management-api.ts index 40423da89..605f54a40 100644 --- a/packages/contentstack-asset-management/src/types/asset-management-api.ts +++ b/packages/contentstack-asset-management/src/types/asset-management-api.ts @@ -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; }; /** diff --git a/packages/contentstack-asset-management/test/unit/import/spaces.test.ts b/packages/contentstack-asset-management/test/unit/import/spaces.test.ts new file mode 100644 index 000000000..40675fadd --- /dev/null +++ b/packages/contentstack-asset-management/test/unit/import/spaces.test.ts @@ -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({}); + }); + }); +}); diff --git a/packages/contentstack-asset-management/test/unit/import/workspaces.test.ts b/packages/contentstack-asset-management/test/unit/import/workspaces.test.ts new file mode 100644 index 000000000..4bc87501c --- /dev/null +++ b/packages/contentstack-asset-management/test/unit/import/workspaces.test.ts @@ -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) => { + 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); + }); + }); +}); diff --git a/packages/contentstack-import/src/import/modules/assets.ts b/packages/contentstack-import/src/import/modules/assets.ts index ff7ff3804..e89baadff 100644 --- a/packages/contentstack-import/src/import/modules/assets.ts +++ b/packages/contentstack-import/src/import/modules/assets.ts @@ -79,9 +79,55 @@ export default class ImportAssets extends BaseClass { const progress = this.createNestedProgress(this.currentModuleName); let spaceMappings: SpaceMapping[] = []; + // Resolve the existing default space in the target branch before building options. + // This allows the source default space to be imported into the pre-existing target default + // space instead of creating a new one. + const branchUid = this.importConfig.branchName ?? 'main'; + let targetDefaultSpaceUid: string | undefined; + let targetDefaultWorkspaceUid: string | undefined; + try { + const branchData = (await this.stack.branch(branchUid).fetch({ include_settings: true })) as Record< + string, + any + >; + const linkedWorkspaces = (branchData?.settings?.am_v2?.linked_workspaces ?? []) as Array<{ + uid: string; + space_uid: string; + is_default: boolean; + }>; + const defaultMatches = linkedWorkspaces.filter((w) => w.is_default === true); + if (defaultMatches.length > 1) { + log.warn( + `Target branch "${branchUid}" has ${defaultMatches.length} workspaces with is_default=true; using the first.`, + this.importConfig.context, + ); + } + if (defaultMatches.length > 0) { + targetDefaultSpaceUid = defaultMatches[0].space_uid; + targetDefaultWorkspaceUid = defaultMatches[0].uid; + log.debug( + `Target default space: ${targetDefaultSpaceUid} (workspace uid: ${targetDefaultWorkspaceUid})`, + this.importConfig.context, + ); + } else { + log.debug( + 'Target branch has no default workspace; source default space will be created as new.', + this.importConfig.context, + ); + } + } catch (e) { + log.debug( + `Could not fetch target branch linked_workspaces for default space detection: ${e}`, + this.importConfig.context, + ); + } + try { const importer = new ImportSpaces( - buildImportSpacesOptions(this.importConfig, this.importConfig.assetManagementUrl), + buildImportSpacesOptions(this.importConfig, this.importConfig.assetManagementUrl, { + targetDefaultSpaceUid, + targetDefaultWorkspaceUid, + }), ); importer.setParentProgressManager(progress); ({ spaceMappings } = await importer.start()); @@ -180,12 +226,16 @@ export default class ImportAssets extends BaseClass { operation?: string; }>; - const newWorkspaces = spaceMappings.map(({ newSpaceUid, workspaceUid }) => ({ - uid: workspaceUid, - space_uid: newSpaceUid, - is_default: false, - operation: 'LINK' as const, - })); + // Skip spaces already linked to the branch (e.g. the pre-existing target default space). + const alreadyLinkedSpaceUids = new Set(currentLinked.map((w) => w.space_uid)); + const newWorkspaces = spaceMappings + .filter(({ newSpaceUid }) => !alreadyLinkedSpaceUids.has(newSpaceUid)) + .map(({ newSpaceUid, workspaceUid }) => ({ + uid: workspaceUid, + space_uid: newSpaceUid, + is_default: false, + operation: 'LINK' as const, + })); const combinedWorkspaces = [...currentLinked, ...newWorkspaces]; diff --git a/packages/contentstack-import/src/utils/build-import-spaces-options.ts b/packages/contentstack-import/src/utils/build-import-spaces-options.ts index 32e57c8ce..22d99c5e6 100644 --- a/packages/contentstack-import/src/utils/build-import-spaces-options.ts +++ b/packages/contentstack-import/src/utils/build-import-spaces-options.ts @@ -6,10 +6,14 @@ import type ImportConfig from '../types/import-config'; /** * Maps stack `ImportConfig` and AM base URL into a single `ImportSpacesOptions` for the AM package * (variants-style: one flat object; `ImportSpaces` splits API vs context internally). + * + * Pass `overrides` to inject default-space mapping data fetched from the target branch before + * calling this function (see `ImportAssets.start()` for the fetch logic). */ export function buildImportSpacesOptions( importConfig: ImportConfig, assetManagementUrl: string, + overrides?: Pick, ): ImportSpacesOptions { const am = importConfig.modules['asset-management']; const org_uid = importConfig.org_uid ?? ''; @@ -40,5 +44,7 @@ export function buildImportSpacesOptions( mapperUidFileName: am?.mapperUidFileName ?? PATH_CONSTANTS.FILES.UID_MAPPING, mapperUrlFileName: am?.mapperUrlFileName ?? PATH_CONSTANTS.FILES.URL_MAPPING, mapperSpaceUidFileName: am?.mapperSpaceUidFileName ?? PATH_CONSTANTS.FILES.SPACE_UID_MAPPING, + targetDefaultSpaceUid: overrides?.targetDefaultSpaceUid, + targetDefaultWorkspaceUid: overrides?.targetDefaultWorkspaceUid, }; } diff --git a/packages/contentstack-import/test/unit/import/modules/assets.test.ts b/packages/contentstack-import/test/unit/import/modules/assets.test.ts index 8231a03ce..45cc20965 100644 --- a/packages/contentstack-import/test/unit/import/modules/assets.test.ts +++ b/packages/contentstack-import/test/unit/import/modules/assets.test.ts @@ -1091,4 +1091,102 @@ describe('ImportAssets', () => { expect(result[99].parent_uid).to.equal('folder-98'); }); }); + + describe('linkImportedAmSpacesToBranch() — duplicate prevention', () => { + let branchFetchStub: sinon.SinonStub; + let branchUpdateSettingsStub: sinon.SinonStub; + let branchStub: sinon.SinonStub; + + beforeEach(() => { + branchFetchStub = sinon.stub(); + branchUpdateSettingsStub = sinon.stub().resolves(); + branchStub = sinon.stub().returns({ + fetch: branchFetchStub, + updateSettings: branchUpdateSettingsStub, + }); + // stack is a getter-only property on BaseClass; override via defineProperty. + Object.defineProperty(importAssets, 'stack', { + get: () => ({ branch: branchStub }), + configurable: true, + }); + }); + + it('should skip space mappings whose newSpaceUid is already linked to the branch', async () => { + branchFetchStub.resolves({ + settings: { + am_v2: { + linked_workspaces: [ + { uid: 'ws-3', space_uid: 'target-space-3', is_default: true }, + ], + }, + }, + }); + + const spaceMappings = [ + { oldSpaceUid: 'am-space-1', newSpaceUid: 'target-space-3', workspaceUid: 'ws-3', isDefault: true }, + ]; + + await (importAssets as any).linkImportedAmSpacesToBranch(spaceMappings); + + expect(branchUpdateSettingsStub.callCount).to.equal(1); + const updatedWorkspaces = + branchUpdateSettingsStub.firstCall.args[0].branch.settings.am_v2.linked_workspaces; + // The existing entry stays; no new entry appended for target-space-3. + expect(updatedWorkspaces).to.have.lengthOf(1); + expect(updatedWorkspaces[0].space_uid).to.equal('target-space-3'); + expect(updatedWorkspaces[0].uid).to.equal('ws-3'); + }); + + it('should append only the non-default (new) space, not the already-linked default space', async () => { + branchFetchStub.resolves({ + settings: { + am_v2: { + linked_workspaces: [ + { uid: 'ws-3', space_uid: 'target-space-3', is_default: true }, + ], + }, + }, + }); + + const spaceMappings = [ + { oldSpaceUid: 'am-space-1', newSpaceUid: 'target-space-3', workspaceUid: 'ws-3', isDefault: true }, + { oldSpaceUid: 'am-space-2', newSpaceUid: 'brand-new-space', workspaceUid: 'main', isDefault: false }, + ]; + + await (importAssets as any).linkImportedAmSpacesToBranch(spaceMappings); + + const updatedWorkspaces = + branchUpdateSettingsStub.firstCall.args[0].branch.settings.am_v2.linked_workspaces; + // Existing default + one new space = 2 total. + expect(updatedWorkspaces).to.have.lengthOf(2); + expect(updatedWorkspaces.map((w: any) => w.space_uid)).to.include.members([ + 'target-space-3', + 'brand-new-space', + ]); + }); + + it('should append all mappings when none are already linked', async () => { + branchFetchStub.resolves({ + settings: { am_v2: { linked_workspaces: [] } }, + }); + + const spaceMappings = [ + { oldSpaceUid: 'am-space-1', newSpaceUid: 'new-space-1', workspaceUid: 'main', isDefault: true }, + { oldSpaceUid: 'am-space-2', newSpaceUid: 'new-space-2', workspaceUid: 'main', isDefault: false }, + ]; + + await (importAssets as any).linkImportedAmSpacesToBranch(spaceMappings); + + const updatedWorkspaces = + branchUpdateSettingsStub.firstCall.args[0].branch.settings.am_v2.linked_workspaces; + expect(updatedWorkspaces).to.have.lengthOf(2); + }); + + it('should do nothing when spaceMappings is empty', async () => { + await (importAssets as any).linkImportedAmSpacesToBranch([]); + + expect(branchFetchStub.callCount).to.equal(0); + expect(branchUpdateSettingsStub.callCount).to.equal(0); + }); + }); }); diff --git a/packages/contentstack-import/test/unit/utils/build-import-spaces-options.test.ts b/packages/contentstack-import/test/unit/utils/build-import-spaces-options.test.ts new file mode 100644 index 000000000..c21f739f0 --- /dev/null +++ b/packages/contentstack-import/test/unit/utils/build-import-spaces-options.test.ts @@ -0,0 +1,101 @@ +import { expect } from 'chai'; +import { buildImportSpacesOptions } from '../../../src/utils/build-import-spaces-options'; +import type { ImportConfig } from '../../../src/types'; + +describe('buildImportSpacesOptions', () => { + const baseConfig = { + contentDir: '/tmp/content', + apiKey: 'stack-api-key', + org_uid: 'org-123', + host: 'https://api.contentstack.io/v3', + region: { cma: 'https://api.contentstack.io/v3' }, + source_stack: 'source-api-key', + context: {} as any, + backupDir: '/tmp/backup', + fetchConcurrency: 5, + modules: { + 'asset-management': { + dirName: 'spaces', + uploadAssetsConcurrency: 3, + importFoldersConcurrency: 2, + mapperRootDir: 'mapper', + mapperAssetsModuleDir: 'assets', + mapperUidFileName: 'uid-mapping.json', + mapperUrlFileName: 'url-mapping.json', + mapperSpaceUidFileName: 'space-uid-mapping.json', + }, + }, + } as unknown as ImportConfig; + + it('should map basic importConfig fields to ImportSpacesOptions', () => { + const result = buildImportSpacesOptions(baseConfig, 'https://am.example.com'); + + expect(result.contentDir).to.equal('/tmp/content'); + expect(result.assetManagementUrl).to.equal('https://am.example.com'); + expect(result.org_uid).to.equal('org-123'); + expect(result.apiKey).to.equal('stack-api-key'); + expect(result.host).to.equal('https://api.contentstack.io/v3'); + expect(result.sourceApiKey).to.equal('source-api-key'); + expect(result.backupDir).to.equal('/tmp/backup'); + expect(result.apiConcurrency).to.equal(5); + expect(result.uploadAssetsConcurrency).to.equal(3); + expect(result.importFoldersConcurrency).to.equal(2); + }); + + it('should leave targetDefaultSpaceUid and targetDefaultWorkspaceUid undefined when no overrides provided', () => { + const result = buildImportSpacesOptions(baseConfig, 'https://am.example.com'); + + expect(result.targetDefaultSpaceUid).to.be.undefined; + expect(result.targetDefaultWorkspaceUid).to.be.undefined; + }); + + it('should populate targetDefaultSpaceUid when provided via overrides', () => { + const result = buildImportSpacesOptions(baseConfig, 'https://am.example.com', { + targetDefaultSpaceUid: 'space-3-uid', + }); + + expect(result.targetDefaultSpaceUid).to.equal('space-3-uid'); + expect(result.targetDefaultWorkspaceUid).to.be.undefined; + }); + + it('should populate both target default fields when provided via overrides', () => { + const result = buildImportSpacesOptions(baseConfig, 'https://am.example.com', { + targetDefaultSpaceUid: 'space-3-uid', + targetDefaultWorkspaceUid: 'ws-link-3', + }); + + expect(result.targetDefaultSpaceUid).to.equal('space-3-uid'); + expect(result.targetDefaultWorkspaceUid).to.equal('ws-link-3'); + }); + + it('should use org_uid empty string when importConfig.org_uid is undefined', () => { + const configWithoutOrg = { ...baseConfig, org_uid: undefined } as unknown as ImportConfig; + const result = buildImportSpacesOptions(configWithoutOrg, 'https://am.example.com'); + + expect(result.org_uid).to.equal(''); + }); + + it('should prefer region.cma over host for the host field', () => { + const configWithRegion = { + ...baseConfig, + region: { cma: 'https://region.api.com' }, + host: 'https://fallback.api.com', + } as unknown as ImportConfig; + + const result = buildImportSpacesOptions(configWithRegion, 'https://am.example.com'); + + expect(result.host).to.equal('https://region.api.com'); + }); + + it('should fall back to host when region.cma is absent', () => { + const configNoRegion = { + ...baseConfig, + region: undefined, + host: 'https://fallback.api.com', + } as unknown as ImportConfig; + + const result = buildImportSpacesOptions(configNoRegion, 'https://am.example.com'); + + expect(result.host).to.equal('https://fallback.api.com'); + }); +});