From 3d7b5f080903df095794ce81115da37ae9d83bc4 Mon Sep 17 00:00:00 2001 From: naman-contentstack Date: Fri, 27 Feb 2026 15:29:35 +0530 Subject: [PATCH 1/3] feat: add asset management package for AM2.0 support --- .talismanrc | 18 +- package-lock.json | 128 ++++++++- .../contentstack-asset-management/.gitignore | 3 + .../contentstack-asset-management/README.md | 49 ++++ .../package.json | 47 ++++ .../src/constants/index.ts | 47 ++++ .../src/export/asset-types.ts | 28 ++ .../src/export/assets.ts | 100 +++++++ .../src/export/base.ts | 101 +++++++ .../src/export/fields.ts | 26 ++ .../src/export/index.ts | 7 + .../src/export/spaces.ts | 131 +++++++++ .../src/export/workspaces.ts | 37 +++ .../src/index.ts | 4 + .../src/types/asset-management-api.ts | 140 ++++++++++ .../src/types/export-types.ts | 16 ++ .../src/types/index.ts | 2 + .../src/utils/asset-management-api-adapter.ts | 149 +++++++++++ .../src/utils/export-helpers.ts | 41 +++ .../src/utils/index.ts | 9 + .../test/unit/export/asset-types.test.ts | 95 +++++++ .../test/unit/export/assets.test.ts | 250 ++++++++++++++++++ .../test/unit/export/base.test.ts | 237 +++++++++++++++++ .../test/unit/export/fields.test.ts | 95 +++++++ .../test/unit/export/spaces.test.ts | 134 ++++++++++ .../test/unit/export/workspaces.test.ts | 115 ++++++++ .../asset-management-api-adapter.test.ts | 224 ++++++++++++++++ .../test/unit/utils/export-helpers.test.ts | 125 +++++++++ .../tsconfig.json | 31 +++ packages/contentstack-export/package.json | 1 + .../src/export/modules/assets.ts | 49 +++- .../src/export/modules/stack.ts | 51 ++-- .../src/types/export-config.ts | 1 + .../contentstack-export/src/types/index.ts | 1 + .../src/utils/constants.ts | 16 ++ .../src/utils/get-linked-workspaces.ts | 33 +++ .../contentstack-export/src/utils/index.ts | 1 + .../src/utils/progress-strategy-registry.ts | 22 +- 38 files changed, 2518 insertions(+), 46 deletions(-) create mode 100644 packages/contentstack-asset-management/.gitignore create mode 100644 packages/contentstack-asset-management/README.md create mode 100644 packages/contentstack-asset-management/package.json create mode 100644 packages/contentstack-asset-management/src/constants/index.ts create mode 100644 packages/contentstack-asset-management/src/export/asset-types.ts create mode 100644 packages/contentstack-asset-management/src/export/assets.ts create mode 100644 packages/contentstack-asset-management/src/export/base.ts create mode 100644 packages/contentstack-asset-management/src/export/fields.ts create mode 100644 packages/contentstack-asset-management/src/export/index.ts create mode 100644 packages/contentstack-asset-management/src/export/spaces.ts create mode 100644 packages/contentstack-asset-management/src/export/workspaces.ts create mode 100644 packages/contentstack-asset-management/src/index.ts create mode 100644 packages/contentstack-asset-management/src/types/asset-management-api.ts create mode 100644 packages/contentstack-asset-management/src/types/export-types.ts create mode 100644 packages/contentstack-asset-management/src/types/index.ts create mode 100644 packages/contentstack-asset-management/src/utils/asset-management-api-adapter.ts create mode 100644 packages/contentstack-asset-management/src/utils/export-helpers.ts create mode 100644 packages/contentstack-asset-management/src/utils/index.ts create mode 100644 packages/contentstack-asset-management/test/unit/export/asset-types.test.ts create mode 100644 packages/contentstack-asset-management/test/unit/export/assets.test.ts create mode 100644 packages/contentstack-asset-management/test/unit/export/base.test.ts create mode 100644 packages/contentstack-asset-management/test/unit/export/fields.test.ts create mode 100644 packages/contentstack-asset-management/test/unit/export/spaces.test.ts create mode 100644 packages/contentstack-asset-management/test/unit/export/workspaces.test.ts create mode 100644 packages/contentstack-asset-management/test/unit/utils/asset-management-api-adapter.test.ts create mode 100644 packages/contentstack-asset-management/test/unit/utils/export-helpers.test.ts create mode 100644 packages/contentstack-asset-management/tsconfig.json create mode 100644 packages/contentstack-export/src/utils/get-linked-workspaces.ts diff --git a/.talismanrc b/.talismanrc index 54aa5874d0..82f0a5a217 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,6 +1,18 @@ fileignoreconfig: - filename: package-lock.json - checksum: 45100667793fc7dfaae3e24787871257e7f29e06df69ba10ec05b358d59ff15d - - filename: pnpm-lock.yaml - checksum: 87d001c32b1d7f9df30a289c277e0ea13cfd8a0e2e5fa5118956ff4183683e5c + checksum: 601217277684d397a213756ac3bf9e0048c5bd1637dbc8ad1dff0fb864354557 + - filename: packages/contentstack-asset-management/src/utils/export-helpers.ts + checksum: 1a533a4e4d56a952f61ced63aa6f1bc8fbb3855fd7acecdd9fd40dd71e5fab6d + - filename: packages/contentstack-export/src/export/modules/stack.ts + checksum: 82f7df78993942debb79e690c8c27d0998157428ef506d0b07ea31d5a1f71aba + - filename: packages/contentstack-asset-management/src/export/base.ts + checksum: fcae2679bdeb93a6786cb290b60ba98f222a9c682552c6474370d17bf59ae1b4 + - filename: packages/contentstack-asset-management/src/utils/asset-management-api-adapter.ts + checksum: 6f5e11d3685b6093d6c4def7fc4199f673d9a56e5fbc2858ed72f69d764f1260 + - filename: packages/contentstack-asset-management/src/types/export-types.ts + checksum: d00ca608006d864f516e21b76d552c0ecf52ff89b3dcb361ed11ac600abed989 + - filename: packages/contentstack-asset-management/test/unit/utils/export-helpers.test.ts + checksum: 0e8751163491fc45e7ae3999282d336ae1ab8a9f88e601cbb85b4f44e8db96b8 + - filename: packages/contentstack-asset-management/test/unit/utils/asset-management-api-adapter.test.ts + checksum: fb076af66b4ffa5cf4a8e43d70dd5aa76d97ce30e0fb519dbdcbb316585ab4e2 version: '1.0' diff --git a/package-lock.json b/package-lock.json index b9d36b6c35..ecdbd723ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -403,7 +403,7 @@ "version": "3.993.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.993.0.tgz", "integrity": "sha512-j6vioBeRZ4eHX4SWGvGPpwGg/xSOcK7f1GL0VM+rdf3ZFTIsUEhCFmD78B+5r2PgztcECSzEfvHQX01k8dPQPw==", - "dev": true, + "extraneous": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.1", @@ -1686,6 +1686,10 @@ "resolved": "packages/contentstack", "link": true }, + "node_modules/@contentstack/cli-asset-management": { + "resolved": "packages/contentstack-asset-management", + "link": true + }, "node_modules/@contentstack/cli-audit": { "resolved": "packages/contentstack-audit", "link": true @@ -26993,9 +26997,9 @@ "@contentstack/cli-command": "~2.0.0-beta", "@contentstack/cli-config": "~2.0.0-beta.2", "@contentstack/cli-launch": "^1.9.6", - "@contentstack/cli-migration": "~2.0.0-beta.5", - "@contentstack/cli-utilities": "~2.0.0-beta", - "@contentstack/cli-variants": "~2.0.0-beta.6", + "@contentstack/cli-migration": "~2.0.0-beta.6", + "@contentstack/cli-utilities": "~2.0.0-beta.1", + "@contentstack/cli-variants": "~2.0.0-beta.7", "@contentstack/management": "~1.27.6", "@contentstack/utils": "~1.7.0", "@oclif/core": "^4.8.0", @@ -27048,6 +27052,109 @@ "node": ">=14.0.0" } }, + "packages/contentstack-asset-management": { + "name": "@contentstack/cli-asset-management", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@contentstack/cli-utilities": "~2.0.0-beta" + }, + "devDependencies": { + "@types/chai": "^4.3.11", + "@types/mocha": "^10.0.6", + "@types/node": "^20.17.50", + "@types/sinon": "^17.0.2", + "chai": "^4.4.1", + "mocha": "^10.8.2", + "nyc": "^15.1.0", + "sinon": "^17.0.1", + "ts-node": "^10.9.2", + "typescript": "^5.8.3" + } + }, + "packages/contentstack-asset-management/node_modules/@sinonjs/fake-timers": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.3.1.tgz", + "integrity": "sha512-EVJO7nW5M/F5Tur0Rf2z/QoMo+1Ia963RiMtapiQrEWvY0iBUvADo8Beegwjpnle5BHkyHuoxSTW3jF43H1XRA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "packages/contentstack-asset-management/node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", + "dev": true, + "license": "MIT" + }, + "packages/contentstack-asset-management/node_modules/@types/node": { + "version": "20.19.34", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.34.tgz", + "integrity": "sha512-by3/Z0Qp+L9cAySEsSNNwZ6WWw8ywgGLPQGgbQDhNRSitqYgkgp4pErd23ZSCavbtUA2CN4jQtoB3T8nk4j3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "packages/contentstack-asset-management/node_modules/@types/sinon": { + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.4.tgz", + "integrity": "sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "packages/contentstack-asset-management/node_modules/sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.5", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "packages/contentstack-asset-management/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "packages/contentstack-asset-management/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "packages/contentstack-audit": { "name": "@contentstack/cli-audit", "version": "2.0.0-beta.5", @@ -27967,9 +28074,10 @@ "version": "2.0.0-beta.10", "license": "MIT", "dependencies": { + "@contentstack/cli-asset-management": "1.0.0", "@contentstack/cli-command": "~2.0.0-beta", - "@contentstack/cli-utilities": "~2.0.0-beta", - "@contentstack/cli-variants": "~2.0.0-beta.6", + "@contentstack/cli-utilities": "~2.0.0-beta.1", + "@contentstack/cli-variants": "~2.0.0-beta.7", "@oclif/core": "^4.8.0", "async": "^3.2.6", "big-json": "^3.2.0", @@ -28143,8 +28251,8 @@ "dependencies": { "@contentstack/cli-audit": "~2.0.0-beta.5", "@contentstack/cli-command": "~2.0.0-beta", - "@contentstack/cli-utilities": "~2.0.0-beta", - "@contentstack/cli-variants": "~2.0.0-beta.6", + "@contentstack/cli-utilities": "~2.0.0-beta.1", + "@contentstack/cli-variants": "~2.0.0-beta.7", "@oclif/core": "^4.3.0", "big-json": "^3.2.0", "bluebird": "^3.7.2", @@ -28472,7 +28580,7 @@ }, "packages/contentstack-variants": { "name": "@contentstack/cli-variants", - "version": "2.0.0-beta.6", + "version": "2.0.0-beta.7", "license": "MIT", "dependencies": { "@contentstack/cli-utilities": "~2.0.0-beta.1", @@ -28518,4 +28626,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/contentstack-asset-management/.gitignore b/packages/contentstack-asset-management/.gitignore new file mode 100644 index 0000000000..6cf8928fa4 --- /dev/null +++ b/packages/contentstack-asset-management/.gitignore @@ -0,0 +1,3 @@ +lib/ +node_modules/ +*.tsbuildinfo diff --git a/packages/contentstack-asset-management/README.md b/packages/contentstack-asset-management/README.md new file mode 100644 index 0000000000..87867c2473 --- /dev/null +++ b/packages/contentstack-asset-management/README.md @@ -0,0 +1,49 @@ +# @contentstack/cli-asset-management + +Asset Management 2.0 API adapter for Contentstack CLI export and import. Used by the export and import plugins when Asset Management (AM 2.0) is enabled. To learn how to export and import content in Contentstack, refer to the [Migration guide](https://www.contentstack.com/docs/developers/cli/migration/). + +[![License](https://img.shields.io/npm/l/@contentstack/cli)](https://github.com/contentstack/cli/blob/main/LICENSE) + + +* [@contentstack/cli-asset-management](#contentstackcli-asset-management) +* [Overview](#overview) +* [Usage](#usage) +* [Exports](#exports) + + +# Overview + +This package provides: + +- **AssetManagementAdapter** – HTTP client for the Asset Management API (spaces, assets, folders, fields, asset types). +- **exportSpaceStructure** – Exports space metadata and full workspace structure (metadata, folders, assets, fields, asset types) for linked workspaces. +- **Types** – `AssetManagementExportOptions`, `LinkedWorkspace`, `IAssetManagementAdapter`, and related types for export/import integration. + +# Usage + +This package is consumed by the export and import plugins. When using the export CLI with the `--asset-management` flag (or when the host app enables AM 2.0), the export plugin calls `exportSpaceStructure` with linked workspaces and options: + +```ts +import { exportSpaceStructure } from '@contentstack/cli-asset-management'; + +await exportSpaceStructure({ + linkedWorkspaces, + exportDir, + branchName: 'main', + assetManagementUrl, + org_uid, + context, + progressManager, + progressProcessName, + updateStatus, + downloadAsset, // optional +}); +``` + +# Exports + +| Export | Description | +|--------|-------------| +| `exportSpaceStructure` | Async function to export space structure for given linked workspaces. | +| `AssetManagementAdapter` | Class to call the Asset Management API (getSpace, getWorkspaceFields, getWorkspaceAssets, etc.). | +| Types from `./types` | `AssetManagementExportOptions`, `ExportSpaceOptions`, `ChunkedJsonWriteOptions`, `LinkedWorkspace`, `SpaceResponse`, `FieldsResponse`, `AssetTypesResponse`, and related API types. | diff --git a/packages/contentstack-asset-management/package.json b/packages/contentstack-asset-management/package.json new file mode 100644 index 0000000000..375e5dbf22 --- /dev/null +++ b/packages/contentstack-asset-management/package.json @@ -0,0 +1,47 @@ +{ + "name": "@contentstack/cli-asset-management", + "version": "1.0.0", + "description": "Asset Management 2.0 API adapter for export and import", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "files": [ + "lib", + "oclif.manifest.json" + ], + "scripts": { + "prepack": "pnpm compile && oclif manifest && oclif readme", + "compile": "tsc -b tsconfig.json", + "clean": "rm -rf ./lib ./node_modules tsconfig.build.tsbuildinfo", + "test": "mocha --require ts-node/register --forbid-only \"test/**/*.test.ts\"", + "test:unit:report": "nyc --extension .ts mocha --require ts-node/register --forbid-only \"test/unit/**/*.test.ts\"" + }, + "keywords": [ + "contentstack", + "asset-management", + "cli" + ], + "license": "MIT", + "dependencies": { + "@contentstack/cli-utilities": "~2.0.0-beta" + }, + "oclif": { + "commands": "./lib/commands", + "bin": "csdx", + "devPlugins": [ + "@oclif/plugin-help" + ], + "repositoryPrefix": "<%- repo %>/blob/main/packages/contentstack-asset-management/<%- commandPath %>" + }, + "devDependencies": { + "@types/chai": "^4.3.11", + "@types/mocha": "^10.0.6", + "@types/node": "^20.17.50", + "@types/sinon": "^17.0.2", + "chai": "^4.4.1", + "mocha": "^10.8.2", + "nyc": "^15.1.0", + "sinon": "^17.0.1", + "ts-node": "^10.9.2", + "typescript": "^5.8.3" + } +} \ No newline at end of file diff --git a/packages/contentstack-asset-management/src/constants/index.ts b/packages/contentstack-asset-management/src/constants/index.ts new file mode 100644 index 0000000000..0a0b469a7e --- /dev/null +++ b/packages/contentstack-asset-management/src/constants/index.ts @@ -0,0 +1,47 @@ +/** + * Main process name for Asset Management 2.0 export (single progress bar). + * Use this when adding/starting the process and for all ticks. + */ +export const AM_MAIN_PROCESS_NAME = 'Asset Management 2.0'; + +/** + * Process names for Asset Management 2.0 export progress (for tick labels). + */ +export const PROCESS_NAMES = { + AM_SPACE_METADATA: 'Space metadata', + AM_FOLDERS: 'Folders', + AM_ASSETS: 'Assets', + AM_FIELDS: 'Fields', + AM_ASSET_TYPES: 'Asset types', + AM_DOWNLOADS: 'Asset downloads', +} as const; + +/** + * Status messages for each process (exporting, fetching, failed). + */ +export const PROCESS_STATUS = { + [PROCESS_NAMES.AM_SPACE_METADATA]: { + EXPORTING: 'Exporting space metadata...', + FAILED: 'Failed to export space metadata.', + }, + [PROCESS_NAMES.AM_FOLDERS]: { + FETCHING: 'Fetching folders...', + FAILED: 'Failed to fetch folders.', + }, + [PROCESS_NAMES.AM_ASSETS]: { + FETCHING: 'Fetching assets...', + FAILED: 'Failed to fetch assets.', + }, + [PROCESS_NAMES.AM_FIELDS]: { + FETCHING: 'Fetching fields...', + FAILED: 'Failed to fetch fields.', + }, + [PROCESS_NAMES.AM_ASSET_TYPES]: { + FETCHING: 'Fetching asset types...', + FAILED: 'Failed to fetch asset types.', + }, + [PROCESS_NAMES.AM_DOWNLOADS]: { + DOWNLOADING: 'Downloading asset files...', + FAILED: 'Failed to download assets.', + }, +} as const; diff --git a/packages/contentstack-asset-management/src/export/asset-types.ts b/packages/contentstack-asset-management/src/export/asset-types.ts new file mode 100644 index 0000000000..2a88e9d19a --- /dev/null +++ b/packages/contentstack-asset-management/src/export/asset-types.ts @@ -0,0 +1,28 @@ +import { log } from '@contentstack/cli-utilities'; + +import type { AssetManagementAPIConfig } from '../types/asset-management-api'; +import type { ExportContext } from '../types/export-types'; +import { AssetManagementExportAdapter } from './base'; +import { getArrayFromResponse } from '../utils/export-helpers'; +import { PROCESS_NAMES } from '../constants/index'; + +export default class ExportAssetTypes extends AssetManagementExportAdapter { + constructor(apiConfig: AssetManagementAPIConfig, exportContext: ExportContext) { + super(apiConfig, exportContext); + } + + async start(spaceUid: string): Promise { + await this.init(); + const assetTypesData = await this.getWorkspaceAssetTypes(spaceUid); + const items = getArrayFromResponse(assetTypesData, 'asset_types'); + const dir = this.getAssetTypesDir(); + log.debug( + items.length === 0 + ? 'No asset types, wrote empty asset-types' + : `Writing ${items.length} shared asset types`, + this.exportContext.context, + ); + await this.writeItemsToChunkedJson(dir, 'asset-types.json', 'asset_types', ['uid', 'title', 'category', 'file_extension'], items); + this.tick(true, PROCESS_NAMES.AM_ASSET_TYPES, null); + } +} diff --git a/packages/contentstack-asset-management/src/export/assets.ts b/packages/contentstack-asset-management/src/export/assets.ts new file mode 100644 index 0000000000..140b566452 --- /dev/null +++ b/packages/contentstack-asset-management/src/export/assets.ts @@ -0,0 +1,100 @@ +import { resolve as pResolve } from 'node:path'; +import { Readable } from 'node:stream'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { configHandler, log } from '@contentstack/cli-utilities'; + +import type { AssetManagementAPIConfig, LinkedWorkspace } from '../types/asset-management-api'; +import type { ExportContext } from '../types/export-types'; +import { AssetManagementExportAdapter } from './base'; +import { getAssetItems, writeStreamToFile } from '../utils/export-helpers'; +import { PROCESS_NAMES, PROCESS_STATUS } from '../constants/index'; + +export default class ExportAssets extends AssetManagementExportAdapter { + constructor(apiConfig: AssetManagementAPIConfig, exportContext: ExportContext) { + super(apiConfig, exportContext); + } + + async start(workspace: LinkedWorkspace, spaceDir: string): Promise { + await this.init(); + const assetsDir = pResolve(spaceDir, 'assets'); + await mkdir(assetsDir, { recursive: true }); + log.debug(`Fetching folders and assets for space ${workspace.space_uid}`, this.exportContext.context); + + const [folders, assetsData] = await Promise.all([ + this.getWorkspaceFolders(workspace.space_uid), + this.getWorkspaceAssets(workspace.space_uid), + ]); + + await writeFile(pResolve(assetsDir, 'folders.json'), JSON.stringify(folders, null, 2)); + this.tick(true, `folders: ${workspace.space_uid}`, null); + log.debug(`Wrote folders.json for space ${workspace.space_uid}`, this.exportContext.context); + + const assetItems = getAssetItems(assetsData); + log.debug( + assetItems.length === 0 + ? `No assets for space ${workspace.space_uid}, wrote empty assets.json` + : `Writing ${assetItems.length} assets metadata for space ${workspace.space_uid}`, + this.exportContext.context, + ); + await this.writeItemsToChunkedJson( + assetsDir, + 'assets.json', + 'assets', + ['uid', 'url', 'filename', 'file_name', 'parent_uid'], + assetItems, + ); + this.tick(true, `assets: ${workspace.space_uid} (${assetItems.length})`, null); + + await this.downloadWorkspaceAssets(assetsData, assetsDir, workspace.space_uid); + } + + private async downloadWorkspaceAssets( + assetsData: unknown, + assetsDir: string, + spaceUid: string, + ): Promise { + const items = getAssetItems(assetsData); + if (items.length === 0) { + log.debug('No assets to download', this.exportContext.context); + return; + } + + this.updateStatus(PROCESS_STATUS[PROCESS_NAMES.AM_DOWNLOADS].DOWNLOADING); + log.debug(`Downloading ${items.length} asset file(s) for space ${spaceUid}...`, this.exportContext.context); + const filesDir = pResolve(assetsDir, 'files'); + await mkdir(filesDir, { recursive: true }); + + const securedAssets = this.exportContext.securedAssets ?? false; + const authtoken = securedAssets ? configHandler.get('authtoken') : null; + let lastError: string | null = null; + let allSuccess = true; + + for (const asset of items) { + const uid = asset.uid ?? asset._uid; + const url = asset.url; + const filename = asset.filename ?? asset.file_name ?? 'asset'; + if (!url || !uid) continue; + try { + const separator = url.includes('?') ? '&' : '?'; + const downloadUrl = securedAssets && authtoken ? `${url}${separator}authtoken=${authtoken}` : url; + const response = await fetch(downloadUrl); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const body = response.body; + if (!body) throw new Error('No response body'); + const nodeStream = Readable.fromWeb(body as Parameters[0]); + const assetFolderPath = pResolve(filesDir, uid); + await mkdir(assetFolderPath, { recursive: true }); + const filePath = pResolve(assetFolderPath, filename); + await writeStreamToFile(nodeStream, filePath); + log.debug(`Downloaded asset ${uid}`, this.exportContext.context); + } catch (e) { + allSuccess = false; + lastError = (e as Error)?.message ?? PROCESS_STATUS[PROCESS_NAMES.AM_DOWNLOADS].FAILED; + log.debug(`Failed to download asset ${uid}: ${e}`, this.exportContext.context); + } + } + + this.tick(allSuccess, `downloads: ${spaceUid}`, lastError); + log.debug('Asset downloads completed', this.exportContext.context); + } +} diff --git a/packages/contentstack-asset-management/src/export/base.ts b/packages/contentstack-asset-management/src/export/base.ts new file mode 100644 index 0000000000..8a55ab29d8 --- /dev/null +++ b/packages/contentstack-asset-management/src/export/base.ts @@ -0,0 +1,101 @@ +import { resolve as pResolve } from 'node:path'; +import { writeFile } from 'node:fs/promises'; +import { FsUtility, log, CLIProgressManager, configHandler } from '@contentstack/cli-utilities'; + +import type { AssetManagementAPIConfig } from '../types/asset-management-api'; +import type { ExportContext } from '../types/export-types'; +import { AssetManagementAdapter } from '../utils/asset-management-api-adapter'; +import { AM_MAIN_PROCESS_NAME } from '../constants/index'; +import { BATCH_SIZE, CHUNK_FILE_SIZE_MB } from '../utils/export-helpers'; + +export type { ExportContext }; + +/** + * Base class for export modules. Extends the API adapter and adds export context, + * internal progress management, and shared write helpers. + */ +export class AssetManagementExportAdapter extends AssetManagementAdapter { + protected readonly apiConfig: AssetManagementAPIConfig; + protected readonly exportContext: ExportContext; + protected progressManager: CLIProgressManager | null = null; + protected parentProgressManager: CLIProgressManager | null = null; + protected readonly processName: string = AM_MAIN_PROCESS_NAME; + + constructor(apiConfig: AssetManagementAPIConfig, exportContext: ExportContext) { + super(apiConfig); + this.apiConfig = apiConfig; + this.exportContext = exportContext; + } + + public setParentProgressManager(parent: CLIProgressManager): void { + this.parentProgressManager = parent; + } + + protected get progressOrParent(): CLIProgressManager | null { + return this.parentProgressManager ?? this.progressManager; + } + + protected createNestedProgress(moduleName: string): CLIProgressManager { + if (this.parentProgressManager) { + this.progressManager = this.parentProgressManager; + return this.parentProgressManager; + } + const logConfig = configHandler.get('log') || {}; + const showConsoleLogs = logConfig.showConsoleLogs ?? false; + this.progressManager = CLIProgressManager.createNested(moduleName, showConsoleLogs); + return this.progressManager; + } + + protected tick(success: boolean, itemName: string, error: string | null, processName?: string): void { + this.progressOrParent?.tick?.(success, itemName, error, processName ?? this.processName); + } + + protected updateStatus(message: string, processName?: string): void { + this.progressOrParent?.updateStatus?.(message, processName ?? this.processName); + } + + protected completeProcess(processName: string, success: boolean): void { + if (!this.parentProgressManager) { + this.progressManager?.completeProcess?.(processName, success); + } + } + + protected get spacesRootPath(): string { + return this.exportContext.spacesRootPath; + } + + protected getAssetTypesDir(): string { + return pResolve(this.exportContext.spacesRootPath, 'asset_types'); + } + + protected getFieldsDir(): string { + return pResolve(this.exportContext.spacesRootPath, 'fields'); + } + + protected async writeItemsToChunkedJson( + dir: string, + indexFileName: string, + moduleName: string, + metaPickKeys: string[], + items: unknown[], + ): Promise { + if (items.length === 0) { + await writeFile(pResolve(dir, indexFileName), '{}'); + return; + } + const fs = new FsUtility({ + basePath: dir, + indexFileName, + chunkFileSize: CHUNK_FILE_SIZE_MB, + moduleName, + fileExt: 'json', + metaPickKeys, + keepMetadata: true, + }); + for (let i = 0; i < items.length; i += BATCH_SIZE) { + const batch = items.slice(i, i + BATCH_SIZE); + fs.writeIntoFile(batch as Record[], { mapKeyVal: true }); + } + fs.completeFile(true); + } +} diff --git a/packages/contentstack-asset-management/src/export/fields.ts b/packages/contentstack-asset-management/src/export/fields.ts new file mode 100644 index 0000000000..2960c024ce --- /dev/null +++ b/packages/contentstack-asset-management/src/export/fields.ts @@ -0,0 +1,26 @@ +import { log } from '@contentstack/cli-utilities'; + +import type { AssetManagementAPIConfig } from '../types/asset-management-api'; +import type { ExportContext } from '../types/export-types'; +import { AssetManagementExportAdapter } from './base'; +import { getArrayFromResponse } from '../utils/export-helpers'; +import { PROCESS_NAMES } from '../constants/index'; + +export default class ExportFields extends AssetManagementExportAdapter { + constructor(apiConfig: AssetManagementAPIConfig, exportContext: ExportContext) { + super(apiConfig, exportContext); + } + + async start(spaceUid: string): Promise { + await this.init(); + const fieldsData = await this.getWorkspaceFields(spaceUid); + const items = getArrayFromResponse(fieldsData, 'fields'); + const dir = this.getFieldsDir(); + log.debug( + items.length === 0 ? 'No field items, wrote empty fields' : `Writing ${items.length} shared fields`, + this.exportContext.context, + ); + await this.writeItemsToChunkedJson(dir, 'fields.json', 'fields', ['uid', 'title', 'display_type'], items); + this.tick(true, PROCESS_NAMES.AM_FIELDS, null); + } +} diff --git a/packages/contentstack-asset-management/src/export/index.ts b/packages/contentstack-asset-management/src/export/index.ts new file mode 100644 index 0000000000..7d71e361e8 --- /dev/null +++ b/packages/contentstack-asset-management/src/export/index.ts @@ -0,0 +1,7 @@ +export { ExportSpaces, exportSpaceStructure } from './spaces'; +export { default as ExportAssetTypes } from './asset-types'; +export { default as ExportFields } from './fields'; +export { default as ExportAssets } from './assets'; +export { default as ExportWorkspace } from './workspaces'; +export { AssetManagementExportAdapter } from './base'; +export type { ExportContext } from './base'; diff --git a/packages/contentstack-asset-management/src/export/spaces.ts b/packages/contentstack-asset-management/src/export/spaces.ts new file mode 100644 index 0000000000..cf3ff2c307 --- /dev/null +++ b/packages/contentstack-asset-management/src/export/spaces.ts @@ -0,0 +1,131 @@ +import { resolve as pResolve } from 'node:path'; +import { mkdir } from 'node:fs/promises'; +import { log, CLIProgressManager, configHandler } from '@contentstack/cli-utilities'; + +import type { AssetManagementExportOptions, AssetManagementAPIConfig } from '../types/asset-management-api'; +import type { ExportContext } from '../types/export-types'; +import { AssetManagementAdapter } from '../utils/asset-management-api-adapter'; +import { AM_MAIN_PROCESS_NAME, PROCESS_NAMES, PROCESS_STATUS } from '../constants/index'; +import ExportAssetTypes from './asset-types'; +import ExportFields from './fields'; +import ExportWorkspace from './workspaces'; + +/** + * Orchestrates the full Asset Management 2.0 export: shared asset types and fields, + * then per-workspace metadata and assets (including internal download). + * Progress and download are fully owned by this package. + */ +export class ExportSpaces { + private readonly options: AssetManagementExportOptions; + private parentProgressManager: CLIProgressManager | null = null; + private progressManager: CLIProgressManager | null = null; + + constructor(options: AssetManagementExportOptions) { + this.options = options; + } + + public setParentProgressManager(parent: CLIProgressManager): void { + this.parentProgressManager = parent; + } + + async start(): Promise { + const { + linkedWorkspaces, + exportDir, + branchName, + assetManagementUrl, + org_uid, + context, + securedAssets, + } = this.options; + + if (!linkedWorkspaces.length) { + log.debug('No linked workspaces to export', context); + return; + } + + log.debug(`Exporting Asset Management 2.0 (${linkedWorkspaces.length} space(s))`, context); + log.debug(`Spaces: ${linkedWorkspaces.map((ws) => ws.space_uid).join(', ')}`, context); + + const spacesRootPath = pResolve(exportDir, branchName || 'main', 'spaces'); + await mkdir(spacesRootPath, { recursive: true }); + log.debug(`Spaces root path: ${spacesRootPath}`, context); + + const totalSteps = 2 + linkedWorkspaces.length * 4; + const progress = this.createProgress(); + progress.addProcess(AM_MAIN_PROCESS_NAME, totalSteps); + progress.startProcess(AM_MAIN_PROCESS_NAME).updateStatus(PROCESS_STATUS[PROCESS_NAMES.AM_FIELDS].FETCHING, AM_MAIN_PROCESS_NAME); + + const apiConfig: AssetManagementAPIConfig = { + baseURL: assetManagementUrl, + headers: { organization_uid: org_uid }, + context, + }; + const exportContext: ExportContext = { + spacesRootPath, + context, + securedAssets, + }; + + const sharedFieldsDir = pResolve(spacesRootPath, 'fields'); + const sharedAssetTypesDir = pResolve(spacesRootPath, 'asset_types'); + await mkdir(sharedFieldsDir, { recursive: true }); + await mkdir(sharedAssetTypesDir, { recursive: true }); + + const firstSpaceUid = linkedWorkspaces[0].space_uid; + try { + const exportAssetTypes = new ExportAssetTypes(apiConfig, exportContext); + exportAssetTypes.setParentProgressManager(progress); + await exportAssetTypes.start(firstSpaceUid); + + const exportFields = new ExportFields(apiConfig, exportContext); + exportFields.setParentProgressManager(progress); + await exportFields.start(firstSpaceUid); + + for (const ws of linkedWorkspaces) { + progress.updateStatus(`Exporting space: ${ws.space_uid}...`, AM_MAIN_PROCESS_NAME); + log.debug(`Exporting space: ${ws.space_uid}`, context); + const spaceDir = pResolve(spacesRootPath, ws.space_uid); + try { + const exportWorkspace = new ExportWorkspace(apiConfig, exportContext); + exportWorkspace.setParentProgressManager(progress); + await exportWorkspace.start(ws, spaceDir, branchName || 'main'); + log.debug(`Exported workspace structure for space ${ws.space_uid}`, context); + } catch (err) { + log.debug(`Failed to export workspace for space ${ws.space_uid}: ${err}`, context); + progress.tick( + false, + `space: ${ws.space_uid}`, + (err as Error)?.message ?? PROCESS_STATUS[PROCESS_NAMES.AM_SPACE_METADATA].FAILED, + AM_MAIN_PROCESS_NAME, + ); + throw err; + } + } + + progress.completeProcess(AM_MAIN_PROCESS_NAME, true); + log.debug('Asset Management 2.0 export completed', context); + } catch (err) { + progress.completeProcess(AM_MAIN_PROCESS_NAME, false); + throw err; + } + } + + private createProgress(): CLIProgressManager { + if (this.parentProgressManager) { + this.progressManager = this.parentProgressManager; + return this.parentProgressManager; + } + const logConfig = configHandler.get('log') || {}; + const showConsoleLogs = logConfig.showConsoleLogs ?? false; + this.progressManager = CLIProgressManager.createNested(AM_MAIN_PROCESS_NAME, showConsoleLogs); + return this.progressManager; + } +} + +/** + * Entry point for callers that prefer a function. Delegates to ExportSpaces. + */ +export async function exportSpaceStructure(options: AssetManagementExportOptions): Promise { + await new ExportSpaces(options).start(); +} diff --git a/packages/contentstack-asset-management/src/export/workspaces.ts b/packages/contentstack-asset-management/src/export/workspaces.ts new file mode 100644 index 0000000000..88cfd3976d --- /dev/null +++ b/packages/contentstack-asset-management/src/export/workspaces.ts @@ -0,0 +1,37 @@ +import { resolve as pResolve } from 'node:path'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { log } from '@contentstack/cli-utilities'; + +import type { AssetManagementAPIConfig, LinkedWorkspace } from '../types/asset-management-api'; +import type { ExportContext } from '../types/export-types'; +import { AssetManagementExportAdapter } from './base'; +import ExportAssets from './assets'; +import { PROCESS_NAMES } from '../constants/index'; + +export default class ExportWorkspace extends AssetManagementExportAdapter { + constructor(apiConfig: AssetManagementAPIConfig, exportContext: ExportContext) { + super(apiConfig, exportContext); + } + + async start(workspace: LinkedWorkspace, spaceDir: string, branchName: string): Promise { + await this.init(); + const spaceResponse = await this.getSpace(workspace.space_uid); + const space = spaceResponse.space; + await mkdir(spaceDir, { recursive: true }); + + const metadata = { + ...space, + workspace_uid: workspace.uid, + is_default: workspace.is_default, + branch: branchName || 'main', + }; + await writeFile(pResolve(spaceDir, 'metadata.json'), JSON.stringify(metadata, null, 2)); + this.tick(true, `space: ${workspace.space_uid}`, null); + log.debug(`Space metadata written for ${workspace.space_uid}`, this.exportContext.context); + + const assetsExporter = new ExportAssets(this.apiConfig, this.exportContext); + if (this.progressOrParent) assetsExporter.setParentProgressManager(this.progressOrParent); + await assetsExporter.start(workspace, spaceDir); + log.debug(`Exported workspace structure for space ${workspace.space_uid}`, this.exportContext.context); + } +} diff --git a/packages/contentstack-asset-management/src/index.ts b/packages/contentstack-asset-management/src/index.ts new file mode 100644 index 0000000000..f0ff59bdd1 --- /dev/null +++ b/packages/contentstack-asset-management/src/index.ts @@ -0,0 +1,4 @@ +export * from './constants/index'; +export * from './types'; +export * from './utils'; +export * from './export'; diff --git a/packages/contentstack-asset-management/src/types/asset-management-api.ts b/packages/contentstack-asset-management/src/types/asset-management-api.ts new file mode 100644 index 0000000000..733ecada76 --- /dev/null +++ b/packages/contentstack-asset-management/src/types/asset-management-api.ts @@ -0,0 +1,140 @@ +/** + * Linked workspace from CMA branch settings (am_v2.linked_workspaces). + * Consumed by export/import after fetching branch with include_settings: true. + */ +export type LinkedWorkspace = { + uid: string; + space_uid: string; + is_default: boolean; +}; + +/** + * Space details from GET /api/spaces/{space_uid}. + */ +export type Space = { + uid: string; + title?: string; + description?: string; + org_uid?: string; + owner_uid?: string; + default_locale?: string; + default_workspace?: string; + tags?: string[]; + settings?: Record; + created_by?: string; + updated_by?: string; + created_at?: string; + updated_at?: string; + meta_info?: { + assets_count?: number; + folders_count?: number; + storage?: number; + last_modified_at?: string; + }; +}; + +/** Response shape of GET /api/spaces/{space_uid}. */ +export type SpaceResponse = { space: Space }; + +/** + * Field structure from GET /api/fields (org-level). + */ +export type FieldStruct = { + uid: string; + title?: string; + description?: string | null; + display_type?: string; + is_system?: boolean; + is_multiple?: boolean; + is_mandatory?: boolean; + asset_types_count?: number; + created_at?: string; + created_by?: string; + updated_at?: string; + updated_by?: string; +}; + +/** Response shape of GET /api/fields. */ +export type FieldsResponse = { + count: number; + relation: string; + fields: FieldStruct[]; +}; + +/** + * Options object for asset type (from GET /api/asset_types). + */ +export type AssetTypeOptions = { + title?: string; + publishable?: boolean; + is_page?: boolean; + singleton?: boolean; + sub_title?: string[]; + url_pattern?: string; + url_prefix?: string; +}; + +/** + * Asset type structure from GET /api/asset_types (org-level). + */ +export type AssetTypeStruct = { + uid: string; + title?: string; + is_system?: boolean; + fields?: string[]; + options?: AssetTypeOptions; + description?: string; + content_type?: string; + file_extension?: string; + created_by?: string; + updated_by?: string; + created_at?: string; + updated_at?: string; + category?: string; + preview_image_url?: string; + category_detail?: string; +}; + +/** Response shape of GET /api/asset_types. */ +export type AssetTypesResponse = { + count: number; + relation: string; + asset_types: AssetTypeStruct[]; +}; + +/** + * Configuration for AssetManagementAdapter constructor. + */ +export type AssetManagementAPIConfig = { + baseURL: string; + headers?: Record; + /** Optional context for logging (e.g. exportConfig.context) */ + context?: Record; +}; + +/** + * Adapter interface for Asset Management API calls. + * Used by export and (future) import. + */ +export interface IAssetManagementAdapter { + init(): Promise; + getSpace(spaceUid: string): Promise; + getWorkspaceFields(spaceUid: string): Promise; + getWorkspaceAssets(spaceUid: string): Promise; + getWorkspaceFolders(spaceUid: string): Promise; + getWorkspaceAssetTypes(spaceUid: string): Promise; +} + +/** + * Options for exporting space structure (used by export app after fetching linked workspaces). + */ +export type AssetManagementExportOptions = { + linkedWorkspaces: LinkedWorkspace[]; + exportDir: string; + branchName: string; + assetManagementUrl: string; + org_uid: string; + context?: Record; + /** When true, the AM package will add authtoken to asset download URLs. */ + securedAssets?: boolean; +}; diff --git a/packages/contentstack-asset-management/src/types/export-types.ts b/packages/contentstack-asset-management/src/types/export-types.ts new file mode 100644 index 0000000000..25b8dcace4 --- /dev/null +++ b/packages/contentstack-asset-management/src/types/export-types.ts @@ -0,0 +1,16 @@ +export type ExportContext = { + spacesRootPath: string; + context?: Record; + securedAssets?: boolean; +}; + +/** + * Options for writing a list of items to chunked JSON files via FsUtility. + */ +export type ChunkedJsonWriteOptions = { + dir: string; + indexFileName: string; + moduleName: string; + metaPickKeys: string[]; + items: unknown[]; +}; diff --git a/packages/contentstack-asset-management/src/types/index.ts b/packages/contentstack-asset-management/src/types/index.ts new file mode 100644 index 0000000000..c673e18935 --- /dev/null +++ b/packages/contentstack-asset-management/src/types/index.ts @@ -0,0 +1,2 @@ +export * from './asset-management-api'; +export * from './export-types'; diff --git a/packages/contentstack-asset-management/src/utils/asset-management-api-adapter.ts b/packages/contentstack-asset-management/src/utils/asset-management-api-adapter.ts new file mode 100644 index 0000000000..b159cc3308 --- /dev/null +++ b/packages/contentstack-asset-management/src/utils/asset-management-api-adapter.ts @@ -0,0 +1,149 @@ +import { HttpClient, log, authenticationHandler } from '@contentstack/cli-utilities'; + +import type { + AssetManagementAPIConfig, + AssetTypesResponse, + FieldsResponse, + IAssetManagementAdapter, + SpaceResponse, +} from '../types/asset-management-api'; + +export class AssetManagementAdapter implements IAssetManagementAdapter { + private readonly config: AssetManagementAPIConfig; + private readonly apiClient: HttpClient; + + constructor(config: AssetManagementAPIConfig) { + this.config = config; + this.apiClient = new HttpClient(); + const baseURL = config.baseURL?.replace(/\/$/, '') ?? ''; + this.apiClient.baseUrl(baseURL); + const defaultHeaders = { Accept: 'application/json', 'x-cs-api-version': '4' }; + this.apiClient.headers(config.headers ? { ...defaultHeaders, ...config.headers } : defaultHeaders); + log.debug('AssetManagementAdapter initialized', config.context); + } + + /** + * Build query string from params. Supports string and string[] values. + * Returns empty string when params are empty so we never append "?" with no keys. + */ + private buildQueryString(params: Record): string { + const entries = Object.entries(params).filter( + ([, v]) => v !== undefined && v !== null && (typeof v === 'string' || Array.isArray(v)), + ); + if (entries.length === 0) return ''; + const parts: string[] = []; + for (const [key, value] of entries) { + if (Array.isArray(value)) { + for (const v of value) { + parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(v))}`); + } + } else { + parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + } + } + return '?' + parts.join('&'); + } + + /** + * GET a space-level endpoint (e.g. /api/spaces/{uid}). Builds path + query string and performs the request. + */ + private async getSpaceLevel( + _spaceUid: string, + path: string, + queryParams: Record = {}, + ): Promise { + await this.init(); + const safeParams: Record = {}; + for (const [k, v] of Object.entries(queryParams)) { + let value: string | string[] | undefined; + if (typeof v === 'string') value = v; + else if (Array.isArray(v) && v.every((x) => typeof x === 'string')) value = v; + else value = undefined; + if (value !== undefined) safeParams[k] = value; + } + const queryString = this.buildQueryString(safeParams); + const fullPath = path + queryString; + log.debug(`GET ${fullPath}`, this.config.context); + const response = await this.apiClient.get(fullPath); + if (response.status < 200 || response.status >= 300) { + throw new Error(`Asset Management API error: status ${response.status}, path ${path}`); + } + return response.data as T; + } + + async init(): Promise { + try { + log.debug('Initializing Asset Management adapter...', this.config.context); + await authenticationHandler.getAuthDetails(); + const token = authenticationHandler.accessToken; + log.debug( + `Authentication type: ${authenticationHandler.isOauthEnabled ? 'OAuth' : 'Token'}`, + this.config.context, + ); + const authHeader = authenticationHandler.isOauthEnabled ? { authorization: token } : { access_token: token }; + this.apiClient.headers(this.config.headers ? { ...authHeader, ...this.config.headers } : authHeader); + log.debug('Asset Management adapter initialization completed', this.config.context); + } catch (error: unknown) { + log.debug(`Asset Management adapter initialization failed: ${error}`, this.config.context); + throw error; + } + } + + async getSpace(spaceUid: string): Promise { + log.debug(`Fetching space: ${spaceUid}`, this.config.context); + const path = `/api/spaces/${spaceUid}`; + const queryParams: Record = { + addl_fields: ['meta_info', 'users'], + }; + const result = await this.getSpaceLevel(spaceUid, path, queryParams); + log.debug(`Fetched space: ${spaceUid}`, this.config.context); + return result; + } + + async getWorkspaceFields(spaceUid: string): Promise { + log.debug(`Fetching fields for space: ${spaceUid}`, this.config.context); + const result = await this.getSpaceLevel(spaceUid, '/api/fields', {}); + log.debug(`Fetched fields (count: ${result?.count ?? '?'})`, this.config.context); + return result; + } + + /** + * GET a workspace collection (assets or folders), log count, and return result. + */ + private async getWorkspaceCollection( + spaceUid: string, + path: string, + logLabel: string, + ): Promise { + log.debug(`Fetching ${logLabel} for space: ${spaceUid}`, this.config.context); + const result = await this.getSpaceLevel(spaceUid, path, {}); + const count = (result as { count?: number })?.count ?? (Array.isArray(result) ? result.length : '?'); + log.debug(`Fetched ${logLabel} (count: ${count})`, this.config.context); + return result; + } + + async getWorkspaceAssets(spaceUid: string): Promise { + return this.getWorkspaceCollection( + spaceUid, + `/api/spaces/${encodeURIComponent(spaceUid)}/assets`, + 'assets', + ); + } + + async getWorkspaceFolders(spaceUid: string): Promise { + return this.getWorkspaceCollection( + spaceUid, + `/api/spaces/${encodeURIComponent(spaceUid)}/folders`, + 'folders', + ); + } + + async getWorkspaceAssetTypes(spaceUid: string): Promise { + log.debug(`Fetching asset types for space: ${spaceUid}`, this.config.context); + const result = await this.getSpaceLevel(spaceUid, '/api/asset_types', { + include_fields: 'true', + }); + log.debug(`Fetched asset types (count: ${result?.count ?? '?'})`, this.config.context); + return result; + } +} diff --git a/packages/contentstack-asset-management/src/utils/export-helpers.ts b/packages/contentstack-asset-management/src/utils/export-helpers.ts new file mode 100644 index 0000000000..2083951ec5 --- /dev/null +++ b/packages/contentstack-asset-management/src/utils/export-helpers.ts @@ -0,0 +1,41 @@ +import { createWriteStream } from 'node:fs'; + +export const BATCH_SIZE = 50; +export const CHUNK_FILE_SIZE_MB = 1; + +export function getArrayFromResponse(data: unknown, arrayKey: string): unknown[] { + if (Array.isArray(data)) return data; + if (data != null && typeof data === 'object' && arrayKey in data) { + const arr = (data as Record)[arrayKey]; + return Array.isArray(arr) ? arr : []; + } + return []; +} + +export function getAssetItems( + assetsData: unknown, +): Array<{ uid?: string; _uid?: string; url?: string; filename?: string; file_name?: string }> { + if (Array.isArray(assetsData)) return assetsData; + const data = assetsData as Record; + const items = data?.items ?? data?.assets; + return Array.isArray(items) ? items : []; +} + +export function getReadableStreamFromDownloadResponse( + response: { data?: NodeJS.ReadableStream } | NodeJS.ReadableStream | null, +): NodeJS.ReadableStream | null { + if (!response) return null; + const withData = response as { data?: NodeJS.ReadableStream }; + if (withData?.data != null) return withData.data; + const stream = response as NodeJS.ReadableStream; + return typeof stream?.pipe === 'function' ? stream : null; +} + +export function writeStreamToFile(stream: NodeJS.ReadableStream, filePath: string): Promise { + const writer = createWriteStream(filePath); + stream.pipe(writer); + return new Promise((resolve, reject) => { + writer.on('finish', () => resolve()); + writer.on('error', reject); + }); +} diff --git a/packages/contentstack-asset-management/src/utils/index.ts b/packages/contentstack-asset-management/src/utils/index.ts new file mode 100644 index 0000000000..fdbfd61335 --- /dev/null +++ b/packages/contentstack-asset-management/src/utils/index.ts @@ -0,0 +1,9 @@ +export { AssetManagementAdapter } from './asset-management-api-adapter'; +export { + BATCH_SIZE, + CHUNK_FILE_SIZE_MB, + getArrayFromResponse, + getAssetItems, + getReadableStreamFromDownloadResponse, + writeStreamToFile, +} from './export-helpers'; diff --git a/packages/contentstack-asset-management/test/unit/export/asset-types.test.ts b/packages/contentstack-asset-management/test/unit/export/asset-types.test.ts new file mode 100644 index 0000000000..54320ae6e1 --- /dev/null +++ b/packages/contentstack-asset-management/test/unit/export/asset-types.test.ts @@ -0,0 +1,95 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; + +import ExportAssetTypes from '../../../src/export/asset-types'; +import { AssetManagementExportAdapter } from '../../../src/export/base'; +import { PROCESS_NAMES } from '../../../src/constants/index'; + +import type { AssetManagementAPIConfig } from '../../../src/types/asset-management-api'; +import type { ExportContext } from '../../../src/types/export-types'; + +describe('ExportAssetTypes', () => { + const apiConfig: AssetManagementAPIConfig = { + baseURL: 'https://am.example.com', + headers: { organization_uid: 'org-1' }, + }; + + const exportContext: ExportContext = { + spacesRootPath: '/tmp/export/spaces', + }; + + const spaceUid = 'space-uid-1'; + const assetTypesDir = '/tmp/export/spaces/asset_types'; + + const assetTypesResponse = { + count: 2, + relation: 'organization', + asset_types: [ + { uid: 'at1', title: 'Image', category: 'image', file_extension: 'png' }, + { uid: 'at2', title: 'Document', category: 'document', file_extension: 'pdf' }, + ], + }; + + beforeEach(() => { + sinon.stub(AssetManagementExportAdapter.prototype, 'init' as any).resolves(); + sinon.stub(AssetManagementExportAdapter.prototype, 'writeItemsToChunkedJson' as any).resolves(); + sinon.stub(AssetManagementExportAdapter.prototype, 'tick' as any); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('start method', () => { + it('should call getWorkspaceAssetTypes with the correct spaceUid', async () => { + const getStub = sinon.stub(ExportAssetTypes.prototype, 'getWorkspaceAssetTypes').resolves(assetTypesResponse); + const exporter = new ExportAssetTypes(apiConfig, exportContext); + await exporter.start(spaceUid); + + expect(getStub.calledOnce).to.be.true; + expect(getStub.calledWith(spaceUid)).to.be.true; + }); + + it('should write asset types with correct chunked JSON args', async () => { + sinon.stub(ExportAssetTypes.prototype, 'getWorkspaceAssetTypes').resolves(assetTypesResponse); + const exporter = new ExportAssetTypes(apiConfig, exportContext); + await exporter.start(spaceUid); + + const writeStub = (AssetManagementExportAdapter.prototype as any).writeItemsToChunkedJson as sinon.SinonStub; + expect(writeStub.calledOnce).to.be.true; + const args = writeStub.firstCall.args; + expect(args[0]).to.equal(assetTypesDir); + expect(args[1]).to.equal('asset-types.json'); + expect(args[2]).to.equal('asset_types'); + expect(args[3]).to.deep.equal(['uid', 'title', 'category', 'file_extension']); + expect(args[4]).to.deep.equal(assetTypesResponse.asset_types); + }); + + it('should write empty items when no asset types returned', async () => { + sinon.stub(ExportAssetTypes.prototype, 'getWorkspaceAssetTypes').resolves({ + count: 0, + relation: 'organization', + asset_types: [], + }); + const exporter = new ExportAssetTypes(apiConfig, exportContext); + await exporter.start(spaceUid); + + const writeStub = (AssetManagementExportAdapter.prototype as any).writeItemsToChunkedJson as sinon.SinonStub; + expect(writeStub.calledOnce).to.be.true; + expect(writeStub.firstCall.args[4]).to.deep.equal([]); + }); + + it('should call tick on success', async () => { + sinon.stub(ExportAssetTypes.prototype, 'getWorkspaceAssetTypes').resolves(assetTypesResponse); + const exporter = new ExportAssetTypes(apiConfig, exportContext); + await exporter.start(spaceUid); + + const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; + expect(tickStub.called).to.be.true; + const args = tickStub.firstCall.args; + expect(args[0]).to.be.true; + expect(args[1]).to.equal(PROCESS_NAMES.AM_ASSET_TYPES); + expect(args[2]).to.be.null; + }); + }); +}); diff --git a/packages/contentstack-asset-management/test/unit/export/assets.test.ts b/packages/contentstack-asset-management/test/unit/export/assets.test.ts new file mode 100644 index 0000000000..64dc4ab421 --- /dev/null +++ b/packages/contentstack-asset-management/test/unit/export/assets.test.ts @@ -0,0 +1,250 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { configHandler } from '@contentstack/cli-utilities'; + +import ExportAssets from '../../../src/export/assets'; +import { AssetManagementExportAdapter } from '../../../src/export/base'; + +import type { AssetManagementAPIConfig, LinkedWorkspace } from '../../../src/types/asset-management-api'; +import type { ExportContext } from '../../../src/types/export-types'; + +const foldersData = [{ uid: 'folder-1', name: 'Images' }]; +const assetsResponseWithItems = { + items: [ + { uid: 'a1', url: 'https://cdn.example.com/a1.png', filename: 'image.png' }, + { uid: 'a2', url: 'https://cdn.example.com/a2.pdf', file_name: 'doc.pdf' }, + ], +}; +const emptyAssetsResponse = { items: [] as any[] }; + +describe('ExportAssets', () => { + const apiConfig: AssetManagementAPIConfig = { + baseURL: 'https://am.example.com', + headers: { organization_uid: 'org-1' }, + }; + + const exportContext: ExportContext = { + spacesRootPath: '/tmp/export/spaces', + }; + + const workspace: LinkedWorkspace = { + uid: 'ws-1', + space_uid: 'space-uid-1', + is_default: true, + }; + + const spaceDir = '/tmp/export/spaces/space-uid-1'; + + let fetchStub: sinon.SinonStub; + + const makeFetchResponse = () => { + const webStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('file-content')); + controller.close(); + }, + }); + return { ok: true, status: 200, body: webStream }; + }; + + beforeEach(() => { + sinon.stub(AssetManagementExportAdapter.prototype, 'init' as any).resolves(); + sinon.stub(AssetManagementExportAdapter.prototype, 'writeItemsToChunkedJson' as any).resolves(); + sinon.stub(AssetManagementExportAdapter.prototype, 'tick' as any); + sinon.stub(AssetManagementExportAdapter.prototype, 'updateStatus' as any); + fetchStub = sinon.stub(globalThis, 'fetch'); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('start method', () => { + it('should fetch folders and assets in parallel', async () => { + const foldersStub = sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); + const assetsStub = sinon.stub(ExportAssets.prototype, 'getWorkspaceAssets').resolves(emptyAssetsResponse); + + const exporter = new ExportAssets(apiConfig, exportContext); + await exporter.start(workspace, spaceDir); + + expect(foldersStub.calledOnce).to.be.true; + expect(foldersStub.calledWith(workspace.space_uid)).to.be.true; + expect(assetsStub.calledOnce).to.be.true; + expect(assetsStub.calledWith(workspace.space_uid)).to.be.true; + }); + + it('should write chunked assets metadata with correct args', async () => { + sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); + sinon.stub(ExportAssets.prototype, 'getWorkspaceAssets').resolves(assetsResponseWithItems); + fetchStub.callsFake(async () => makeFetchResponse() as any); + + const exporter = new ExportAssets(apiConfig, exportContext); + await exporter.start(workspace, spaceDir); + + const writeStub = (AssetManagementExportAdapter.prototype as any).writeItemsToChunkedJson as sinon.SinonStub; + expect(writeStub.calledOnce).to.be.true; + const args = writeStub.firstCall.args; + expect(args[1]).to.equal('assets.json'); + expect(args[2]).to.equal('assets'); + expect(args[3]).to.deep.equal(['uid', 'url', 'filename', 'file_name', 'parent_uid']); + expect(args[4]).to.have.length(2); + }); + + it('should skip downloads when no asset items exist', async () => { + sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); + sinon.stub(ExportAssets.prototype, 'getWorkspaceAssets').resolves(emptyAssetsResponse); + + const exporter = new ExportAssets(apiConfig, exportContext); + await exporter.start(workspace, spaceDir); + + const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; + const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); + expect(downloadTick).to.be.undefined; + }); + + it('should handle download failures gracefully without throwing', async () => { + sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); + sinon.stub(ExportAssets.prototype, 'getWorkspaceAssets').resolves(assetsResponseWithItems); + fetchStub.rejects(new Error('network failure')); + + const exporter = new ExportAssets(apiConfig, exportContext); + await exporter.start(workspace, spaceDir); + + const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; + const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); + expect(downloadTick).to.not.be.undefined; + expect(downloadTick!.args[0]).to.be.false; + expect(downloadTick!.args[2]).to.equal('network failure'); + }); + + it('should tick success for downloads when all succeed', async () => { + sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); + sinon.stub(ExportAssets.prototype, 'getWorkspaceAssets').resolves(assetsResponseWithItems); + fetchStub.callsFake(async () => makeFetchResponse() as any); + + const exporter = new ExportAssets(apiConfig, exportContext); + await exporter.start(workspace, spaceDir); + + const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; + const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); + expect(downloadTick).to.not.be.undefined; + expect(downloadTick!.args[0]).to.be.true; + expect(downloadTick!.args[2]).to.be.null; + }); + + it('should skip assets with no url or uid', async () => { + const incompleteAssets = { + items: [ + { uid: 'a1', url: null as any }, + { url: 'https://cdn.example.com/a2.png', filename: 'img.png' }, + { uid: null as any, url: null as any }, + ], + }; + sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); + sinon.stub(ExportAssets.prototype, 'getWorkspaceAssets').resolves(incompleteAssets); + + const exporter = new ExportAssets(apiConfig, exportContext); + await exporter.start(workspace, spaceDir); + + expect(fetchStub.called).to.be.false; + }); + + it('should use _uid when uid is not present on asset', async () => { + const assetsWithUnderscoreUid = { + items: [{ _uid: 'a-uid', url: 'https://cdn.example.com/a.png', filename: 'a.png' }], + }; + sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); + sinon.stub(ExportAssets.prototype, 'getWorkspaceAssets').resolves(assetsWithUnderscoreUid); + fetchStub.callsFake(async () => makeFetchResponse() as any); + + const exporter = new ExportAssets(apiConfig, exportContext); + await exporter.start(workspace, spaceDir); + + const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; + const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); + expect(downloadTick).to.not.be.undefined; + expect(downloadTick!.args[0]).to.be.true; + }); + + it('should use file_name when filename is not present, defaulting to "asset"', async () => { + const assetsNoFilename = { + items: [ + { uid: 'a1', url: 'https://cdn.example.com/a1', file_name: 'named.pdf' }, + { uid: 'a2', url: 'https://cdn.example.com/a2' }, + ], + }; + sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); + sinon.stub(ExportAssets.prototype, 'getWorkspaceAssets').resolves(assetsNoFilename); + fetchStub.callsFake(async () => makeFetchResponse() as any); + + const exporter = new ExportAssets(apiConfig, exportContext); + await exporter.start(workspace, spaceDir); + + expect(fetchStub.callCount).to.equal(2); + }); + + it('should append authtoken to URL when securedAssets is true', async () => { + sinon.stub(configHandler, 'get').returns('my-auth-token'); + sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); + sinon.stub(ExportAssets.prototype, 'getWorkspaceAssets').resolves({ + items: [{ uid: 'a1', url: 'https://cdn.example.com/a1.png', filename: 'img.png' }], + }); + fetchStub.callsFake(async () => makeFetchResponse() as any); + + const securedContext: typeof exportContext = { ...exportContext, securedAssets: true }; + const exporter = new ExportAssets(apiConfig, securedContext); + await exporter.start(workspace, spaceDir); + + const downloadUrl = fetchStub.firstCall.args[0] as string; + expect(downloadUrl).to.include('authtoken=my-auth-token'); + }); + + it('should use "&" separator when URL already contains "?"', async () => { + sinon.stub(configHandler, 'get').returns('my-token'); + sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); + sinon.stub(ExportAssets.prototype, 'getWorkspaceAssets').resolves({ + items: [{ uid: 'a1', url: 'https://cdn.example.com/a1?v=1', filename: 'img.png' }], + }); + fetchStub.callsFake(async () => makeFetchResponse() as any); + + const securedContext: typeof exportContext = { ...exportContext, securedAssets: true }; + const exporter = new ExportAssets(apiConfig, securedContext); + await exporter.start(workspace, spaceDir); + + const downloadUrl = fetchStub.firstCall.args[0] as string; + expect(downloadUrl).to.include('?v=1&authtoken='); + }); + + it('should handle non-ok HTTP response as download failure', async () => { + sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); + sinon.stub(ExportAssets.prototype, 'getWorkspaceAssets').resolves({ + items: [{ uid: 'a1', url: 'https://cdn.example.com/a1.png', filename: 'img.png' }], + }); + fetchStub.resolves({ ok: false, status: 403, body: null } as any); + + const exporter = new ExportAssets(apiConfig, exportContext); + await exporter.start(workspace, spaceDir); + + const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; + const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); + expect(downloadTick!.args[0]).to.be.false; + expect(downloadTick!.args[2]).to.include('403'); + }); + + it('should handle missing response body as download failure', async () => { + sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); + sinon.stub(ExportAssets.prototype, 'getWorkspaceAssets').resolves({ + items: [{ uid: 'a1', url: 'https://cdn.example.com/a1.png', filename: 'img.png' }], + }); + fetchStub.resolves({ ok: true, status: 200, body: null } as any); + + const exporter = new ExportAssets(apiConfig, exportContext); + await exporter.start(workspace, spaceDir); + + const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; + const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); + expect(downloadTick!.args[0]).to.be.false; + expect(downloadTick!.args[2]).to.equal('No response body'); + }); + }); +}); diff --git a/packages/contentstack-asset-management/test/unit/export/base.test.ts b/packages/contentstack-asset-management/test/unit/export/base.test.ts new file mode 100644 index 0000000000..e8be9fc814 --- /dev/null +++ b/packages/contentstack-asset-management/test/unit/export/base.test.ts @@ -0,0 +1,237 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { FsUtility, CLIProgressManager, configHandler } from '@contentstack/cli-utilities'; + +import { AssetManagementExportAdapter } from '../../../src/export/base'; + +import type { AssetManagementAPIConfig } from '../../../src/types/asset-management-api'; +import type { ExportContext } from '../../../src/types/export-types'; + +class TestAdapter extends AssetManagementExportAdapter { + public callCreateNestedProgress(name: string) { + return this.createNestedProgress(name); + } + public callTick(success: boolean, name: string, error: string | null) { + return this.tick(success, name, error); + } + public callUpdateStatus(msg: string) { + return this.updateStatus(msg); + } + public callCompleteProcess(name: string, success: boolean) { + return this.completeProcess(name, success); + } + public callWriteItemsToChunkedJson(...args: Parameters) { + return this.writeItemsToChunkedJson(...args); + } + public getProgressOrParent() { + return this.progressOrParent; + } + public getAssetTypesDirPublic() { + return this.getAssetTypesDir(); + } + public getFieldsDirPublic() { + return this.getFieldsDir(); + } + public get spacesRootPathPublic() { + return this.spacesRootPath; + } +} + +describe('AssetManagementExportAdapter (base)', () => { + const apiConfig: AssetManagementAPIConfig = { + baseURL: 'https://am.example.com', + headers: { organization_uid: 'org-1' }, + }; + + const exportContext: ExportContext = { + spacesRootPath: '/tmp/export/spaces', + }; + + beforeEach(() => { + sinon.stub(AssetManagementExportAdapter.prototype, 'init' as any).resolves(); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('constructor + path helpers', () => { + it('should expose spacesRootPath from exportContext', () => { + const adapter = new TestAdapter(apiConfig, exportContext); + expect(adapter.spacesRootPathPublic).to.equal('/tmp/export/spaces'); + }); + + it('should build getAssetTypesDir from spacesRootPath', () => { + const adapter = new TestAdapter(apiConfig, exportContext); + expect(adapter.getAssetTypesDirPublic()).to.include('asset_types'); + }); + + it('should build getFieldsDir from spacesRootPath', () => { + const adapter = new TestAdapter(apiConfig, exportContext); + expect(adapter.getFieldsDirPublic()).to.include('fields'); + }); + }); + + describe('setParentProgressManager / progressOrParent getter', () => { + it('should return null when no progress manager is set', () => { + const adapter = new TestAdapter(apiConfig, exportContext); + expect(adapter.getProgressOrParent()).to.be.null; + }); + + it('should return parentProgressManager when set', () => { + const fakeParent = { tick: sinon.stub() } as any; + const adapter = new TestAdapter(apiConfig, exportContext); + adapter.setParentProgressManager(fakeParent); + expect(adapter.getProgressOrParent()).to.equal(fakeParent); + }); + + it('should return progressManager when parentProgressManager is not set', () => { + sinon.stub(configHandler, 'get').returns({}); + const fakeProgress = { tick: sinon.stub() } as any; + sinon.stub(CLIProgressManager, 'createNested').returns(fakeProgress); + + const adapter = new TestAdapter(apiConfig, exportContext); + adapter.callCreateNestedProgress('test'); + expect(adapter.getProgressOrParent()).to.equal(fakeProgress); + }); + }); + + describe('createNestedProgress', () => { + it('should create a new CLIProgressManager when no parent is set', () => { + const getStub = sinon.stub(configHandler, 'get').returns({ showConsoleLogs: true }); + const fakeProgress = { tick: sinon.stub() } as any; + const createNestedStub = sinon.stub(CLIProgressManager, 'createNested').returns(fakeProgress); + + const adapter = new TestAdapter(apiConfig, exportContext); + const result = adapter.callCreateNestedProgress('my-module'); + + expect(createNestedStub.calledOnce).to.be.true; + expect(createNestedStub.firstCall.args[0]).to.equal('my-module'); + expect(result).to.equal(fakeProgress); + }); + + it('should return parentProgressManager directly when parent is set', () => { + const fakeParent = { tick: sinon.stub() } as any; + const adapter = new TestAdapter(apiConfig, exportContext); + adapter.setParentProgressManager(fakeParent); + + const result = adapter.callCreateNestedProgress('ignored'); + expect(result).to.equal(fakeParent); + }); + + it('should default showConsoleLogs to false when log config is missing', () => { + sinon.stub(configHandler, 'get').returns(null); + const fakeProgress = { tick: sinon.stub() } as any; + const createNestedStub = sinon.stub(CLIProgressManager, 'createNested').returns(fakeProgress); + + const adapter = new TestAdapter(apiConfig, exportContext); + adapter.callCreateNestedProgress('test'); + + expect(createNestedStub.firstCall.args[1]).to.be.false; + }); + }); + + describe('tick', () => { + it('should call tick on progressOrParent when available', () => { + const fakeParent = { tick: sinon.stub(), updateStatus: sinon.stub() } as any; + const adapter = new TestAdapter(apiConfig, exportContext); + adapter.setParentProgressManager(fakeParent); + + adapter.callTick(true, 'my-item', null); + + expect(fakeParent.tick.calledOnce).to.be.true; + const args = fakeParent.tick.firstCall.args; + expect(args[0]).to.be.true; + expect(args[1]).to.equal('my-item'); + expect(args[2]).to.be.null; + }); + + it('should not throw when progressOrParent is null', () => { + const adapter = new TestAdapter(apiConfig, exportContext); + expect(() => adapter.callTick(true, 'item', null)).to.not.throw(); + }); + }); + + describe('updateStatus', () => { + it('should call updateStatus on progressOrParent when available', () => { + const fakeParent = { tick: sinon.stub(), updateStatus: sinon.stub() } as any; + const adapter = new TestAdapter(apiConfig, exportContext); + adapter.setParentProgressManager(fakeParent); + + adapter.callUpdateStatus('Fetching...'); + + expect(fakeParent.updateStatus.calledOnce).to.be.true; + expect(fakeParent.updateStatus.firstCall.args[0]).to.equal('Fetching...'); + }); + + it('should not throw when progressOrParent is null', () => { + const adapter = new TestAdapter(apiConfig, exportContext); + expect(() => adapter.callUpdateStatus('msg')).to.not.throw(); + }); + }); + + describe('completeProcess', () => { + it('should call completeProcess on progressManager when no parent is set', () => { + sinon.stub(configHandler, 'get').returns({}); + const fakeProgress = { tick: sinon.stub(), completeProcess: sinon.stub() } as any; + sinon.stub(CLIProgressManager, 'createNested').returns(fakeProgress); + + const adapter = new TestAdapter(apiConfig, exportContext); + adapter.callCreateNestedProgress('test'); + adapter.callCompleteProcess('test', true); + + expect(fakeProgress.completeProcess.calledWith('test', true)).to.be.true; + }); + + it('should NOT call completeProcess when parentProgressManager is set', () => { + const fakeParent = { tick: sinon.stub(), completeProcess: sinon.stub() } as any; + const adapter = new TestAdapter(apiConfig, exportContext); + adapter.setParentProgressManager(fakeParent); + + adapter.callCompleteProcess('test', true); + + expect(fakeParent.completeProcess.called).to.be.false; + }); + }); + + describe('writeItemsToChunkedJson', () => { + it('should write {} to an empty file when items array is empty', async () => { + const os = require('node:os'); + const path = require('node:path'); + const fsReal = require('node:fs'); + const tmpDir = os.tmpdir(); + const adapter = new TestAdapter(apiConfig, exportContext); + await adapter.callWriteItemsToChunkedJson(tmpDir, 'test-empty.json', 'items', ['uid'], []); + + const written = fsReal.readFileSync(path.join(tmpDir, 'test-empty.json'), 'utf-8'); + expect(written).to.equal('{}'); + fsReal.unlinkSync(path.join(tmpDir, 'test-empty.json')); + }); + + it('should use FsUtility to write items in batches when items exist', async () => { + const writeIntoFileStub = sinon.stub(FsUtility.prototype, 'writeIntoFile'); + const completeFileStub = sinon.stub(FsUtility.prototype, 'completeFile'); + + const items = Array.from({ length: 3 }, (_, i) => ({ uid: `item-${i}` })); + const adapter = new TestAdapter(apiConfig, exportContext); + await adapter.callWriteItemsToChunkedJson('/tmp/dir', 'items.json', 'items', ['uid'], items); + + expect(writeIntoFileStub.called).to.be.true; + expect(completeFileStub.calledWith(true)).to.be.true; + }); + + it('should write items in batches of BATCH_SIZE (50)', async () => { + const writeIntoFileStub = sinon.stub(FsUtility.prototype, 'writeIntoFile'); + sinon.stub(FsUtility.prototype, 'completeFile'); + + const items = Array.from({ length: 120 }, (_, i) => ({ uid: `item-${i}` })); + const adapter = new TestAdapter(apiConfig, exportContext); + await adapter.callWriteItemsToChunkedJson('/tmp/dir', 'items.json', 'items', ['uid'], items); + + expect(writeIntoFileStub.callCount).to.equal(3); + expect(writeIntoFileStub.firstCall.args[0]).to.have.length(50); + expect(writeIntoFileStub.secondCall.args[0]).to.have.length(50); + expect(writeIntoFileStub.thirdCall.args[0]).to.have.length(20); + }); + }); +}); diff --git a/packages/contentstack-asset-management/test/unit/export/fields.test.ts b/packages/contentstack-asset-management/test/unit/export/fields.test.ts new file mode 100644 index 0000000000..9be76ba915 --- /dev/null +++ b/packages/contentstack-asset-management/test/unit/export/fields.test.ts @@ -0,0 +1,95 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; + +import ExportFields from '../../../src/export/fields'; +import { AssetManagementExportAdapter } from '../../../src/export/base'; +import { PROCESS_NAMES } from '../../../src/constants/index'; + +import type { AssetManagementAPIConfig } from '../../../src/types/asset-management-api'; +import type { ExportContext } from '../../../src/types/export-types'; + +describe('ExportFields', () => { + const apiConfig: AssetManagementAPIConfig = { + baseURL: 'https://am.example.com', + headers: { organization_uid: 'org-1' }, + }; + + const exportContext: ExportContext = { + spacesRootPath: '/tmp/export/spaces', + }; + + const spaceUid = 'space-uid-1'; + const fieldsDir = '/tmp/export/spaces/fields'; + + const fieldsResponse = { + count: 2, + relation: 'organization', + fields: [ + { uid: 'f1', title: 'Tags', display_type: 'text' }, + { uid: 'f2', title: 'Description', display_type: 'textarea' }, + ], + }; + + beforeEach(() => { + sinon.stub(AssetManagementExportAdapter.prototype, 'init' as any).resolves(); + sinon.stub(AssetManagementExportAdapter.prototype, 'writeItemsToChunkedJson' as any).resolves(); + sinon.stub(AssetManagementExportAdapter.prototype, 'tick' as any); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('start method', () => { + it('should call getWorkspaceFields with the correct spaceUid', async () => { + const getFieldsStub = sinon.stub(ExportFields.prototype, 'getWorkspaceFields').resolves(fieldsResponse); + const exporter = new ExportFields(apiConfig, exportContext); + await exporter.start(spaceUid); + + expect(getFieldsStub.calledOnce).to.be.true; + expect(getFieldsStub.calledWith(spaceUid)).to.be.true; + }); + + it('should write fields with correct chunked JSON args', async () => { + sinon.stub(ExportFields.prototype, 'getWorkspaceFields').resolves(fieldsResponse); + const exporter = new ExportFields(apiConfig, exportContext); + await exporter.start(spaceUid); + + const writeStub = (AssetManagementExportAdapter.prototype as any).writeItemsToChunkedJson as sinon.SinonStub; + expect(writeStub.calledOnce).to.be.true; + const args = writeStub.firstCall.args; + expect(args[0]).to.equal(fieldsDir); + expect(args[1]).to.equal('fields.json'); + expect(args[2]).to.equal('fields'); + expect(args[3]).to.deep.equal(['uid', 'title', 'display_type']); + expect(args[4]).to.deep.equal(fieldsResponse.fields); + }); + + it('should write empty items when no fields returned', async () => { + sinon.stub(ExportFields.prototype, 'getWorkspaceFields').resolves({ + count: 0, + relation: 'organization', + fields: [], + }); + const exporter = new ExportFields(apiConfig, exportContext); + await exporter.start(spaceUid); + + const writeStub = (AssetManagementExportAdapter.prototype as any).writeItemsToChunkedJson as sinon.SinonStub; + expect(writeStub.calledOnce).to.be.true; + expect(writeStub.firstCall.args[4]).to.deep.equal([]); + }); + + it('should call tick on success', async () => { + sinon.stub(ExportFields.prototype, 'getWorkspaceFields').resolves(fieldsResponse); + const exporter = new ExportFields(apiConfig, exportContext); + await exporter.start(spaceUid); + + const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; + expect(tickStub.called).to.be.true; + const args = tickStub.firstCall.args; + expect(args[0]).to.be.true; + expect(args[1]).to.equal(PROCESS_NAMES.AM_FIELDS); + expect(args[2]).to.be.null; + }); + }); +}); diff --git a/packages/contentstack-asset-management/test/unit/export/spaces.test.ts b/packages/contentstack-asset-management/test/unit/export/spaces.test.ts new file mode 100644 index 0000000000..093e7297ed --- /dev/null +++ b/packages/contentstack-asset-management/test/unit/export/spaces.test.ts @@ -0,0 +1,134 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { CLIProgressManager, configHandler } from '@contentstack/cli-utilities'; + +import { ExportSpaces, exportSpaceStructure } from '../../../src/export/spaces'; +import ExportAssetTypes from '../../../src/export/asset-types'; +import ExportFields from '../../../src/export/fields'; +import ExportWorkspace from '../../../src/export/workspaces'; +import { AssetManagementExportAdapter } from '../../../src/export/base'; + +import type { AssetManagementExportOptions } from '../../../src/types/asset-management-api'; + +describe('ExportSpaces', () => { + const baseOptions: AssetManagementExportOptions = { + linkedWorkspaces: [ + { uid: 'ws-1', space_uid: 'space-1', is_default: true }, + { uid: 'ws-2', space_uid: 'space-2', is_default: false }, + ], + exportDir: '/tmp/export', + branchName: 'main', + assetManagementUrl: 'https://am.example.com', + org_uid: 'org-1', + }; + + const fakeProgress = { + addProcess: sinon.stub().returnsThis(), + startProcess: sinon.stub().returnsThis(), + updateStatus: sinon.stub().returnsThis(), + tick: sinon.stub(), + completeProcess: sinon.stub(), + }; + + beforeEach(() => { + sinon.stub(AssetManagementExportAdapter.prototype, 'init' as any).resolves(); + sinon.stub(configHandler, 'get').returns({ showConsoleLogs: false }); + sinon.stub(CLIProgressManager, 'createNested').returns(fakeProgress as any); + sinon.stub(ExportAssetTypes.prototype, 'start').resolves(); + sinon.stub(ExportAssetTypes.prototype, 'setParentProgressManager'); + sinon.stub(ExportFields.prototype, 'start').resolves(); + sinon.stub(ExportFields.prototype, 'setParentProgressManager'); + sinon.stub(ExportWorkspace.prototype, 'start').resolves(); + sinon.stub(ExportWorkspace.prototype, 'setParentProgressManager'); + + fakeProgress.addProcess.returnsThis(); + fakeProgress.startProcess.returnsThis(); + fakeProgress.updateStatus.returnsThis(); + fakeProgress.tick.reset(); + fakeProgress.completeProcess.reset(); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('start method', () => { + it('should return early when linkedWorkspaces is empty', async () => { + const exporter = new ExportSpaces({ ...baseOptions, linkedWorkspaces: [] }); + await exporter.start(); + + expect((ExportAssetTypes.prototype.start as sinon.SinonStub).called).to.be.false; + expect((ExportFields.prototype.start as sinon.SinonStub).called).to.be.false; + }); + + it('should export shared asset types and fields from the first workspace', async () => { + const exporter = new ExportSpaces(baseOptions); + await exporter.start(); + + const atStub = ExportAssetTypes.prototype.start as sinon.SinonStub; + expect(atStub.calledOnce).to.be.true; + expect(atStub.firstCall.args[0]).to.equal('space-1'); + + const fieldsStub = ExportFields.prototype.start as sinon.SinonStub; + expect(fieldsStub.calledOnce).to.be.true; + expect(fieldsStub.firstCall.args[0]).to.equal('space-1'); + }); + + it('should iterate over all workspaces', async () => { + const exporter = new ExportSpaces(baseOptions); + await exporter.start(); + + const wsStub = ExportWorkspace.prototype.start as sinon.SinonStub; + expect(wsStub.callCount).to.equal(2); + expect(wsStub.firstCall.args[0]).to.deep.include({ space_uid: 'space-1' }); + expect(wsStub.secondCall.args[0]).to.deep.include({ space_uid: 'space-2' }); + }); + + it('should complete progress on success', async () => { + const exporter = new ExportSpaces(baseOptions); + await exporter.start(); + + expect(fakeProgress.completeProcess.calledOnce).to.be.true; + expect(fakeProgress.completeProcess.firstCall.args[1]).to.be.true; + }); + + it('should re-throw and complete progress with failure when a workspace export fails', async () => { + (ExportWorkspace.prototype.start as sinon.SinonStub).rejects(new Error('workspace-error')); + + const exporter = new ExportSpaces(baseOptions); + try { + await exporter.start(); + expect.fail('should have thrown'); + } catch (err: any) { + expect(err.message).to.equal('workspace-error'); + } + + expect(fakeProgress.completeProcess.called).to.be.true; + const lastCall = fakeProgress.completeProcess.lastCall; + expect(lastCall.args[1]).to.be.false; + }); + + it('should use parentProgressManager directly when setParentProgressManager was called', async () => { + const fakeParent = { + addProcess: sinon.stub().returnsThis(), + startProcess: sinon.stub().returnsThis(), + updateStatus: sinon.stub().returnsThis(), + tick: sinon.stub(), + completeProcess: sinon.stub(), + }; + const exporter = new ExportSpaces(baseOptions); + exporter.setParentProgressManager(fakeParent as any); + await exporter.start(); + + expect((CLIProgressManager.createNested as sinon.SinonStub).called).to.be.false; + expect(fakeParent.completeProcess.called).to.be.true; + }); + }); + + describe('exportSpaceStructure', () => { + it('should delegate to ExportSpaces.start', async () => { + await exportSpaceStructure({ ...baseOptions, linkedWorkspaces: [] }); + expect((ExportAssetTypes.prototype.start as sinon.SinonStub).called).to.be.false; + }); + }); +}); diff --git a/packages/contentstack-asset-management/test/unit/export/workspaces.test.ts b/packages/contentstack-asset-management/test/unit/export/workspaces.test.ts new file mode 100644 index 0000000000..f94eb3f702 --- /dev/null +++ b/packages/contentstack-asset-management/test/unit/export/workspaces.test.ts @@ -0,0 +1,115 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; + +import ExportWorkspace from '../../../src/export/workspaces'; +import ExportAssets from '../../../src/export/assets'; +import { AssetManagementExportAdapter } from '../../../src/export/base'; + +import type { AssetManagementAPIConfig, LinkedWorkspace, SpaceResponse } from '../../../src/types/asset-management-api'; +import type { ExportContext } from '../../../src/types/export-types'; + +describe('ExportWorkspace', () => { + const apiConfig: AssetManagementAPIConfig = { + baseURL: 'https://am.example.com', + headers: { organization_uid: 'org-1' }, + }; + + const exportContext: ExportContext = { + spacesRootPath: '/tmp/export/spaces', + }; + + const workspace: LinkedWorkspace = { + uid: 'ws-1', + space_uid: 'space-uid-1', + is_default: true, + }; + + const spaceDir = '/tmp/export/spaces/space-uid-1'; + const branchName = 'develop'; + + const spaceResponse: SpaceResponse = { + space: { + uid: 'space-uid-1', + title: 'My Space', + org_uid: 'org-1', + }, + }; + + beforeEach(() => { + sinon.stub(AssetManagementExportAdapter.prototype, 'init' as any).resolves(); + sinon.stub(AssetManagementExportAdapter.prototype, 'tick' as any); + sinon.stub(ExportAssets.prototype, 'start').resolves(); + sinon.stub(ExportAssets.prototype, 'setParentProgressManager'); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('start method', () => { + it('should call getSpace with the workspace space_uid', async () => { + const getSpaceStub = sinon.stub(ExportWorkspace.prototype, 'getSpace').resolves(spaceResponse); + const exporter = new ExportWorkspace(apiConfig, exportContext); + await exporter.start(workspace, spaceDir, branchName); + + expect(getSpaceStub.calledOnce).to.be.true; + expect(getSpaceStub.calledWith(workspace.space_uid)).to.be.true; + }); + + it('should tick success after writing metadata', async () => { + sinon.stub(ExportWorkspace.prototype, 'getSpace').resolves(spaceResponse); + const exporter = new ExportWorkspace(apiConfig, exportContext); + await exporter.start(workspace, spaceDir, branchName); + + const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; + expect(tickStub.called).to.be.true; + const args = tickStub.firstCall.args; + expect(args[0]).to.be.true; + expect(args[1]).to.equal(`space: ${workspace.space_uid}`); + expect(args[2]).to.be.null; + }); + + it('should delegate to ExportAssets.start with workspace and spaceDir', async () => { + sinon.stub(ExportWorkspace.prototype, 'getSpace').resolves(spaceResponse); + const exporter = new ExportWorkspace(apiConfig, exportContext); + await exporter.start(workspace, spaceDir, branchName); + + const startStub = ExportAssets.prototype.start as sinon.SinonStub; + expect(startStub.calledOnce).to.be.true; + const args = startStub.firstCall.args; + expect(args[0]).to.deep.equal(workspace); + expect(args[1]).to.equal(spaceDir); + }); + + it('should use "main" as branch when branchName is empty', async () => { + sinon.stub(ExportWorkspace.prototype, 'getSpace').resolves(spaceResponse); + const exporter = new ExportWorkspace(apiConfig, exportContext); + await exporter.start(workspace, spaceDir, ''); + + const startStub = ExportAssets.prototype.start as sinon.SinonStub; + expect(startStub.calledOnce).to.be.true; + }); + + it('should NOT call setParentProgressManager on assets exporter when progressOrParent is null', async () => { + sinon.stub(ExportWorkspace.prototype, 'getSpace').resolves(spaceResponse); + const setParentStub = ExportAssets.prototype.setParentProgressManager as sinon.SinonStub; + + const exporter = new ExportWorkspace(apiConfig, exportContext); + await exporter.start(workspace, spaceDir, branchName); + + expect(setParentStub.called).to.be.false; + }); + + it('should call setParentProgressManager on assets exporter when a progress manager is set', async () => { + sinon.stub(ExportWorkspace.prototype, 'getSpace').resolves(spaceResponse); + const fakeProgress = { tick: sinon.stub(), updateStatus: sinon.stub() } as any; + const setParentStub = ExportAssets.prototype.setParentProgressManager as sinon.SinonStub; + + const exporter = new ExportWorkspace(apiConfig, exportContext); + exporter.setParentProgressManager(fakeProgress); + await exporter.start(workspace, spaceDir, branchName); + + expect(setParentStub.calledWith(fakeProgress)).to.be.true; + }); + }); +}); diff --git a/packages/contentstack-asset-management/test/unit/utils/asset-management-api-adapter.test.ts b/packages/contentstack-asset-management/test/unit/utils/asset-management-api-adapter.test.ts new file mode 100644 index 0000000000..b2f9a024e2 --- /dev/null +++ b/packages/contentstack-asset-management/test/unit/utils/asset-management-api-adapter.test.ts @@ -0,0 +1,224 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { HttpClient, authenticationHandler } from '@contentstack/cli-utilities'; + +import { AssetManagementAdapter } from '../../../src/utils/asset-management-api-adapter'; + +import type { AssetManagementAPIConfig } from '../../../src/types/asset-management-api'; + +describe('AssetManagementAdapter', () => { + const baseConfig: AssetManagementAPIConfig = { + baseURL: 'https://am.example.com', + headers: { organization_uid: 'org-1' }, + }; + + let headersStub: sinon.SinonStub; + let baseUrlStub: sinon.SinonStub; + let getStub: sinon.SinonStub; + + beforeEach(() => { + headersStub = sinon.stub(HttpClient.prototype, 'headers').returnsThis(); + baseUrlStub = sinon.stub(HttpClient.prototype, 'baseUrl').returnsThis(); + getStub = sinon.stub(HttpClient.prototype, 'get'); + sinon.stub(authenticationHandler, 'getAuthDetails').resolves(); + sinon.stub(authenticationHandler, 'isOauthEnabled').get(() => false); + sinon.stub(authenticationHandler, 'accessToken').get(() => 'test-token-123'); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('constructor', () => { + it('should set the baseURL with trailing slash stripped', () => { + new AssetManagementAdapter({ baseURL: 'https://am.example.com/' }); + expect(baseUrlStub.calledWith('https://am.example.com')).to.be.true; + }); + + it('should set default headers with x-cs-api-version when no extra headers provided', () => { + new AssetManagementAdapter({ baseURL: 'https://am.example.com' }); + const allHeaderArgs = headersStub.getCalls().map((c) => c.args[0]); + const apiVersionCall = allHeaderArgs.find((h) => 'x-cs-api-version' in h); + expect(apiVersionCall).to.exist; + expect(apiVersionCall['x-cs-api-version']).to.equal('4'); + expect(apiVersionCall['Accept']).to.equal('application/json'); + }); + + it('should merge extra headers with default headers', () => { + new AssetManagementAdapter(baseConfig); + const allHeaderArgs = headersStub.getCalls().map((c) => c.args[0]); + const apiVersionCall = allHeaderArgs.find((h) => 'x-cs-api-version' in h); + expect(apiVersionCall).to.exist; + expect(apiVersionCall['x-cs-api-version']).to.equal('4'); + expect(apiVersionCall['organization_uid']).to.equal('org-1'); + }); + + it('should handle empty baseURL gracefully', () => { + new AssetManagementAdapter({ baseURL: '' }); + expect(baseUrlStub.calledWith('')).to.be.true; + }); + }); + + describe('init', () => { + it('should set access_token header when OAuth is disabled', async () => { + const adapter = new AssetManagementAdapter(baseConfig); + await adapter.init(); + + const authCallArgs = headersStub.getCalls().map((c) => c.args[0]); + const authCall = authCallArgs.find((a) => 'access_token' in a); + expect(authCall).to.exist; + expect(authCall.access_token).to.equal('test-token-123'); + }); + + describe('when OAuth is enabled', () => { + beforeEach(() => { + sinon.restore(); + sinon.stub(HttpClient.prototype, 'headers').returnsThis(); + sinon.stub(HttpClient.prototype, 'baseUrl').returnsThis(); + sinon.stub(HttpClient.prototype, 'get'); + sinon.stub(authenticationHandler, 'getAuthDetails').resolves(); + sinon.stub(authenticationHandler, 'isOauthEnabled').get(() => true); + sinon.stub(authenticationHandler, 'accessToken').get(() => 'oauth-bearer-token'); + }); + + it('should set authorization header', async () => { + const capturedHeaders = HttpClient.prototype.headers as sinon.SinonStub; + const adapter = new AssetManagementAdapter(baseConfig); + await adapter.init(); + + const authCallArgs = capturedHeaders.getCalls().map((c) => c.args[0]); + const authCall = authCallArgs.find((a: any) => 'authorization' in a); + expect(authCall).to.exist; + expect(authCall.authorization).to.equal('oauth-bearer-token'); + }); + }); + + it('should re-throw errors from getAuthDetails', async () => { + (authenticationHandler.getAuthDetails as sinon.SinonStub).rejects(new Error('auth-failed')); + const adapter = new AssetManagementAdapter(baseConfig); + + try { + await adapter.init(); + expect.fail('should have thrown'); + } catch (err: any) { + expect(err.message).to.equal('auth-failed'); + } + }); + + it('should merge config headers with auth header when config.headers is present', async () => { + const adapter = new AssetManagementAdapter(baseConfig); + await adapter.init(); + + const capturedHeaders = headersStub.getCalls().map((c) => c.args[0]); + const authCall = capturedHeaders.find((a) => 'access_token' in a); + expect(authCall).to.include({ organization_uid: 'org-1' }); + }); + }); + + describe('getSpace', () => { + it('should call GET /api/spaces/{spaceUid} with addl_fields query params', async () => { + getStub.resolves({ status: 200, data: { space: { uid: 'sp-1' } } }); + const adapter = new AssetManagementAdapter(baseConfig); + const result = await adapter.getSpace('sp-1'); + + expect(getStub.calledOnce).to.be.true; + const path = getStub.firstCall.args[0] as string; + expect(path).to.include('/api/spaces/sp-1'); + expect(path).to.include('addl_fields'); + expect(result).to.deep.equal({ space: { uid: 'sp-1' } }); + }); + + it('should throw when response status is non-2xx', async () => { + getStub.resolves({ status: 404, data: null }); + const adapter = new AssetManagementAdapter(baseConfig); + + try { + await adapter.getSpace('missing-space'); + expect.fail('should have thrown'); + } catch (err: any) { + expect(err.message).to.include('404'); + } + }); + }); + + describe('getWorkspaceFields', () => { + it('should call GET /api/fields', async () => { + const fieldsResponse = { count: 1, relation: 'org', fields: [{ uid: 'f1' }] }; + getStub.resolves({ status: 200, data: fieldsResponse }); + const adapter = new AssetManagementAdapter(baseConfig); + const result = await adapter.getWorkspaceFields('sp-1'); + + expect(getStub.calledOnce).to.be.true; + expect(getStub.firstCall.args[0]).to.equal('/api/fields'); + expect(result).to.deep.equal(fieldsResponse); + }); + }); + + describe('getWorkspaceAssets', () => { + it('should call GET /api/spaces/{spaceUid}/assets', async () => { + getStub.resolves({ status: 200, data: { items: [] } }); + const adapter = new AssetManagementAdapter(baseConfig); + await adapter.getWorkspaceAssets('sp-1'); + + expect(getStub.calledOnce).to.be.true; + expect(getStub.firstCall.args[0]).to.include('/api/spaces/sp-1/assets'); + }); + + it('should URL-encode the spaceUid in the path', async () => { + getStub.resolves({ status: 200, data: { items: [] } }); + const adapter = new AssetManagementAdapter(baseConfig); + await adapter.getWorkspaceAssets('sp uid/special'); + + const path = getStub.firstCall.args[0] as string; + expect(path).to.include('sp%20uid%2Fspecial'); + }); + }); + + describe('getWorkspaceFolders', () => { + it('should call GET /api/spaces/{spaceUid}/folders', async () => { + getStub.resolves({ status: 200, data: [] }); + const adapter = new AssetManagementAdapter(baseConfig); + await adapter.getWorkspaceFolders('sp-1'); + + expect(getStub.calledOnce).to.be.true; + expect(getStub.firstCall.args[0]).to.include('/api/spaces/sp-1/folders'); + }); + }); + + describe('getWorkspaceAssetTypes', () => { + it('should call GET /api/asset_types with include_fields=true', async () => { + const atResponse = { count: 1, relation: 'org', asset_types: [{ uid: 'at1' }] }; + getStub.resolves({ status: 200, data: atResponse }); + const adapter = new AssetManagementAdapter(baseConfig); + const result = await adapter.getWorkspaceAssetTypes('sp-1'); + + expect(getStub.calledOnce).to.be.true; + const path = getStub.firstCall.args[0] as string; + expect(path).to.include('/api/asset_types'); + expect(path).to.include('include_fields=true'); + expect(result).to.deep.equal(atResponse); + }); + }); + + describe('buildQueryString (via public methods)', () => { + it('should encode array values as repeated key=value pairs', async () => { + getStub.resolves({ status: 200, data: { space: { uid: 'sp-1' } } }); + const adapter = new AssetManagementAdapter(baseConfig); + await adapter.getSpace('sp-1'); + + const path = getStub.firstCall.args[0] as string; + expect(path).to.include('addl_fields=meta_info'); + expect(path).to.include('addl_fields=users'); + }); + + it('should return empty string and no "?" when params are empty', async () => { + getStub.resolves({ status: 200, data: { count: 0, relation: '', fields: [] } }); + const adapter = new AssetManagementAdapter(baseConfig); + await adapter.getWorkspaceFields('sp-1'); + + const path = getStub.firstCall.args[0] as string; + expect(path).to.equal('/api/fields'); + expect(path).to.not.include('?'); + }); + }); +}); diff --git a/packages/contentstack-asset-management/test/unit/utils/export-helpers.test.ts b/packages/contentstack-asset-management/test/unit/utils/export-helpers.test.ts new file mode 100644 index 0000000000..adcd20896a --- /dev/null +++ b/packages/contentstack-asset-management/test/unit/utils/export-helpers.test.ts @@ -0,0 +1,125 @@ +import { expect } from 'chai'; +import { PassThrough } from 'node:stream'; + +import { + getArrayFromResponse, + getAssetItems, + getReadableStreamFromDownloadResponse, + writeStreamToFile, +} from '../../../src/utils/export-helpers'; + +describe('export-helpers', () => { + describe('getArrayFromResponse', () => { + it('should return the input when it is already an array', () => { + const arr = [1, 2, 3]; + expect(getArrayFromResponse(arr, 'items')).to.equal(arr); + }); + + it('should extract nested array by key', () => { + const data = { fields: [{ uid: 'f1' }, { uid: 'f2' }] }; + const result = getArrayFromResponse(data, 'fields'); + expect(result).to.deep.equal([{ uid: 'f1' }, { uid: 'f2' }]); + }); + + it('should return [] when key exists but value is not an array', () => { + const data = { fields: 'not-an-array' }; + expect(getArrayFromResponse(data, 'fields')).to.deep.equal([]); + }); + + it('should return [] when key is missing', () => { + const data = { other: [1] }; + expect(getArrayFromResponse(data, 'fields')).to.deep.equal([]); + }); + + it('should return [] for null input', () => { + expect(getArrayFromResponse(null, 'key')).to.deep.equal([]); + }); + + it('should return [] for undefined input', () => { + expect(getArrayFromResponse(undefined, 'key')).to.deep.equal([]); + }); + + it('should return [] for non-object input (number)', () => { + expect(getArrayFromResponse(42, 'key')).to.deep.equal([]); + }); + }); + + describe('getAssetItems', () => { + it('should return the input when it is already an array', () => { + const arr = [{ uid: 'a1' }]; + expect(getAssetItems(arr)).to.equal(arr); + }); + + it('should extract from data.items', () => { + const data = { items: [{ uid: 'a1', url: 'http://example.com/a1' }] }; + expect(getAssetItems(data)).to.deep.equal(data.items); + }); + + it('should extract from data.assets', () => { + const data = { assets: [{ uid: 'a2', filename: 'img.png' }] }; + expect(getAssetItems(data)).to.deep.equal(data.assets); + }); + + it('should prefer data.items over data.assets', () => { + const data = { items: [{ uid: 'from-items' }], assets: [{ uid: 'from-assets' }] }; + expect(getAssetItems(data)).to.deep.equal([{ uid: 'from-items' }]); + }); + + it('should return [] when neither key exists', () => { + expect(getAssetItems({ other: 'value' })).to.deep.equal([]); + }); + + it('should return [] for null input', () => { + expect(getAssetItems(null)).to.deep.equal([]); + }); + }); + + describe('getReadableStreamFromDownloadResponse', () => { + it('should return null for null input', () => { + expect(getReadableStreamFromDownloadResponse(null)).to.be.null; + }); + + it('should extract response.data when present', () => { + const inner = new PassThrough(); + const response = { data: inner }; + expect(getReadableStreamFromDownloadResponse(response)).to.equal(inner); + }); + + it('should return the stream itself if it has .pipe', () => { + const stream = new PassThrough(); + expect(getReadableStreamFromDownloadResponse(stream as any)).to.equal(stream); + }); + + it('should return null for non-stream objects without data', () => { + const obj = { something: 'else' } as any; + expect(getReadableStreamFromDownloadResponse(obj)).to.be.null; + }); + }); + + describe('writeStreamToFile', () => { + it('should resolve when stream finishes writing', async () => { + const source = new PassThrough(); + const tmpPath = require('node:path').join(require('node:os').tmpdir(), `test-write-${Date.now()}.txt`); + + const promise = writeStreamToFile(source, tmpPath); + source.end('hello world'); + await promise; + + const content = require('node:fs').readFileSync(tmpPath, 'utf-8'); + expect(content).to.equal('hello world'); + require('node:fs').unlinkSync(tmpPath); + }); + + it('should reject when the write stream errors', async () => { + const source = new PassThrough(); + const badPath = '/nonexistent-dir-xyz/file.txt'; + + try { + await writeStreamToFile(source, badPath); + expect.fail('should have thrown'); + } catch (err: any) { + expect(err.code).to.equal('ENOENT'); + } + }); + }); +}); diff --git a/packages/contentstack-asset-management/tsconfig.json b/packages/contentstack-asset-management/tsconfig.json new file mode 100644 index 0000000000..5136623392 --- /dev/null +++ b/packages/contentstack-asset-management/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "declaration": true, + "importHelpers": true, + "module": "commonjs", + "outDir": "lib", + "rootDir": "src", + "strict": false, + "target": "es2017", + "allowJs": true, + "skipLibCheck": true, + "sourceMap": false, + "esModuleInterop": true, + "noImplicitAny": true, + "lib": [ + "ES2019", + "es2020.promise" + ], + "strictPropertyInitialization": false, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "src/**/*", + "types/*" + ], + "exclude": [ + "node_modules", + "lib" + ] +} \ No newline at end of file diff --git a/packages/contentstack-export/package.json b/packages/contentstack-export/package.json index 923ee790f3..d115672d83 100644 --- a/packages/contentstack-export/package.json +++ b/packages/contentstack-export/package.json @@ -8,6 +8,7 @@ "@contentstack/cli-command": "~2.0.0-beta", "@contentstack/cli-utilities": "~2.0.0-beta.1", "@contentstack/cli-variants": "~2.0.0-beta.7", + "@contentstack/cli-asset-management": "1.0.0", "@oclif/core": "^4.8.0", "async": "^3.2.6", "big-json": "^3.2.0", diff --git a/packages/contentstack-export/src/export/modules/assets.ts b/packages/contentstack-export/src/export/modules/assets.ts index 0d6233f0ed..ff6314338d 100644 --- a/packages/contentstack-export/src/export/modules/assets.ts +++ b/packages/contentstack-export/src/export/modules/assets.ts @@ -25,7 +25,14 @@ import { PATH_CONSTANTS } from '../../constants'; import config from '../../config'; import { ModuleClassParams } from '../../types'; import BaseClass, { CustomPromiseHandler, CustomPromiseHandlerInput } from './base-class'; -import { PROCESS_NAMES, MODULE_CONTEXTS, PROCESS_STATUS, MODULE_NAMES } from '../../utils'; +import { ExportSpaces } from '@contentstack/cli-asset-management'; +import { + PROCESS_NAMES, + MODULE_CONTEXTS, + PROCESS_STATUS, + MODULE_NAMES, + getOrgUid, +} from '../../utils'; export default class ExportAssets extends BaseClass { private assetsRootPath: string; @@ -48,7 +55,45 @@ export default class ExportAssets extends BaseClass { } async start(): Promise { - this.assetsRootPath = pResolve( + const linkedWorkspaces = this.exportConfig.linkedWorkspaces ?? []; + + if (linkedWorkspaces.length > 0) { + const assetManagementUrl = this.exportConfig.region?.assetManagementUrl; + if (!assetManagementUrl) { + this.completeProgress( + false, + 'Asset Management URL is required for AM 2.0 export. Ensure your region is configured with assetManagementUrl.', + ); + throw new Error( + 'Asset Management URL is required for AM 2.0 export. Ensure your region is configured with assetManagementUrl.', + ); + } + log.debug(`Exporting with AM 2.0: ${assetManagementUrl} (linked_workspaces from exportConfig)`, this.exportConfig.context); + this.exportConfig.org_uid = this.exportConfig.org_uid || (await getOrgUid(this.exportConfig)); + const progress = this.createNestedProgress(this.currentModuleName); + try { + const exporter = new ExportSpaces({ + linkedWorkspaces, + exportDir: this.exportConfig.exportDir, + branchName: this.exportConfig.branchName || 'main', + assetManagementUrl, + org_uid: this.exportConfig.org_uid ?? '', + context: this.exportConfig.context as unknown as Record, + securedAssets: this.exportConfig.securedAssets, + }); + exporter.setParentProgressManager(progress); + await exporter.start(); + this.completeProgressWithMessage(); + } catch (error) { + this.completeProgress(false, (error as Error)?.message ?? 'Asset Management export failed'); + throw error; + } + return; + } + + log.debug('Using legacy asset export (no linked_workspaces in exportConfig)', this.exportConfig.context); + + this.assetsRootPath = pResolve( this.exportConfig.exportDir, this.exportConfig.branchName || '', this.assetConfig.dirName, diff --git a/packages/contentstack-export/src/export/modules/stack.ts b/packages/contentstack-export/src/export/modules/stack.ts index 5007235dae..e4898815d2 100644 --- a/packages/contentstack-export/src/export/modules/stack.ts +++ b/packages/contentstack-export/src/export/modules/stack.ts @@ -1,11 +1,6 @@ import find from 'lodash/find'; import { resolve as pResolve } from 'node:path'; -import { - handleAndLogError, - isAuthenticated, - managementSDKClient, - log, -} from '@contentstack/cli-utilities'; +import { handleAndLogError, isAuthenticated, managementSDKClient, log } from '@contentstack/cli-utilities'; import { PATH_CONSTANTS } from '../../constants'; import BaseClass from './base-class'; @@ -15,6 +10,7 @@ import { MODULE_CONTEXTS, PROCESS_STATUS, MODULE_NAMES, + getLinkedWorkspacesForBranch, } from '../../utils'; import { StackConfig, ModuleClassParams } from '../../types'; @@ -79,10 +75,7 @@ export default class ExportStack extends BaseClass { if (!this.exportConfig.management_token) { progress .startProcess(PROCESS_NAMES.STACK_SETTINGS) - .updateStatus( - PROCESS_STATUS[PROCESS_NAMES.STACK_SETTINGS].EXPORTING, - PROCESS_NAMES.STACK_SETTINGS, - ); + .updateStatus(PROCESS_STATUS[PROCESS_NAMES.STACK_SETTINGS].EXPORTING, PROCESS_NAMES.STACK_SETTINGS); await this.exportStackSettings(); progress.completeProcess(PROCESS_NAMES.STACK_SETTINGS, true); } else { @@ -95,10 +88,7 @@ export default class ExportStack extends BaseClass { if (!this.exportConfig.preserveStackVersion && !this.exportConfig.hasOwnProperty('master_locale')) { progress .startProcess(PROCESS_NAMES.STACK_LOCALE) - .updateStatus( - PROCESS_STATUS[PROCESS_NAMES.STACK_LOCALE].FETCHING, - PROCESS_NAMES.STACK_LOCALE, - ); + .updateStatus(PROCESS_STATUS[PROCESS_NAMES.STACK_LOCALE].FETCHING, PROCESS_NAMES.STACK_LOCALE); const masterLocale = await this.getLocales(); progress.completeProcess(PROCESS_NAMES.STACK_LOCALE, true); @@ -112,10 +102,7 @@ export default class ExportStack extends BaseClass { } else if (this.exportConfig.preserveStackVersion) { progress .startProcess(PROCESS_NAMES.STACK_DETAILS) - .updateStatus( - PROCESS_STATUS[PROCESS_NAMES.STACK_DETAILS].EXPORTING, - PROCESS_NAMES.STACK_DETAILS, - ); + .updateStatus(PROCESS_STATUS[PROCESS_NAMES.STACK_DETAILS].EXPORTING, PROCESS_NAMES.STACK_DETAILS); const stackResult = await this.exportStack(); progress.completeProcess(PROCESS_NAMES.STACK_DETAILS, true); @@ -126,7 +113,6 @@ export default class ExportStack extends BaseClass { } this.completeProgressWithMessage(); - } catch (error) { log.debug('Error occurred during stack export', this.exportConfig.context); handleAndLogError(error, { ...this.exportConfig.context }); @@ -233,18 +219,13 @@ export default class ExportStack extends BaseClass { return this.stack .fetch() - .then((resp: any) => { + .then(async (resp: any) => { const stackFilePath = pResolve(this.stackFolderPath, this.stackConfig.fileName); log.debug(`Writing stack data to: '${stackFilePath}'`, this.exportConfig.context); fsUtil.writeFile(stackFilePath, resp); // Track progress for stack export completion - this.progressManager?.tick( - true, - `stack: ${this.exportConfig.apiKey}`, - null, - PROCESS_NAMES.STACK_DETAILS, - ); + this.progressManager?.tick(true, `stack: ${this.exportConfig.apiKey}`, null, PROCESS_NAMES.STACK_DETAILS); log.success( `Stack details exported successfully for stack ${this.exportConfig.apiKey}`, @@ -270,14 +251,26 @@ export default class ExportStack extends BaseClass { await fsUtil.makeDirectory(this.stackFolderPath); return this.stack .settings() - .then((resp: any) => { - fsUtil.writeFile(pResolve(this.stackFolderPath, PATH_CONSTANTS.FILES.SETTINGS), resp); + .then(async (resp: any) => { + const linked = await getLinkedWorkspacesForBranch( + this.stack, + this.exportConfig.branchName || 'main', + this.exportConfig.context as unknown as Record, + ); + const settings = { + ...resp, + am_v2: { ...(resp.am_v2 ?? {}), linked_workspaces: linked }, + }; + fsUtil.writeFile(pResolve(this.stackFolderPath, PATH_CONSTANTS.FILES.SETTINGS), settings); + + this.exportConfig.linkedWorkspaces = linked; // Track progress for stack settings completion this.progressManager?.tick(true, 'stack settings', null, PROCESS_NAMES.STACK_SETTINGS); + log.debug(`Included ${linked.length} linked workspace(s) in settings`, this.exportConfig.context); log.success('Exported stack settings successfully!', this.exportConfig.context); - return resp; + return settings; }) .catch((error: any) => { this.progressManager?.tick( diff --git a/packages/contentstack-export/src/types/export-config.ts b/packages/contentstack-export/src/types/export-config.ts index 8b0e1b37bd..978a6c04aa 100644 --- a/packages/contentstack-export/src/types/export-config.ts +++ b/packages/contentstack-export/src/types/export-config.ts @@ -36,6 +36,7 @@ export default interface ExportConfig extends DefaultConfig { skipStackSettings?: boolean; skipDependencies?: boolean; authenticationMethod?: string; + linkedWorkspaces?: Array<{ uid: string; space_uid: string; is_default: boolean }>; } type branch = { diff --git a/packages/contentstack-export/src/types/index.ts b/packages/contentstack-export/src/types/index.ts index 63baf41e65..60d044a1ac 100644 --- a/packages/contentstack-export/src/types/index.ts +++ b/packages/contentstack-export/src/types/index.ts @@ -32,6 +32,7 @@ export interface Region { cma: string; cda: string; uiHost: string; + assetManagementUrl?: string; } export type Modules = diff --git a/packages/contentstack-export/src/utils/constants.ts b/packages/contentstack-export/src/utils/constants.ts index fc2f7dd287..a44441b21c 100644 --- a/packages/contentstack-export/src/utils/constants.ts +++ b/packages/contentstack-export/src/utils/constants.ts @@ -3,6 +3,11 @@ export const PROCESS_NAMES = { ASSET_FOLDERS: 'Folders', ASSET_METADATA: 'Metadata', ASSET_DOWNLOADS: 'Downloads', + /** Used when Assets module runs Asset Management 2.0 path (spaces, metadata, folders, assets, downloads). */ + ASSET_MANAGEMENT_SPACES: 'Spaces & assets', + + // Asset Management 2.0 module + ASSET_MANAGEMENT_EXPORT: 'Asset Management 2.0', // Custom Roles module FETCH_ROLES: 'Fetch Roles', @@ -37,6 +42,7 @@ export const PROCESS_NAMES = { export const MODULE_CONTEXTS = { ASSETS: 'assets', + ASSET_MANAGEMENT: 'asset-management', CONTENT_TYPES: 'content-types', CUSTOM_ROLES: 'custom-roles', ENTRIES: 'entries', @@ -56,6 +62,7 @@ export const MODULE_CONTEXTS = { // Display names for modules to avoid scattering user-facing strings export const MODULE_NAMES = { [MODULE_CONTEXTS.ASSETS]: 'Assets', + [MODULE_CONTEXTS.ASSET_MANAGEMENT]: 'Asset Management 2.0', [MODULE_CONTEXTS.CONTENT_TYPES]: 'Content Types', [MODULE_CONTEXTS.CUSTOM_ROLES]: 'Custom Roles', [MODULE_CONTEXTS.ENTRIES]: 'Entries', @@ -86,6 +93,15 @@ export const PROCESS_STATUS = { DOWNLOADING: 'Downloading asset file...', FAILED: 'Failed to download asset:', }, + [PROCESS_NAMES.ASSET_MANAGEMENT_SPACES]: { + EXPORTING: 'Exporting spaces & assets...', + FAILED: 'Failed to export spaces & assets.', + }, + // Asset Management 2.0 + [PROCESS_NAMES.ASSET_MANAGEMENT_EXPORT]: { + EXPORTING: 'Exporting...', + FAILED: 'Asset Management export failed.', + }, // Custom Roles [PROCESS_NAMES.FETCH_ROLES]: { FETCHING: 'Fetching custom roles...', diff --git a/packages/contentstack-export/src/utils/get-linked-workspaces.ts b/packages/contentstack-export/src/utils/get-linked-workspaces.ts new file mode 100644 index 0000000000..c9f17338ef --- /dev/null +++ b/packages/contentstack-export/src/utils/get-linked-workspaces.ts @@ -0,0 +1,33 @@ +import { log, handleAndLogError } from '@contentstack/cli-utilities'; +import type { LinkedWorkspace } from '@contentstack/cli-asset-management'; + +/** Stack client with branch().fetch() for CMA branch details */ +type StackWithBranch = { branch: (name: string) => { fetch: (params?: Record) => Promise } }; + +/** + * Fetch branch details with include_settings: true and return linked workspaces (am_v2). + * Reused by stack export (included in settings.json) and asset-management module. + */ +export async function getLinkedWorkspacesForBranch( + stack: StackWithBranch, + branchName: string, + context?: Record, +): Promise { + log.debug(`Fetching branch details for: ${branchName}`, context); + try { + const branch = await stack.branch(branchName).fetch({ include_settings: true } as Record); + const linked = (branch as any)?.settings?.am_v2?.linked_workspaces; + if (!Array.isArray(linked)) { + log.debug('No linked_workspaces in branch settings', context); + return []; + } + log.info( + `Found ${linked.length} linked workspace(s) for branch ${branchName}`, + context, + ); + return linked as LinkedWorkspace[]; + } catch (error) { + handleAndLogError(error as Error, context as any, 'Failed to fetch branch settings'); + return []; + } +} diff --git a/packages/contentstack-export/src/utils/index.ts b/packages/contentstack-export/src/utils/index.ts index 9cbd32cac5..2b5dd9eeae 100644 --- a/packages/contentstack-export/src/utils/index.ts +++ b/packages/contentstack-export/src/utils/index.ts @@ -8,4 +8,5 @@ export { log, unlinkFileLogger } from './logger'; export { default as login } from './basic-login'; export * from './common-helper'; export * from './marketplace-app-helper'; +export { getLinkedWorkspacesForBranch } from './get-linked-workspaces'; export { MODULE_CONTEXTS, MODULE_NAMES, PROCESS_NAMES, PROCESS_STATUS } from './constants'; diff --git a/packages/contentstack-export/src/utils/progress-strategy-registry.ts b/packages/contentstack-export/src/utils/progress-strategy-registry.ts index ed68231385..fa6a19923d 100644 --- a/packages/contentstack-export/src/utils/progress-strategy-registry.ts +++ b/packages/contentstack-export/src/utils/progress-strategy-registry.ts @@ -1,3 +1,4 @@ +import { AM_MAIN_PROCESS_NAME } from '@contentstack/cli-asset-management'; import { MODULE_CONTEXTS, MODULE_NAMES, PROCESS_NAMES } from './constants'; /** * Progress Strategy Registrations for Export Modules @@ -20,8 +21,7 @@ try { ProgressStrategyRegistry.register( MODULE_NAMES[MODULE_CONTEXTS.ASSETS], new CustomProgressStrategy((processes) => { - // Both ASSET_METADATA and ASSET_DOWNLOADS represent the same assets - // Count only the downloads process to avoid double counting in summary + // Legacy path: both ASSET_METADATA and ASSET_DOWNLOADS represent the same assets const downloadsProcess = processes.get(PROCESS_NAMES.ASSET_DOWNLOADS); if (downloadsProcess) { return { @@ -31,6 +31,24 @@ try { }; } + // Asset Management 2.0 path (process name owned by AM package) + const amProcess = processes.get(AM_MAIN_PROCESS_NAME); + if (amProcess) { + return { + total: amProcess.total, + success: amProcess.successCount, + failures: amProcess.failureCount, + }; + } + const spacesProcess = processes.get(PROCESS_NAMES.ASSET_MANAGEMENT_SPACES); + if (spacesProcess) { + return { + total: spacesProcess.total, + success: spacesProcess.successCount, + failures: spacesProcess.failureCount, + }; + } + // Fallback to metadata process if downloads don't exist const metadataProcess = processes.get(PROCESS_NAMES.ASSET_METADATA); if (metadataProcess) { From f14bac7ae90fe77974de302b423512930707e503 Mon Sep 17 00:00:00 2001 From: naman-contentstack Date: Fri, 27 Feb 2026 15:42:36 +0530 Subject: [PATCH 2/3] chore: updated test cases --- .../test/unit/export/asset-types.test.ts | 13 ++--- .../test/unit/export/assets.test.ts | 42 ++++++++------- .../test/unit/export/base.test.ts | 40 +++++++------- .../test/unit/export/fields.test.ts | 13 ++--- .../test/unit/export/spaces.test.ts | 54 +++++++++++-------- .../test/unit/export/workspaces.test.ts | 36 +++++++------ .../asset-management-api-adapter.test.ts | 19 +++---- 7 files changed, 104 insertions(+), 113 deletions(-) diff --git a/packages/contentstack-asset-management/test/unit/export/asset-types.test.ts b/packages/contentstack-asset-management/test/unit/export/asset-types.test.ts index 54320ae6e1..af052e2db5 100644 --- a/packages/contentstack-asset-management/test/unit/export/asset-types.test.ts +++ b/packages/contentstack-asset-management/test/unit/export/asset-types.test.ts @@ -46,8 +46,7 @@ describe('ExportAssetTypes', () => { const exporter = new ExportAssetTypes(apiConfig, exportContext); await exporter.start(spaceUid); - expect(getStub.calledOnce).to.be.true; - expect(getStub.calledWith(spaceUid)).to.be.true; + expect(getStub.firstCall.args[0]).to.equal(spaceUid); }); it('should write asset types with correct chunked JSON args', async () => { @@ -56,7 +55,6 @@ describe('ExportAssetTypes', () => { await exporter.start(spaceUid); const writeStub = (AssetManagementExportAdapter.prototype as any).writeItemsToChunkedJson as sinon.SinonStub; - expect(writeStub.calledOnce).to.be.true; const args = writeStub.firstCall.args; expect(args[0]).to.equal(assetTypesDir); expect(args[1]).to.equal('asset-types.json'); @@ -75,21 +73,16 @@ describe('ExportAssetTypes', () => { await exporter.start(spaceUid); const writeStub = (AssetManagementExportAdapter.prototype as any).writeItemsToChunkedJson as sinon.SinonStub; - expect(writeStub.calledOnce).to.be.true; expect(writeStub.firstCall.args[4]).to.deep.equal([]); }); - it('should call tick on success', async () => { + it('should tick with success=true, the asset types process name, and null error', async () => { sinon.stub(ExportAssetTypes.prototype, 'getWorkspaceAssetTypes').resolves(assetTypesResponse); const exporter = new ExportAssetTypes(apiConfig, exportContext); await exporter.start(spaceUid); const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - expect(tickStub.called).to.be.true; - const args = tickStub.firstCall.args; - expect(args[0]).to.be.true; - expect(args[1]).to.equal(PROCESS_NAMES.AM_ASSET_TYPES); - expect(args[2]).to.be.null; + expect(tickStub.firstCall.args).to.deep.equal([true, PROCESS_NAMES.AM_ASSET_TYPES, null]); }); }); }); diff --git a/packages/contentstack-asset-management/test/unit/export/assets.test.ts b/packages/contentstack-asset-management/test/unit/export/assets.test.ts index 64dc4ab421..763285ca29 100644 --- a/packages/contentstack-asset-management/test/unit/export/assets.test.ts +++ b/packages/contentstack-asset-management/test/unit/export/assets.test.ts @@ -60,17 +60,15 @@ describe('ExportAssets', () => { }); describe('start method', () => { - it('should fetch folders and assets in parallel', async () => { + it('should fetch folders and assets using the workspace space_uid', async () => { const foldersStub = sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); const assetsStub = sinon.stub(ExportAssets.prototype, 'getWorkspaceAssets').resolves(emptyAssetsResponse); const exporter = new ExportAssets(apiConfig, exportContext); await exporter.start(workspace, spaceDir); - expect(foldersStub.calledOnce).to.be.true; - expect(foldersStub.calledWith(workspace.space_uid)).to.be.true; - expect(assetsStub.calledOnce).to.be.true; - expect(assetsStub.calledWith(workspace.space_uid)).to.be.true; + expect(foldersStub.firstCall.args[0]).to.equal(workspace.space_uid); + expect(assetsStub.firstCall.args[0]).to.equal(workspace.space_uid); }); it('should write chunked assets metadata with correct args', async () => { @@ -82,7 +80,6 @@ describe('ExportAssets', () => { await exporter.start(workspace, spaceDir); const writeStub = (AssetManagementExportAdapter.prototype as any).writeItemsToChunkedJson as sinon.SinonStub; - expect(writeStub.calledOnce).to.be.true; const args = writeStub.firstCall.args; expect(args[1]).to.equal('assets.json'); expect(args[2]).to.equal('assets'); @@ -90,19 +87,20 @@ describe('ExportAssets', () => { expect(args[4]).to.have.length(2); }); - it('should skip downloads when no asset items exist', async () => { + it('should not attempt any downloads when the asset list is empty', async () => { sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); sinon.stub(ExportAssets.prototype, 'getWorkspaceAssets').resolves(emptyAssetsResponse); const exporter = new ExportAssets(apiConfig, exportContext); await exporter.start(workspace, spaceDir); + expect(fetchStub.callCount).to.equal(0); const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); expect(downloadTick).to.be.undefined; }); - it('should handle download failures gracefully without throwing', async () => { + it('should tick with success=false and the error message on download failure', async () => { sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); sinon.stub(ExportAssets.prototype, 'getWorkspaceAssets').resolves(assetsResponseWithItems); fetchStub.rejects(new Error('network failure')); @@ -112,12 +110,11 @@ describe('ExportAssets', () => { const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); - expect(downloadTick).to.not.be.undefined; expect(downloadTick!.args[0]).to.be.false; expect(downloadTick!.args[2]).to.equal('network failure'); }); - it('should tick success for downloads when all succeed', async () => { + it('should tick with success=true and null error on successful downloads', async () => { sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); sinon.stub(ExportAssets.prototype, 'getWorkspaceAssets').resolves(assetsResponseWithItems); fetchStub.callsFake(async () => makeFetchResponse() as any); @@ -127,12 +124,11 @@ describe('ExportAssets', () => { const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); - expect(downloadTick).to.not.be.undefined; expect(downloadTick!.args[0]).to.be.true; expect(downloadTick!.args[2]).to.be.null; }); - it('should skip assets with no url or uid', async () => { + it('should skip assets that have neither a url nor a uid', async () => { const incompleteAssets = { items: [ { uid: 'a1', url: null as any }, @@ -146,10 +142,10 @@ describe('ExportAssets', () => { const exporter = new ExportAssets(apiConfig, exportContext); await exporter.start(workspace, spaceDir); - expect(fetchStub.called).to.be.false; + expect(fetchStub.callCount).to.equal(0); }); - it('should use _uid when uid is not present on asset', async () => { + it('should process assets that have _uid instead of uid without skipping them', async () => { const assetsWithUnderscoreUid = { items: [{ _uid: 'a-uid', url: 'https://cdn.example.com/a.png', filename: 'a.png' }], }; @@ -160,17 +156,18 @@ describe('ExportAssets', () => { const exporter = new ExportAssets(apiConfig, exportContext); await exporter.start(workspace, spaceDir); + expect(fetchStub.firstCall.args[0]).to.equal('https://cdn.example.com/a.png'); const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); - expect(downloadTick).to.not.be.undefined; expect(downloadTick!.args[0]).to.be.true; + expect(downloadTick!.args[2]).to.be.null; }); - it('should use file_name when filename is not present, defaulting to "asset"', async () => { + it('should download assets that use file_name, and fall back to "asset" when both names are absent', async () => { const assetsNoFilename = { items: [ - { uid: 'a1', url: 'https://cdn.example.com/a1', file_name: 'named.pdf' }, - { uid: 'a2', url: 'https://cdn.example.com/a2' }, + { uid: 'a1', url: 'https://cdn.example.com/a1.pdf', file_name: 'named.pdf' }, + { uid: 'a2', url: 'https://cdn.example.com/a2.bin' }, ], }; sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); @@ -181,6 +178,11 @@ describe('ExportAssets', () => { await exporter.start(workspace, spaceDir); expect(fetchStub.callCount).to.equal(2); + expect(fetchStub.firstCall.args[0]).to.equal('https://cdn.example.com/a1.pdf'); + expect(fetchStub.secondCall.args[0]).to.equal('https://cdn.example.com/a2.bin'); + const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; + const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); + expect(downloadTick!.args[0]).to.be.true; }); it('should append authtoken to URL when securedAssets is true', async () => { @@ -215,7 +217,7 @@ describe('ExportAssets', () => { expect(downloadUrl).to.include('?v=1&authtoken='); }); - it('should handle non-ok HTTP response as download failure', async () => { + it('should tick with success=false and the HTTP status code on non-ok response', async () => { sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); sinon.stub(ExportAssets.prototype, 'getWorkspaceAssets').resolves({ items: [{ uid: 'a1', url: 'https://cdn.example.com/a1.png', filename: 'img.png' }], @@ -231,7 +233,7 @@ describe('ExportAssets', () => { expect(downloadTick!.args[2]).to.include('403'); }); - it('should handle missing response body as download failure', async () => { + it('should tick with success=false and "No response body" when body is null', async () => { sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); sinon.stub(ExportAssets.prototype, 'getWorkspaceAssets').resolves({ items: [{ uid: 'a1', url: 'https://cdn.example.com/a1.png', filename: 'img.png' }], diff --git a/packages/contentstack-asset-management/test/unit/export/base.test.ts b/packages/contentstack-asset-management/test/unit/export/base.test.ts index e8be9fc814..84264fcc8c 100644 --- a/packages/contentstack-asset-management/test/unit/export/base.test.ts +++ b/packages/contentstack-asset-management/test/unit/export/base.test.ts @@ -61,14 +61,16 @@ describe('AssetManagementExportAdapter (base)', () => { expect(adapter.spacesRootPathPublic).to.equal('/tmp/export/spaces'); }); - it('should build getAssetTypesDir from spacesRootPath', () => { + it('should build getAssetTypesDir as /asset_types', () => { const adapter = new TestAdapter(apiConfig, exportContext); - expect(adapter.getAssetTypesDirPublic()).to.include('asset_types'); + const expected = require('node:path').join('/tmp/export/spaces', 'asset_types'); + expect(adapter.getAssetTypesDirPublic()).to.equal(expected); }); - it('should build getFieldsDir from spacesRootPath', () => { + it('should build getFieldsDir as /fields', () => { const adapter = new TestAdapter(apiConfig, exportContext); - expect(adapter.getFieldsDirPublic()).to.include('fields'); + const expected = require('node:path').join('/tmp/export/spaces', 'fields'); + expect(adapter.getFieldsDirPublic()).to.equal(expected); }); }); @@ -97,15 +99,14 @@ describe('AssetManagementExportAdapter (base)', () => { }); describe('createNestedProgress', () => { - it('should create a new CLIProgressManager when no parent is set', () => { - const getStub = sinon.stub(configHandler, 'get').returns({ showConsoleLogs: true }); + it('should create a new CLIProgressManager with the given name and showConsoleLogs flag', () => { + sinon.stub(configHandler, 'get').returns({ showConsoleLogs: true }); const fakeProgress = { tick: sinon.stub() } as any; const createNestedStub = sinon.stub(CLIProgressManager, 'createNested').returns(fakeProgress); const adapter = new TestAdapter(apiConfig, exportContext); const result = adapter.callCreateNestedProgress('my-module'); - expect(createNestedStub.calledOnce).to.be.true; expect(createNestedStub.firstCall.args[0]).to.equal('my-module'); expect(result).to.equal(fakeProgress); }); @@ -132,18 +133,16 @@ describe('AssetManagementExportAdapter (base)', () => { }); describe('tick', () => { - it('should call tick on progressOrParent when available', () => { + it('should forward success, item name, and error to the progress manager tick', () => { const fakeParent = { tick: sinon.stub(), updateStatus: sinon.stub() } as any; const adapter = new TestAdapter(apiConfig, exportContext); adapter.setParentProgressManager(fakeParent); adapter.callTick(true, 'my-item', null); - expect(fakeParent.tick.calledOnce).to.be.true; - const args = fakeParent.tick.firstCall.args; - expect(args[0]).to.be.true; - expect(args[1]).to.equal('my-item'); - expect(args[2]).to.be.null; + expect(fakeParent.tick.firstCall.args[0]).to.equal(true); + expect(fakeParent.tick.firstCall.args[1]).to.equal('my-item'); + expect(fakeParent.tick.firstCall.args[2]).to.be.null; }); it('should not throw when progressOrParent is null', () => { @@ -153,14 +152,13 @@ describe('AssetManagementExportAdapter (base)', () => { }); describe('updateStatus', () => { - it('should call updateStatus on progressOrParent when available', () => { + it('should forward the status message to the progress manager', () => { const fakeParent = { tick: sinon.stub(), updateStatus: sinon.stub() } as any; const adapter = new TestAdapter(apiConfig, exportContext); adapter.setParentProgressManager(fakeParent); adapter.callUpdateStatus('Fetching...'); - expect(fakeParent.updateStatus.calledOnce).to.be.true; expect(fakeParent.updateStatus.firstCall.args[0]).to.equal('Fetching...'); }); @@ -171,7 +169,7 @@ describe('AssetManagementExportAdapter (base)', () => { }); describe('completeProcess', () => { - it('should call completeProcess on progressManager when no parent is set', () => { + it('should call completeProcess on progressManager with the given name and success flag', () => { sinon.stub(configHandler, 'get').returns({}); const fakeProgress = { tick: sinon.stub(), completeProcess: sinon.stub() } as any; sinon.stub(CLIProgressManager, 'createNested').returns(fakeProgress); @@ -180,7 +178,7 @@ describe('AssetManagementExportAdapter (base)', () => { adapter.callCreateNestedProgress('test'); adapter.callCompleteProcess('test', true); - expect(fakeProgress.completeProcess.calledWith('test', true)).to.be.true; + expect(fakeProgress.completeProcess.firstCall.args).to.deep.equal(['test', true]); }); it('should NOT call completeProcess when parentProgressManager is set', () => { @@ -190,7 +188,7 @@ describe('AssetManagementExportAdapter (base)', () => { adapter.callCompleteProcess('test', true); - expect(fakeParent.completeProcess.called).to.be.false; + expect(fakeParent.completeProcess.callCount).to.equal(0); }); }); @@ -208,7 +206,7 @@ describe('AssetManagementExportAdapter (base)', () => { fsReal.unlinkSync(path.join(tmpDir, 'test-empty.json')); }); - it('should use FsUtility to write items in batches when items exist', async () => { + it('should write all items in a single batch and complete the file when count is below BATCH_SIZE', async () => { const writeIntoFileStub = sinon.stub(FsUtility.prototype, 'writeIntoFile'); const completeFileStub = sinon.stub(FsUtility.prototype, 'completeFile'); @@ -216,8 +214,8 @@ describe('AssetManagementExportAdapter (base)', () => { const adapter = new TestAdapter(apiConfig, exportContext); await adapter.callWriteItemsToChunkedJson('/tmp/dir', 'items.json', 'items', ['uid'], items); - expect(writeIntoFileStub.called).to.be.true; - expect(completeFileStub.calledWith(true)).to.be.true; + expect(writeIntoFileStub.firstCall.args[0]).to.have.length(3); + expect(completeFileStub.firstCall.args[0]).to.be.true; }); it('should write items in batches of BATCH_SIZE (50)', async () => { diff --git a/packages/contentstack-asset-management/test/unit/export/fields.test.ts b/packages/contentstack-asset-management/test/unit/export/fields.test.ts index 9be76ba915..a039dcb75f 100644 --- a/packages/contentstack-asset-management/test/unit/export/fields.test.ts +++ b/packages/contentstack-asset-management/test/unit/export/fields.test.ts @@ -46,8 +46,7 @@ describe('ExportFields', () => { const exporter = new ExportFields(apiConfig, exportContext); await exporter.start(spaceUid); - expect(getFieldsStub.calledOnce).to.be.true; - expect(getFieldsStub.calledWith(spaceUid)).to.be.true; + expect(getFieldsStub.firstCall.args[0]).to.equal(spaceUid); }); it('should write fields with correct chunked JSON args', async () => { @@ -56,7 +55,6 @@ describe('ExportFields', () => { await exporter.start(spaceUid); const writeStub = (AssetManagementExportAdapter.prototype as any).writeItemsToChunkedJson as sinon.SinonStub; - expect(writeStub.calledOnce).to.be.true; const args = writeStub.firstCall.args; expect(args[0]).to.equal(fieldsDir); expect(args[1]).to.equal('fields.json'); @@ -75,21 +73,16 @@ describe('ExportFields', () => { await exporter.start(spaceUid); const writeStub = (AssetManagementExportAdapter.prototype as any).writeItemsToChunkedJson as sinon.SinonStub; - expect(writeStub.calledOnce).to.be.true; expect(writeStub.firstCall.args[4]).to.deep.equal([]); }); - it('should call tick on success', async () => { + it('should tick with success=true, the fields process name, and null error', async () => { sinon.stub(ExportFields.prototype, 'getWorkspaceFields').resolves(fieldsResponse); const exporter = new ExportFields(apiConfig, exportContext); await exporter.start(spaceUid); const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - expect(tickStub.called).to.be.true; - const args = tickStub.firstCall.args; - expect(args[0]).to.be.true; - expect(args[1]).to.equal(PROCESS_NAMES.AM_FIELDS); - expect(args[2]).to.be.null; + expect(tickStub.firstCall.args).to.deep.equal([true, PROCESS_NAMES.AM_FIELDS, null]); }); }); }); diff --git a/packages/contentstack-asset-management/test/unit/export/spaces.test.ts b/packages/contentstack-asset-management/test/unit/export/spaces.test.ts index 093e7297ed..935b7d2629 100644 --- a/packages/contentstack-asset-management/test/unit/export/spaces.test.ts +++ b/packages/contentstack-asset-management/test/unit/export/spaces.test.ts @@ -7,8 +7,9 @@ import ExportAssetTypes from '../../../src/export/asset-types'; import ExportFields from '../../../src/export/fields'; import ExportWorkspace from '../../../src/export/workspaces'; import { AssetManagementExportAdapter } from '../../../src/export/base'; +import { AM_MAIN_PROCESS_NAME } from '../../../src/constants/index'; -import type { AssetManagementExportOptions } from '../../../src/types/asset-management-api'; +import type { AssetManagementExportOptions, LinkedWorkspace } from '../../../src/types/asset-management-api'; describe('ExportSpaces', () => { const baseOptions: AssetManagementExportOptions = { @@ -53,46 +54,48 @@ describe('ExportSpaces', () => { }); describe('start method', () => { - it('should return early when linkedWorkspaces is empty', async () => { + it('should return early without starting any export when linkedWorkspaces is empty', async () => { const exporter = new ExportSpaces({ ...baseOptions, linkedWorkspaces: [] }); await exporter.start(); - expect((ExportAssetTypes.prototype.start as sinon.SinonStub).called).to.be.false; - expect((ExportFields.prototype.start as sinon.SinonStub).called).to.be.false; + expect((CLIProgressManager.createNested as sinon.SinonStub).callCount).to.equal(0); + expect((ExportAssetTypes.prototype.start as sinon.SinonStub).callCount).to.equal(0); + expect((ExportFields.prototype.start as sinon.SinonStub).callCount).to.equal(0); + expect((ExportWorkspace.prototype.start as sinon.SinonStub).callCount).to.equal(0); }); - it('should export shared asset types and fields from the first workspace', async () => { + it('should export shared asset types and fields from the first workspace space_uid', async () => { const exporter = new ExportSpaces(baseOptions); await exporter.start(); const atStub = ExportAssetTypes.prototype.start as sinon.SinonStub; - expect(atStub.calledOnce).to.be.true; expect(atStub.firstCall.args[0]).to.equal('space-1'); const fieldsStub = ExportFields.prototype.start as sinon.SinonStub; - expect(fieldsStub.calledOnce).to.be.true; expect(fieldsStub.firstCall.args[0]).to.equal('space-1'); }); - it('should iterate over all workspaces', async () => { + it('should iterate over all workspaces in order', async () => { const exporter = new ExportSpaces(baseOptions); await exporter.start(); const wsStub = ExportWorkspace.prototype.start as sinon.SinonStub; expect(wsStub.callCount).to.equal(2); - expect(wsStub.firstCall.args[0]).to.deep.include({ space_uid: 'space-1' }); - expect(wsStub.secondCall.args[0]).to.deep.include({ space_uid: 'space-2' }); + expect(wsStub.firstCall.args[0]).to.deep.include({ uid: 'ws-1', space_uid: 'space-1' }); + expect(wsStub.secondCall.args[0]).to.deep.include({ uid: 'ws-2', space_uid: 'space-2' }); }); - it('should complete progress on success', async () => { + it('should register and complete the progress process with success', async () => { + const totalSteps = 2 + baseOptions.linkedWorkspaces.length * 4; // 10 const exporter = new ExportSpaces(baseOptions); await exporter.start(); - expect(fakeProgress.completeProcess.calledOnce).to.be.true; - expect(fakeProgress.completeProcess.firstCall.args[1]).to.be.true; + expect(fakeProgress.addProcess.firstCall.args).to.deep.equal([AM_MAIN_PROCESS_NAME, totalSteps]); + expect(fakeProgress.startProcess.firstCall.args[0]).to.equal(AM_MAIN_PROCESS_NAME); + expect(fakeProgress.completeProcess.firstCall.args).to.deep.equal([AM_MAIN_PROCESS_NAME, true]); }); - it('should re-throw and complete progress with failure when a workspace export fails', async () => { + it('should mark progress as failed and re-throw when a workspace export errors', async () => { (ExportWorkspace.prototype.start as sinon.SinonStub).rejects(new Error('workspace-error')); const exporter = new ExportSpaces(baseOptions); @@ -103,12 +106,10 @@ describe('ExportSpaces', () => { expect(err.message).to.equal('workspace-error'); } - expect(fakeProgress.completeProcess.called).to.be.true; - const lastCall = fakeProgress.completeProcess.lastCall; - expect(lastCall.args[1]).to.be.false; + expect(fakeProgress.completeProcess.firstCall.args).to.deep.equal([AM_MAIN_PROCESS_NAME, false]); }); - it('should use parentProgressManager directly when setParentProgressManager was called', async () => { + it('should use the provided parentProgressManager instead of creating a new one', async () => { const fakeParent = { addProcess: sinon.stub().returnsThis(), startProcess: sinon.stub().returnsThis(), @@ -116,19 +117,26 @@ describe('ExportSpaces', () => { tick: sinon.stub(), completeProcess: sinon.stub(), }; + const totalSteps = 2 + baseOptions.linkedWorkspaces.length * 4; + const exporter = new ExportSpaces(baseOptions); exporter.setParentProgressManager(fakeParent as any); await exporter.start(); - expect((CLIProgressManager.createNested as sinon.SinonStub).called).to.be.false; - expect(fakeParent.completeProcess.called).to.be.true; + expect((CLIProgressManager.createNested as sinon.SinonStub).callCount).to.equal(0); + expect(fakeParent.addProcess.firstCall.args).to.deep.equal([AM_MAIN_PROCESS_NAME, totalSteps]); + expect(fakeParent.startProcess.firstCall.args[0]).to.equal(AM_MAIN_PROCESS_NAME); + expect(fakeParent.completeProcess.firstCall.args).to.deep.equal([AM_MAIN_PROCESS_NAME, true]); }); }); describe('exportSpaceStructure', () => { - it('should delegate to ExportSpaces.start', async () => { - await exportSpaceStructure({ ...baseOptions, linkedWorkspaces: [] }); - expect((ExportAssetTypes.prototype.start as sinon.SinonStub).called).to.be.false; + it('should be a thin wrapper that delegates to ExportSpaces.start', async () => { + const startSpy = sinon.stub(ExportSpaces.prototype, 'start').resolves(); + const options: AssetManagementExportOptions = { ...baseOptions, linkedWorkspaces: [] as LinkedWorkspace[] }; + await exportSpaceStructure(options); + + expect(startSpy.callCount).to.equal(1); }); }); }); diff --git a/packages/contentstack-asset-management/test/unit/export/workspaces.test.ts b/packages/contentstack-asset-management/test/unit/export/workspaces.test.ts index f94eb3f702..0a4503b046 100644 --- a/packages/contentstack-asset-management/test/unit/export/workspaces.test.ts +++ b/packages/contentstack-asset-management/test/unit/export/workspaces.test.ts @@ -52,8 +52,7 @@ describe('ExportWorkspace', () => { const exporter = new ExportWorkspace(apiConfig, exportContext); await exporter.start(workspace, spaceDir, branchName); - expect(getSpaceStub.calledOnce).to.be.true; - expect(getSpaceStub.calledWith(workspace.space_uid)).to.be.true; + expect(getSpaceStub.firstCall.args[0]).to.equal(workspace.space_uid); }); it('should tick success after writing metadata', async () => { @@ -62,11 +61,7 @@ describe('ExportWorkspace', () => { await exporter.start(workspace, spaceDir, branchName); const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - expect(tickStub.called).to.be.true; - const args = tickStub.firstCall.args; - expect(args[0]).to.be.true; - expect(args[1]).to.equal(`space: ${workspace.space_uid}`); - expect(args[2]).to.be.null; + expect(tickStub.firstCall.args).to.deep.equal([true, `space: ${workspace.space_uid}`, null]); }); it('should delegate to ExportAssets.start with workspace and spaceDir', async () => { @@ -75,19 +70,26 @@ describe('ExportWorkspace', () => { await exporter.start(workspace, spaceDir, branchName); const startStub = ExportAssets.prototype.start as sinon.SinonStub; - expect(startStub.calledOnce).to.be.true; - const args = startStub.firstCall.args; - expect(args[0]).to.deep.equal(workspace); - expect(args[1]).to.equal(spaceDir); + expect(startStub.firstCall.args[0]).to.deep.equal(workspace); + expect(startStub.firstCall.args[1]).to.equal(spaceDir); }); - it('should use "main" as branch when branchName is empty', async () => { + it('should write "main" as branch in metadata when branchName is empty', async () => { + const os = require('node:os'); + const path = require('node:path'); + const fs = require('node:fs'); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-ws-')); + sinon.stub(ExportWorkspace.prototype, 'getSpace').resolves(spaceResponse); const exporter = new ExportWorkspace(apiConfig, exportContext); - await exporter.start(workspace, spaceDir, ''); + await exporter.start(workspace, tmpDir, ''); - const startStub = ExportAssets.prototype.start as sinon.SinonStub; - expect(startStub.calledOnce).to.be.true; + const metadata = JSON.parse(fs.readFileSync(path.join(tmpDir, 'metadata.json'), 'utf-8')); + expect(metadata.branch).to.equal('main'); + expect(metadata.workspace_uid).to.equal(workspace.uid); + expect(metadata.is_default).to.equal(workspace.is_default); + + fs.rmSync(tmpDir, { recursive: true }); }); it('should NOT call setParentProgressManager on assets exporter when progressOrParent is null', async () => { @@ -97,7 +99,7 @@ describe('ExportWorkspace', () => { const exporter = new ExportWorkspace(apiConfig, exportContext); await exporter.start(workspace, spaceDir, branchName); - expect(setParentStub.called).to.be.false; + expect(setParentStub.callCount).to.equal(0); }); it('should call setParentProgressManager on assets exporter when a progress manager is set', async () => { @@ -109,7 +111,7 @@ describe('ExportWorkspace', () => { exporter.setParentProgressManager(fakeProgress); await exporter.start(workspace, spaceDir, branchName); - expect(setParentStub.calledWith(fakeProgress)).to.be.true; + expect(setParentStub.firstCall.args[0]).to.equal(fakeProgress); }); }); }); diff --git a/packages/contentstack-asset-management/test/unit/utils/asset-management-api-adapter.test.ts b/packages/contentstack-asset-management/test/unit/utils/asset-management-api-adapter.test.ts index b2f9a024e2..f7e7749120 100644 --- a/packages/contentstack-asset-management/test/unit/utils/asset-management-api-adapter.test.ts +++ b/packages/contentstack-asset-management/test/unit/utils/asset-management-api-adapter.test.ts @@ -32,7 +32,7 @@ describe('AssetManagementAdapter', () => { describe('constructor', () => { it('should set the baseURL with trailing slash stripped', () => { new AssetManagementAdapter({ baseURL: 'https://am.example.com/' }); - expect(baseUrlStub.calledWith('https://am.example.com')).to.be.true; + expect(baseUrlStub.firstCall.args[0]).to.equal('https://am.example.com'); }); it('should set default headers with x-cs-api-version when no extra headers provided', () => { @@ -55,7 +55,7 @@ describe('AssetManagementAdapter', () => { it('should handle empty baseURL gracefully', () => { new AssetManagementAdapter({ baseURL: '' }); - expect(baseUrlStub.calledWith('')).to.be.true; + expect(baseUrlStub.firstCall.args[0]).to.equal(''); }); }); @@ -116,12 +116,11 @@ describe('AssetManagementAdapter', () => { }); describe('getSpace', () => { - it('should call GET /api/spaces/{spaceUid} with addl_fields query params', async () => { + it('should GET /api/spaces/{spaceUid}?addl_fields=... and return the space', async () => { getStub.resolves({ status: 200, data: { space: { uid: 'sp-1' } } }); const adapter = new AssetManagementAdapter(baseConfig); const result = await adapter.getSpace('sp-1'); - expect(getStub.calledOnce).to.be.true; const path = getStub.firstCall.args[0] as string; expect(path).to.include('/api/spaces/sp-1'); expect(path).to.include('addl_fields'); @@ -142,25 +141,23 @@ describe('AssetManagementAdapter', () => { }); describe('getWorkspaceFields', () => { - it('should call GET /api/fields', async () => { + it('should GET /api/fields and return the response data', async () => { const fieldsResponse = { count: 1, relation: 'org', fields: [{ uid: 'f1' }] }; getStub.resolves({ status: 200, data: fieldsResponse }); const adapter = new AssetManagementAdapter(baseConfig); const result = await adapter.getWorkspaceFields('sp-1'); - expect(getStub.calledOnce).to.be.true; expect(getStub.firstCall.args[0]).to.equal('/api/fields'); expect(result).to.deep.equal(fieldsResponse); }); }); describe('getWorkspaceAssets', () => { - it('should call GET /api/spaces/{spaceUid}/assets', async () => { + it('should GET /api/spaces/{spaceUid}/assets', async () => { getStub.resolves({ status: 200, data: { items: [] } }); const adapter = new AssetManagementAdapter(baseConfig); await adapter.getWorkspaceAssets('sp-1'); - expect(getStub.calledOnce).to.be.true; expect(getStub.firstCall.args[0]).to.include('/api/spaces/sp-1/assets'); }); @@ -175,24 +172,22 @@ describe('AssetManagementAdapter', () => { }); describe('getWorkspaceFolders', () => { - it('should call GET /api/spaces/{spaceUid}/folders', async () => { + it('should GET /api/spaces/{spaceUid}/folders', async () => { getStub.resolves({ status: 200, data: [] }); const adapter = new AssetManagementAdapter(baseConfig); await adapter.getWorkspaceFolders('sp-1'); - expect(getStub.calledOnce).to.be.true; expect(getStub.firstCall.args[0]).to.include('/api/spaces/sp-1/folders'); }); }); describe('getWorkspaceAssetTypes', () => { - it('should call GET /api/asset_types with include_fields=true', async () => { + it('should GET /api/asset_types?include_fields=true and return the response data', async () => { const atResponse = { count: 1, relation: 'org', asset_types: [{ uid: 'at1' }] }; getStub.resolves({ status: 200, data: atResponse }); const adapter = new AssetManagementAdapter(baseConfig); const result = await adapter.getWorkspaceAssetTypes('sp-1'); - expect(getStub.calledOnce).to.be.true; const path = getStub.firstCall.args[0] as string; expect(path).to.include('/api/asset_types'); expect(path).to.include('include_fields=true'); From fc36d9954a96334640ab40eefaf7aadc02afe532 Mon Sep 17 00:00:00 2001 From: naman-contentstack Date: Fri, 27 Feb 2026 17:22:40 +0530 Subject: [PATCH 3/3] chore: fix failing test cases in export --- .../test/unit/export/modules/stack.test.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/contentstack-export/test/unit/export/modules/stack.test.ts b/packages/contentstack-export/test/unit/export/modules/stack.test.ts index 52645c0281..5b7ed1d9b4 100644 --- a/packages/contentstack-export/test/unit/export/modules/stack.test.ts +++ b/packages/contentstack-export/test/unit/export/modules/stack.test.ts @@ -482,18 +482,16 @@ describe('ExportStack', () => { description: 'Settings description', settings: { global: { example: 'value' } }, }; + const expectedSettings = { ...settingsData, am_v2: { linked_workspaces: [] as any[] } }; mockStackClient.settings = sinon.stub().resolves(settingsData); const result = await exportStack.exportStackSettings(); - expect(writeFileStub.called).to.be.true; - expect(makeDirectoryStub.called).to.be.true; - // Should return the settings data - expect(result).to.deep.equal(settingsData); - // Verify file was written with correct path - const writeCall = writeFileStub.getCall(0); + expect(result).to.deep.equal(expectedSettings); + const writeCall = writeFileStub.firstCall; expect(writeCall.args[0]).to.include('settings.json'); - expect(writeCall.args[1]).to.deep.equal(settingsData); + expect(writeCall.args[1]).to.deep.equal(expectedSettings); + expect(makeDirectoryStub.firstCall).to.not.be.null; }); it('should handle errors when exporting settings without throwing', async () => {