diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 40d3203b..1d2cb4f8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,7 +5,8 @@ #/src/core @celonis/astro #/src/commands/configuration-management/ @celonis/astro #/src/commands/profile/ @celonis/astro -#/src/commands/action-flows/ @celonis/process-automation +/src/commands/action-flows/ @celonis/process-automation +/tests/commands/action-flows/ @celonis/process-automation /src/commands/analysis/ @celonis/process-analytics /src/commands/cpm4/ @celonis/cpm4 /src/commands/data-pipeline/ @Dusan-r @IvanGandacov @EktaCelonis @gorasoCelonis diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a8af1fd1..5e078070 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,5 +16,5 @@ jobs: run: yarn install - name: Yarn Build run: yarn build -# - name: Yarn Test -# run: yarn test + - name: Yarn Test + run: yarn test diff --git a/jest.config.ts b/jest.config.ts index 3e7cddc0..6b6f80d3 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,23 +1,23 @@ -// import type { Config } from "@jest/types" -// const config: Config.InitialOptions = { -// verbose: true, -// transform: { -// "^.+\\.tsx?$": "ts-jest", -// }, -// testMatch: ["/tests/**/*.spec.ts"], -// moduleNameMapper: { -// "^\\./../package.json$": "/tests/mocks/package.json", -// }, -// setupFilesAfterEnv: [ -// "/tests/jest.setup.ts", -// ], -// globals: { -// "ts-jest": { -// tsconfig: { -// sourceMap: true -// } -// } -// } -// } -// -// export default config \ No newline at end of file +import type { Config } from "@jest/types" +const config: Config.InitialOptions = { + verbose: true, + transform: { + "^.+\\.tsx?$": "ts-jest", + }, + testMatch: ["/tests/**/*.spec.ts"], + moduleNameMapper: { + "^\\./../package.json$": "/tests/mocks/package.json", + }, + setupFilesAfterEnv: [ + "/tests/jest.setup.ts", + ], + globals: { + "ts-jest": { + tsconfig: { + sourceMap: true + } + } + } +} + +export default config \ No newline at end of file diff --git a/src/commands/action-flows/action-flow/action-flow-api.ts b/src/commands/action-flows/action-flow/action-flow-api.ts new file mode 100644 index 00000000..04c90486 --- /dev/null +++ b/src/commands/action-flows/action-flow/action-flow-api.ts @@ -0,0 +1,35 @@ +import * as FormData from "form-data"; +import { HttpClient } from "../../../core/http/http-client"; +import { Context } from "../../../core/command/cli-context"; +import { FatalError } from "../../../core/utils/logger"; + +export class ActionFlowApi { + + private httpClient: HttpClient; + + constructor(context: Context) { + this.httpClient = context.httpClient; + } + + public async exportRawAssets(packageId: string): Promise { + return this.httpClient.getFile(`/ems-automation/api/root/${packageId}/export/assets`).catch(e => { + throw new FatalError(`Problem getting Action Flow assets: ${e}`); + }); + } + + public async analyzeAssets(packageId: string): Promise { + return this.httpClient.get(`/ems-automation/api/root/${packageId}/export/assets/analyze`).catch(e => { + throw new FatalError(`Problem analyzing Action Flow assets: ${e}`); + }); + } + + public async importAssets(packageId: string, data: FormData, dryRun: boolean): Promise { + const params = { + dryRun: dryRun, + }; + + return this.httpClient.postFile(`/ems-automation/api/root/${packageId}/import/assets`, data, params).catch(e => { + throw new FatalError(`Problem importing Action Flow assets: ${e}`); + }); + } +} diff --git a/src/commands/action-flows/action-flow/action-flow-command.service.ts b/src/commands/action-flows/action-flow/action-flow-command.service.ts new file mode 100644 index 00000000..1a69b678 --- /dev/null +++ b/src/commands/action-flows/action-flow/action-flow-command.service.ts @@ -0,0 +1,23 @@ +import { Context } from "../../../core/command/cli-context"; +import { ActionFlowService } from "./action-flow.service"; + +export class ActionFlowCommandService { + + private actionFlowService: ActionFlowService; + + constructor(context: Context) { + this.actionFlowService = new ActionFlowService(context); + } + + public async exportActionFlows(packageId: string, metadataFile: string): Promise { + await this.actionFlowService.exportActionFlows(packageId, metadataFile); + } + + public async analyzeActionFlows(packageId: string, outputToJsonFile: boolean): Promise { + await this.actionFlowService.analyzeActionFlows(packageId, outputToJsonFile); + } + + public async importActionFlows(packageId: string, filePath: string, dryRun: boolean, outputToJsonFile: boolean): Promise { + await this.actionFlowService.importActionFlows(packageId, filePath, dryRun, outputToJsonFile); + } +} \ No newline at end of file diff --git a/src/commands/action-flows/action-flow/action-flow.service.ts b/src/commands/action-flows/action-flow/action-flow.service.ts new file mode 100644 index 00000000..29365dc8 --- /dev/null +++ b/src/commands/action-flows/action-flow/action-flow.service.ts @@ -0,0 +1,79 @@ +import { v4 as uuidv4 } from "uuid"; +import * as AdmZip from "adm-zip"; +import * as FormData from "form-data"; +import * as fs from "fs"; +import { Context } from "../../../core/command/cli-context"; +import { ActionFlowApi } from "./action-flow-api"; +import { fileService, FileService } from "../../../core/utils/file-service"; +import { logger } from "../../../core/utils/logger"; + +export class ActionFlowService { + public static readonly METADATA_FILE_NAME = "metadata.json"; + + private actionFlowApi: ActionFlowApi; + + constructor(context: Context) { + this.actionFlowApi = new ActionFlowApi(context); + } + + public async exportActionFlows(packageId: string, metadataFilePath: string): Promise { + const exportedActionFlowsData = await this.actionFlowApi.exportRawAssets(packageId); + const tmpZip: AdmZip = new AdmZip(exportedActionFlowsData); + + const zip = new AdmZip(); + tmpZip.getEntries().forEach(entry => { + zip.addFile(entry.entryName, entry.getData()); + }); + + if (metadataFilePath) { + this.attachMetadataFile(metadataFilePath, zip); + } + + const fileName = "action-flows_export_" + uuidv4() + ".zip"; + zip.writeZip(fileName); + logger.info(FileService.fileDownloadedMessage + fileName); + } + + public async analyzeActionFlows(packageId: string, outputToJsonFile: boolean): Promise { + const actionFlowsMetadata = await this.actionFlowApi.analyzeAssets(packageId); + const actionFlowsMetadataString = JSON.stringify(actionFlowsMetadata, null, 4); + + if (outputToJsonFile) { + const metadataFileName = "action-flows_metadata_" + uuidv4() + ".json"; + fileService.writeToFileWithGivenName(actionFlowsMetadataString, metadataFileName); + logger.info(FileService.fileDownloadedMessage + metadataFileName); + } else { + logger.info("Action flows analyze metadata: \n" + actionFlowsMetadataString); + } + } + + public async importActionFlows(packageId: string, filePath: string, dryRun: boolean, outputToJsonFile: boolean): Promise { + const actionFlowsZip = this.createBodyForImport(filePath); + const eventLog = await this.actionFlowApi.importAssets(packageId, actionFlowsZip, dryRun); + const eventLogString = JSON.stringify(eventLog, null, 4); + + if (outputToJsonFile) { + const eventLogFileName = "action-flows_import_event_log_" + uuidv4() + ".json"; + fileService.writeToFileWithGivenName(eventLogString, eventLogFileName); + logger.info(FileService.fileDownloadedMessage + eventLogFileName); + } else { + logger.info("Action flows import event log: \n" + eventLogString); + } + } + + private createBodyForImport(fileName: string): FormData { + fileName = fileName + (fileName.endsWith(".zip") ? "" : ".zip"); + + const formData = new FormData(); + formData.append("file", fs.createReadStream(fileName, { encoding: null }), { filename: fileName }); + + return formData; + } + + private attachMetadataFile(fileName: string, zip: AdmZip): void { + fileName = fileName + (fileName.endsWith(".json") ? "" : ".json"); + const metadata = fileService.readFile(fileName); + + zip.addFile(ActionFlowService.METADATA_FILE_NAME, Buffer.from(metadata)); + } +} diff --git a/src/commands/action-flows/module.ts b/src/commands/action-flows/module.ts index 32075375..24653359 100644 --- a/src/commands/action-flows/module.ts +++ b/src/commands/action-flows/module.ts @@ -4,12 +4,75 @@ import { Configurator, IModule } from "../../core/command/module-handler"; import { Context } from "../../core/command/cli-context"; +import { Command, OptionValues } from "commander"; +import { ActionFlowCommandService } from "./action-flow/action-flow-command.service"; +import { SkillCommandService } from "./skill/skill-command.service"; class Module extends IModule { register(context: Context, configurator: Configurator) { + const analyzeCommand = configurator.command("analyze"); + analyzeCommand.command("action-flows") + .description("Analyze Action Flows dependencies for a certain package") + .option("-p, --profile ", "Profile which you want to use to analyze Action Flows") + .requiredOption("--packageId ", "ID of the package from which you want to export Action Flows") + .option("-o, --outputToJsonFile", "Output the analyze result in a JSON file") + .action(this.analyzeActionFlows); + + const exportCommand = configurator.command("export"); + exportCommand.command("action-flows") + .description("Command to export all Action Flows in a package with their objects and dependencies") + .option("-p, --profile ", "Profile which you want to use to export Action Flows") + .requiredOption("--packageId ", "ID of the package from which you want to export Action Flows") + .option("-f, --file ", "Action flows metadata file (relative path)") + .action(this.exportActionFlows); + + const importCommand = configurator.command("import"); + importCommand.command("action-flows") + .description("Command to import all Action Flows in a package with their objects and dependencies") + .option("-p, --profile ", "Profile which you want to use to import Action Flows") + .requiredOption("--packageId ", "ID of the package to which you want to export Action Flows") + .requiredOption("-f, --file ", "Exported Action Flows file (relative path)") + .requiredOption("-d, --dryRun ", "Execute the import on dry run mode") + .option("-o, --outputToJsonFile", "Output the import result in a JSON file") + .action(this.importActionFlows); + + const pullCommand = configurator.command("pull"); + pullCommand.command("skill") + .description("Command to pull a skill") + .option("-p, --profile ", "Profile which you want to use to pull the skill") + .requiredOption("--projectId ", "Id of the project you want to pull") + .requiredOption("--skillId ", "Id of the skill you want to pull") + .action(this.pullSkill); + + const pushCommand = configurator.command("push"); + pushCommand.command("skill") + .description("Command to push a skill to a project") + .option("-p, --profile ", "Profile which you want to use to push the skill") + .requiredOption("--projectId ", "Id of the project you want to push") + .requiredOption("-f, --file ", "The file you want to push") + .action(this.pushSkill); } + private async analyzeActionFlows(context: Context, command: Command, options: OptionValues): Promise { + await new ActionFlowCommandService(context).analyzeActionFlows(options.packageId, options.outputToJsonFile); + } + + private async exportActionFlows(context: Context, command: Command, options: OptionValues): Promise { + await new ActionFlowCommandService(context).exportActionFlows(options.packageId, options.file); + } + + private async importActionFlows(context: Context, command: Command, options: OptionValues): Promise { + await new ActionFlowCommandService(context).importActionFlows(options.packageId, options.file, options.dryRun, options.outputToJsonFile); + } + + private async pullSkill(context: Context, command: Command, options: OptionValues): Promise { + await new SkillCommandService(context).pullSkill(options.profile, options.projectId, options.skillId); + } + + private async pushSkill(context: Context, command: Command, options: OptionValues): Promise { + await new SkillCommandService(context).pushSkill(options.profile, options.projectId, options.file); + } } export = Module; \ No newline at end of file diff --git a/src/commands/action-flows/skill/skill-command.service.ts b/src/commands/action-flows/skill/skill-command.service.ts new file mode 100644 index 00000000..7a3a4c88 --- /dev/null +++ b/src/commands/action-flows/skill/skill-command.service.ts @@ -0,0 +1,20 @@ +import { ContentService } from "../../../core/http/http-shared/content.service"; +import { Context } from "../../../core/command/cli-context"; +import { SkillManagerFactory } from "./skill.manager-factory"; + +export class SkillCommandService { + private contentService = new ContentService(); + private skillManagerFactory: SkillManagerFactory; + + constructor(context: Context) { + this.skillManagerFactory = new SkillManagerFactory(context); + } + + public async pullSkill(profile: string, projectId: string, skillId: string): Promise { + await this.contentService.pull(this.skillManagerFactory.createManager(projectId, skillId, null)); + } + + public async pushSkill(profile: string, projectId: string, filename: string): Promise { + await this.contentService.push(this.skillManagerFactory.createManager(projectId, null, filename)); + } +} diff --git a/src/commands/action-flows/skill/skill.manager-factory.ts b/src/commands/action-flows/skill/skill.manager-factory.ts new file mode 100644 index 00000000..e3897494 --- /dev/null +++ b/src/commands/action-flows/skill/skill.manager-factory.ts @@ -0,0 +1,32 @@ +import * as fs from "fs"; +import * as path from "path"; +import { Stream } from "stream"; +import { Context } from "../../../core/command/cli-context"; +import { FatalError, logger } from "../../../core/utils/logger"; +import { SkillManager } from "./skill.manager"; + +export class SkillManagerFactory { + + private readonly context: Context; + + constructor(context: Context) { + this.context = context; + } + + public createManager(projectId: string, skillId: string, filename: string): SkillManager { + const skillManager = new SkillManager(this.context); + skillManager.skillId = skillId; + skillManager.projectId = projectId; + if (filename !== null) { + skillManager.content = this.readFile(filename); + } + return skillManager; + } + + private readFile(filename: string): Stream { + if (!fs.existsSync(path.resolve(process.cwd(), filename))) { + logger.error(new FatalError("The provided file does not exit")); + } + return fs.createReadStream(path.resolve(process.cwd(), filename), { encoding: "binary" }); + } +} diff --git a/src/commands/action-flows/skill/skill.manager.ts b/src/commands/action-flows/skill/skill.manager.ts new file mode 100644 index 00000000..5a83ae2e --- /dev/null +++ b/src/commands/action-flows/skill/skill.manager.ts @@ -0,0 +1,59 @@ +import * as FormData from "form-data"; +import { Context } from "../../../core/command/cli-context"; +import { BaseManager } from "../../../core/http/http-shared/base.manager"; +import { ManagerConfig } from "../../../core/http/http-shared/manager-config.interface"; + +export class SkillManager extends BaseManager { + private static BASE_URL = "/action-engine/api/projects"; + private _skillId: string; + private _projectId: string; + private _content: any; + + constructor(context: Context) { + super(context); + } + + public get content(): any { + return this._content; + } + + public set content(value: any) { + this._content = value; + } + public get skillId(): string { + return this._skillId; + } + + public set skillId(value: string) { + this._skillId = value; + } + + public get projectId(): string { + return this._projectId; + } + + public set projectId(value: string) { + this._projectId = value; + } + + public getConfig(): ManagerConfig { + return { + pushUrl: `${SkillManager.BASE_URL}/${this.projectId}/skills/import-file`, + pullUrl: `${SkillManager.BASE_URL}/${this.projectId}/skills/${this.skillId}/export`, + exportFileName: "skill_" + this.skillId + ".json", + onPushSuccessMessage: (skill: any): string => { + return "Skill was pushed successfully. New ID: " + skill.id; + }, + }; + } + + public getBody(): any { + const formData = new FormData(); + formData.append("file", this.content); + return formData; + } + + protected getSerializedFileContent(data: any): string { + return JSON.stringify(data); + } +} diff --git a/src/commands/configuration-management/api/batch-import-export-api.ts b/src/commands/configuration-management/api/batch-import-export-api.ts new file mode 100644 index 00000000..2c0e4d7e --- /dev/null +++ b/src/commands/configuration-management/api/batch-import-export-api.ts @@ -0,0 +1,78 @@ +import * as FormData from "form-data"; +import { + PackageExportTransport, + PackageKeyAndVersionPair, + PostPackageImportData, VariableManifestTransport, +} from "../interfaces/package-export.interfaces"; +import { FatalError } from "../../../core/utils/logger"; +import { HttpClient } from "../../../core/http/http-client"; +import { Context } from "../../../core/command/cli-context"; + +export class BatchImportExportApi { + + private httpClient: HttpClient; + + constructor(context: Context) { + this.httpClient = context.httpClient; + } + + public async findAllActivePackages(flavors: string[], withDependencies: boolean = false): Promise { + const queryParams = new URLSearchParams(); + + queryParams.set("withDependencies", withDependencies.toString()); + flavors.forEach(flavor => queryParams.append("flavors", flavor)) + + return this.httpClient.get(`/package-manager/api/core/packages/export/list?${queryParams.toString()}`).catch(e => { + throw new FatalError(`Problem getting active packages: ${e}`); + }); + } + + public async findActivePackagesByVariableValue(flavors: string[], variableValue: string, variableType: string): Promise { + const queryParams = new URLSearchParams(); + + queryParams.set("variableValue", variableValue); + if (variableType) { + queryParams.set("variableType", variableType); + } + flavors.forEach(flavor => queryParams.append("flavors", flavor)) + + return this.httpClient.get(`/package-manager/api/core/packages/export/list-by-variable-value?${queryParams.toString()}`).catch(e => { + throw new FatalError(`Problem getting active packages by variable value: ${e}`); + }); + } + + public async findActivePackagesByKeys(packageKeys: string[], withDependencies: boolean = false): Promise { + const queryParams = new URLSearchParams(); + + packageKeys.forEach(key => queryParams.append("packageKeys", key)) + queryParams.set("withDependencies", withDependencies.toString()); + + return this.httpClient.get(`/package-manager/api/core/packages/export/list-by-keys?${queryParams.toString()}`).catch(e => { + throw new FatalError(`Problem getting active packages by keys: ${e}`); + }); + } + + public async exportPackages(packageKeys: string[], withDependencies: boolean = false): Promise { + const queryParams = new URLSearchParams(); + packageKeys.forEach(packageKey => queryParams.append("packageKeys", packageKey)); + queryParams.set("withDependencies", withDependencies.toString()); + + return this.httpClient.getFile(`/package-manager/api/core/packages/export/batch?${queryParams.toString()}`).catch(e => { + throw new FatalError(`Problem exporting packages: ${e}`); + }); + } + + public async importPackages(data: FormData, overwrite: boolean): Promise { + return this.httpClient.postFile( + "/package-manager/api/core/packages/import/batch", + data, + {overwrite} + ); + } + + public async findVariablesWithValuesByPackageKeysAndVersion(packagesByKeyAndVersion: PackageKeyAndVersionPair[]): Promise { + return this.httpClient.post("/package-manager/api/core/packages/export/batch/variables-with-assignments", packagesByKeyAndVersion).catch(e => { + throw new FatalError(`Problem exporting package variables: ${e}`); + }) + } +} diff --git a/src/commands/configuration-management/api/diff-api.ts b/src/commands/configuration-management/api/diff-api.ts new file mode 100644 index 00000000..211757f5 --- /dev/null +++ b/src/commands/configuration-management/api/diff-api.ts @@ -0,0 +1,27 @@ +import * as FormData from "form-data"; +import { HttpClient } from "../../../core/http/http-client"; +import { Context } from "../../../core/command/cli-context"; +import { PackageDiffMetadata, PackageDiffTransport } from "../interfaces/diff-package.interfaces"; + +export class DiffApi { + + private httpClient: HttpClient; + + constructor(context: Context) { + this.httpClient = context.httpClient; + } + + public async diffPackages(data: FormData): Promise { + return this.httpClient.postFile( + "/package-manager/api/core/packages/diff/configuration", + data + ); + } + + public async hasChanges(data: FormData): Promise { + return this.httpClient.postFile( + "/package-manager/api/core/packages/diff/configuration/has-changes", + data + ); + } +} diff --git a/src/commands/configuration-management/api/variable-assignment-candidates-api.ts b/src/commands/configuration-management/api/variable-assignment-candidates-api.ts new file mode 100644 index 00000000..0f44a890 --- /dev/null +++ b/src/commands/configuration-management/api/variable-assignment-candidates-api.ts @@ -0,0 +1,26 @@ +import {URLSearchParams} from "url"; +import { variableAssignmentApis } from "../interfaces/variable-assignment-apis.constants"; +import { HttpClient } from "../../../core/http/http-client"; +import { Context } from "../../../core/command/cli-context"; +import { FatalError } from "../../../core/utils/logger"; + +export class VariableAssignmentCandidatesApi { + + private httpClient: HttpClient; + + constructor(context: Context) { + this.httpClient = context.httpClient; + } + + public async getCandidateAssignments(type: string, params: URLSearchParams): Promise { + if (!variableAssignmentApis[type]) { + throw new FatalError(`Variable type ${type} not supported.`); + } + + const apiUrl: string = variableAssignmentApis[type].url + (params.toString().length ? `?${params.toString()}` : ""); + + return this.httpClient.get(apiUrl).catch(e => { + throw new FatalError(`Problem getting variables assignment values for type ${type}: ${e}`); + }); + } +} diff --git a/src/commands/configuration-management/batch-import-export.service.ts b/src/commands/configuration-management/batch-import-export.service.ts new file mode 100644 index 00000000..2b8b70ba --- /dev/null +++ b/src/commands/configuration-management/batch-import-export.service.ts @@ -0,0 +1,184 @@ +import {v4 as uuidv4} from "uuid"; +import * as FormData from "form-data"; +import {Readable} from "stream"; +import * as AdmZip from "adm-zip"; +import { Context } from "../../core/command/cli-context"; +import { + PackageExportTransport, PackageKeyAndVersionPair, + PackageManifestTransport, + StudioPackageManifest, VariableManifestTransport, +} from "./interfaces/package-export.interfaces"; +import { BatchExportImportConstants } from "./interfaces/batch-export-import.constants"; +import { fileService, FileService } from "../../core/utils/file-service"; +import { logger } from "../../core/utils/logger"; +import { parse, stringify } from "../../core/utils/json"; +import { PackageApi } from "../studio/api/package-api"; +import { BatchImportExportApi } from "./api/batch-import-export-api"; +import { StudioService } from "./studio.service"; + +export class BatchImportExportService { + + private batchImportExportApi: BatchImportExportApi; + + private studioPackageApi: PackageApi; + private studioService: StudioService; + + constructor(context: Context) { + this.batchImportExportApi = new BatchImportExportApi(context); + + this.studioPackageApi = new PackageApi(context); + this.studioService = new StudioService(context); + } + + public async listActivePackages(flavors: string[]): Promise { + const activePackages = await this.batchImportExportApi.findAllActivePackages(flavors); + activePackages.forEach(pkg => { + logger.info(`${pkg.name} - Key: "${pkg.key}"`) + }); + } + + public async findAndExportListOfActivePackages(flavors: string[], packageKeys: string[], withDependencies: boolean): Promise { + let packagesToExport: PackageExportTransport[]; + + if (packageKeys.length) { + packagesToExport = await this.batchImportExportApi.findActivePackagesByKeys(packageKeys, withDependencies); + } else { + packagesToExport = await this.batchImportExportApi.findAllActivePackages(flavors, withDependencies); + } + + packagesToExport = await this.studioService.getExportPackagesWithStudioData(packagesToExport, withDependencies); + + this.exportListOfPackages(packagesToExport); + } + + public async batchExportPackages(packageKeys: string[], withDependencies: boolean = false): Promise { + const exportedPackagesData: Buffer = await this.batchImportExportApi.exportPackages(packageKeys, withDependencies); + const exportedPackagesZip: AdmZip = new AdmZip(exportedPackagesData); + + const manifest: PackageManifestTransport[] = parse( + exportedPackagesZip.getEntry(BatchExportImportConstants.MANIFEST_FILE_NAME).getData().toString() + ); + + const versionsByPackageKey = this.getVersionsByPackageKey(manifest); + + let exportedVariables = await this.getVersionedVariablesForPackagesWithKeys(versionsByPackageKey); + exportedVariables = this.studioService.fixConnectionVariables(exportedVariables); + exportedPackagesZip.addFile(BatchExportImportConstants.VARIABLES_FILE_NAME, Buffer.from(stringify(exportedVariables), "utf8")); + + const studioPackageKeys = manifest.filter(packageManifest => packageManifest.flavor === BatchExportImportConstants.STUDIO) + .map(packageManifest => packageManifest.packageKey); + + const studioData = await this.studioService.getStudioPackageManifests(studioPackageKeys); + exportedPackagesZip.addFile(BatchExportImportConstants.STUDIO_FILE_NAME, Buffer.from(stringify(studioData), "utf8")); + + exportedPackagesZip.getEntries().forEach(entry => { + if (entry.name.endsWith(BatchExportImportConstants.ZIP_EXTENSION)) { + const lastUnderscoreIndex = entry.name.lastIndexOf("_"); + const packageKey = entry.name.substring(0, lastUnderscoreIndex); + + if (studioPackageKeys.includes(packageKey)) { + const updatedPackage = this.studioService.processPackageForExport(entry, exportedVariables); + exportedPackagesZip.updateFile(entry, updatedPackage.toBuffer()); + } + } + }); + + const fileDownloadedMessage = "File downloaded successfully. New filename: "; + const filename = `export_${uuidv4()}.zip`; + exportedPackagesZip.writeZip(filename); + logger.info(fileDownloadedMessage + filename); + } + + public async batchImportPackages(file: string, overwrite: boolean): Promise { + let configs = new AdmZip(file); + const studioManifests = this.parseEntryData(configs, BatchExportImportConstants.STUDIO_FILE_NAME) as StudioPackageManifest[]; + const variablesManifests: VariableManifestTransport[] = this.parseEntryData(configs, BatchExportImportConstants.VARIABLES_FILE_NAME) as VariableManifestTransport[]; + + configs = await this.studioService.mapSpaces(configs, studioManifests); + const existingStudioPackages = await this.studioPackageApi.findAllPackages(); + + const formData = this.buildBodyForImport(configs, variablesManifests); + const postPackageImportData = await this.batchImportExportApi.importPackages(formData, overwrite); + await this.studioService.processImportedPackages(configs, existingStudioPackages, studioManifests); + + const reportFileName = "config_import_report_" + uuidv4() + ".json"; + fileService.writeToFileWithGivenName(JSON.stringify(postPackageImportData), reportFileName); + logger.info("Config import report file: " + reportFileName); + } + + public async findAndExportListOfActivePackagesByVariableValue(flavors: string[], variableValue: string, variableType: string): Promise { + let packagesToExport = await this.batchImportExportApi.findActivePackagesByVariableValue(flavors, variableValue, variableType); + + packagesToExport = await this.studioService.getExportPackagesWithStudioData(packagesToExport, false); + + this.exportListOfPackages(packagesToExport); + } + + public async listActivePackagesByVariableValue(flavors: string[], variableValue: string, variableType: string) : Promise { + const packagesByVariableValue = await this.batchImportExportApi.findActivePackagesByVariableValue(flavors, variableValue, variableType); + packagesByVariableValue.forEach(pkg => { + logger.info(`${pkg.name} - Key: "${pkg.key}"`) + }); + } + + private exportListOfPackages(packages: PackageExportTransport[]): void { + const filename = uuidv4() + ".json"; + fileService.writeToFileWithGivenName(JSON.stringify(packages), filename); + logger.info(FileService.fileDownloadedMessage + filename); + } + + private getVersionsByPackageKey(manifests: PackageManifestTransport[]): Map { + const versionsByPackageKey = new Map(); + manifests.forEach(packageManifest => { + versionsByPackageKey.set(packageManifest.packageKey, Object.keys(packageManifest.dependenciesByVersion)); + }) + + return versionsByPackageKey; + } + + private getVersionedVariablesForPackagesWithKeys(versionsByPackageKey: Map): Promise { + const variableExportRequest: PackageKeyAndVersionPair[] = []; + versionsByPackageKey?.forEach((versions, key) => { + versions?.forEach(version => { + variableExportRequest.push({ + packageKey: key, + version: version, + }) + }) + }); + + return this.batchImportExportApi.findVariablesWithValuesByPackageKeysAndVersion(variableExportRequest) + } + + private buildBodyForImport(configs: AdmZip, variablesManifests: VariableManifestTransport[]): FormData { + const formData = new FormData(); + const readableStream = this.getReadableStream(configs); + + formData.append("file", readableStream, {filename: "configs.zip"}); + + if (variablesManifests) { + formData.append("mappedVariables", JSON.stringify(variablesManifests), { + contentType: "application/json" + }); + } + + return formData; + } + + private getReadableStream(configs: AdmZip): Readable { + return new Readable({ + read(): void { + this.push(configs.toBuffer()); + this.push(null); + }, + }); + } + + private parseEntryData(configs: AdmZip, fileName: string): any { + const entry = configs.getEntry(fileName); + if (entry) { + return (parse(entry.getData().toString())); + } + return null; + } +} diff --git a/src/commands/configuration-management/config-command.service.ts b/src/commands/configuration-management/config-command.service.ts new file mode 100644 index 00000000..b63fe82f --- /dev/null +++ b/src/commands/configuration-management/config-command.service.ts @@ -0,0 +1,58 @@ +import { Context } from "../../core/command/cli-context"; +import { BatchImportExportService } from "./batch-import-export.service"; +import { VariableService } from "./variable.service"; +import { DiffService } from "./diff.service"; + +export class ConfigCommandService { + + private batchImportExportService: BatchImportExportService; + private variableService: VariableService; + private diffService: DiffService; + + constructor(context: Context) { + this.batchImportExportService = new BatchImportExportService(context); + this.variableService = new VariableService(context); + this.diffService = new DiffService(context); + } + + public async listActivePackages(jsonResponse: boolean, flavors: string[], withDependencies: boolean, packageKeys: string[], variableValue: string, variableType: string): Promise { + if (variableValue) { + await this.listPackagesByVariableValue(jsonResponse, flavors, variableValue, variableType); + return; + } + + if (jsonResponse) { + await this.batchImportExportService.findAndExportListOfActivePackages(flavors ?? [], packageKeys ?? [], withDependencies) + } else { + await this.batchImportExportService.listActivePackages(flavors ?? []); + } + } + + public async listVariables(jsonResponse: boolean, keysByVersion: string[], keysByVersionFile: string): Promise { + if (jsonResponse) { + await this.variableService.exportVariables(keysByVersion, keysByVersionFile); + } else { + await this.variableService.listVariables(keysByVersion, keysByVersionFile); + } + } + + public batchExportPackages(packageKeys: string[], withDependencies: boolean = false): Promise { + return this.batchImportExportService.batchExportPackages(packageKeys, withDependencies); + } + + public batchImportPackages(file: string, overwrite: boolean): Promise { + return this.batchImportExportService.batchImportPackages(file, overwrite); + } + + public diffPackages(file: string, hasChanges: boolean, jsonResponse: boolean): Promise { + return this.diffService.diffPackages(file, hasChanges, jsonResponse); + } + + private async listPackagesByVariableValue(jsonResponse: boolean, flavors: string[], variableValue: string, variableType: string): Promise { + if (jsonResponse) { + await this.batchImportExportService.findAndExportListOfActivePackagesByVariableValue(flavors ?? [], variableValue, variableType ) + } else { + await this.batchImportExportService.listActivePackagesByVariableValue(flavors ?? [], variableValue, variableType); + } + } +} diff --git a/src/commands/configuration-management/diff.service.ts b/src/commands/configuration-management/diff.service.ts new file mode 100644 index 00000000..23f676e7 --- /dev/null +++ b/src/commands/configuration-management/diff.service.ts @@ -0,0 +1,88 @@ +import * as AdmZip from "adm-zip"; +import {Readable} from "stream"; +import * as FormData from "form-data"; +import {v4 as uuidv4} from "uuid"; +import { logger } from "../../core/utils/logger"; +import { fileService, FileService } from "../../core/utils/file-service"; +import { Context } from "../../core/command/cli-context"; +import { PackageDiffMetadata, PackageDiffTransport } from "./interfaces/diff-package.interfaces"; +import { DiffApi } from "./api/diff-api"; + +export class DiffService { + + private diffApi: DiffApi; + + constructor(context: Context) { + this.diffApi = new DiffApi(context); + } + + public async diffPackages(file: string, hasChanges: boolean, jsonResponse: boolean): Promise { + if (hasChanges) { + await this.hasChanges(file, jsonResponse); + } else { + await this.diffPackagesAndReturnDiff(file, jsonResponse); + } + } + + private async hasChanges(file: string, jsonResponse: boolean): Promise { + const packages = new AdmZip(file); + const formData = this.buildBodyForDiff(packages); + const returnedHasChangesData = await this.diffApi.hasChanges(formData); + + if (jsonResponse) { + this.exportListOfPackageDiffMetadata(returnedHasChangesData); + } else { + logger.info(this.buildStringResponseForPackageDiffMetadataList(returnedHasChangesData)); + } + } + + private async diffPackagesAndReturnDiff(file: string, jsonResponse: boolean): Promise { + const packages = new AdmZip(file); + const formData = this.buildBodyForDiff(packages); + const returnedHasChangesData = await this.diffApi.diffPackages(formData); + + if (jsonResponse) { + this.exportListOfPackageDiffs(returnedHasChangesData); + } else { + logger.info(this.buildStringResponseForPackageDiffs(returnedHasChangesData)); + } + } + + private buildBodyForDiff(packages: AdmZip): FormData { + const formData = new FormData(); + const readableStream = this.getReadableStream(packages); + + formData.append("file", readableStream, {filename: "packages.zip"}); + + return formData; + } + + private getReadableStream(packages: AdmZip): Readable { + return new Readable({ + read(): void { + this.push(packages.toBuffer()); + this.push(null); + } + }); + } + + private exportListOfPackageDiffs(packageDiffs: PackageDiffTransport[]): void { + const filename = uuidv4() + ".json"; + fileService.writeToFileWithGivenName(JSON.stringify(packageDiffs), filename); + logger.info(FileService.fileDownloadedMessage + filename); + } + + private exportListOfPackageDiffMetadata(packageDiffMetadata: PackageDiffMetadata[]): void { + const filename = uuidv4() + ".json"; + fileService.writeToFileWithGivenName(JSON.stringify(packageDiffMetadata), filename); + logger.info(FileService.fileDownloadedMessage + filename); + } + + private buildStringResponseForPackageDiffs(packageDiffs: PackageDiffTransport[]): string { + return "\n" + JSON.stringify(packageDiffs, null, 2); + } + + private buildStringResponseForPackageDiffMetadataList(packageDiffMetadata: PackageDiffMetadata[]): string { + return "\n" + JSON.stringify(packageDiffMetadata, null, 2); + } +} diff --git a/src/commands/configuration-management/interfaces/batch-export-import.constants.ts b/src/commands/configuration-management/interfaces/batch-export-import.constants.ts new file mode 100644 index 00000000..a0c815ea --- /dev/null +++ b/src/commands/configuration-management/interfaces/batch-export-import.constants.ts @@ -0,0 +1,11 @@ +export enum BatchExportImportConstants { + STUDIO_FILE_NAME = "studio.json", + VARIABLES_FILE_NAME = "variables.json", + MANIFEST_FILE_NAME = "manifest.json", + STUDIO = "STUDIO", + APP_MODE_VIEWER = "VIEWER", + ZIP_EXTENSION = ".zip", + JSON_EXTENSION = ".json", + NODES_FOLDER_NAME = "nodes/", + SCENARIO_NODE = "SCENARIO" +} \ No newline at end of file diff --git a/src/commands/configuration-management/interfaces/diff-package.interfaces.ts b/src/commands/configuration-management/interfaces/diff-package.interfaces.ts new file mode 100644 index 00000000..507242a1 --- /dev/null +++ b/src/commands/configuration-management/interfaces/diff-package.interfaces.ts @@ -0,0 +1,34 @@ +export interface ConfigurationChangeTransport { + op: string; + path: string; + from: string; + value: object; + fromValue: object; +} + +export enum NodeConfigurationChangeType { + ADDED = "ADDED", + DELETED = "DELETED", + CHANGED = "CHANGED", + UNCHANGED = "UNCHANGED", + INVALID = "INVALID" +} + +export interface NodeDiffTransport { + nodeKey: string; + name: string; + type: string; + changeType: NodeConfigurationChangeType; + changes: ConfigurationChangeTransport[]; +} + +export interface PackageDiffTransport { + packageKey: string; + packageChanges: ConfigurationChangeTransport[]; + nodesWithChanges: NodeDiffTransport[]; +} + +export interface PackageDiffMetadata { + packageKey: string; + hasChanges: boolean; +} \ No newline at end of file diff --git a/src/commands/configuration-management/interfaces/package-export.interfaces.ts b/src/commands/configuration-management/interfaces/package-export.interfaces.ts new file mode 100644 index 00000000..ec6d54ab --- /dev/null +++ b/src/commands/configuration-management/interfaces/package-export.interfaces.ts @@ -0,0 +1,86 @@ +import { + StudioComputeNodeDescriptor, + VariableDefinition, + VariablesAssignments, +} from "../../studio/interfaces/package-manager.interfaces"; +import { SpaceTransport } from "../../studio/interfaces/space.interface"; + +export interface DependencyTransport { + key: string; + version: string; +} + +export interface PackageExportTransport { + id: string; + key: string; + name: string; + changeDate: string; + activatedDraftId: string; + workingDraftId: string; + flavor: string; + version: string; + dependencies: DependencyTransport[]; + spaceId?: string; + datamodels?: StudioComputeNodeDescriptor[]; +} + +export interface PackageManifestTransport { + packageKey: string; + flavor: string; + activeVersion: string; + dependenciesByVersion: Map; +} + +export interface VariableExportTransport { + key: string; + value: any; + type: string; + metadata: object; +} + +export interface VariableManifestTransport { + packageKey: string; + version: string; + variables?: VariableExportTransport[]; +} + +export interface PackageKeyAndVersionPair { + packageKey: string; + version: string; +} + +export interface NodeExportTransport { + key: string; + parentNodeKey: string; + name: string; + type: string; + exportSerializationType: string; + configuration: NodeConfiguration; + schemaVersion: number; + + spaceId: string; + + invalidContent?: boolean; + serializedDocument?: Buffer; +} + +export interface NodeConfiguration { + variables?: VariableDefinition[]; + [key: string]: any; +} + +export interface StudioPackageManifest { + packageKey: string; + space: Partial; + runtimeVariableAssignments: VariablesAssignments[]; +} + +export interface PackageVersionImport { + oldVersion: string; + newVersion: string; +} + +export interface PostPackageImportData { + packageKey: string; + importedVersions: PackageVersionImport[]; +} \ No newline at end of file diff --git a/src/commands/configuration-management/interfaces/variable-assignment-apis.constants.ts b/src/commands/configuration-management/interfaces/variable-assignment-apis.constants.ts new file mode 100644 index 00000000..ae930750 --- /dev/null +++ b/src/commands/configuration-management/interfaces/variable-assignment-apis.constants.ts @@ -0,0 +1,12 @@ +export interface VariableAssignmentApi { + url: string; +} + +export const variableAssignmentApis: { [key: string]: VariableAssignmentApi } = { + DATA_MODEL: { + url: "/package-manager/api/compute-pools/pools-with-data-models" + }, + CONNECTION: { + url: "/process-automation-v2/api/connections" + } +}; \ No newline at end of file diff --git a/src/commands/configuration-management/module.ts b/src/commands/configuration-management/module.ts index b08cdd02..be6e253e 100644 --- a/src/commands/configuration-management/module.ts +++ b/src/commands/configuration-management/module.ts @@ -4,12 +4,91 @@ import { Configurator, IModule } from "../../core/command/module-handler"; import { Context } from "../../core/command/cli-context"; +import { Command, OptionValues } from "commander"; +import { ConfigCommandService } from "./config-command.service"; +import { VariableCommandService } from "./variable-command.service"; class Module extends IModule { - register(context: Context, configurator: Configurator) { + public register(context: Context, configurator: Configurator): void { + const configCommand = configurator.command("config"); + configCommand.command("list") + .description("Command to list active packages that can be exported") + .option("-p, --profile ", "Profile which you want to use to list possible variable assignments") + .option("--json", "Return response as json type", "") + .option("--flavors ", "Lists only active packages of the given flavors") + .option("--withDependencies", "Include dependencies", "") + .option("--packageKeys ", "Lists only given package keys") + .option("--variableValue ", "Variable value for filtering packages by.") + .option("--variableType ", "Variable type for filtering packages by.") + .action(this.listActivePackages); + + configCommand.command("export") + .description("Command to export package configs") + .option("-p, --profile ", "Profile which you want to use to export packages") + .requiredOption("--packageKeys ", "Keys of packages to export") + .option("--withDependencies", "Include variables and dependencies", "") + .action(this.batchExportPackages); + + configCommand.command("import") + .description("Command to import package configs") + .option("-p, --profile ", "Profile which you want to use to import packages") + .option("--overwrite", "Flag to allow overwriting of packages") + .requiredOption("-f, --file ", "Exported packages file (relative path)") + .action(this.batchImportPackages); + + configCommand.command("diff") + .description("Command to diff configs of packages") + .option("-p, --profile ", "Profile of the team/realm which you want to use to diff the packages with") + .option("--hasChanges", "Flag to return only the information if the package has changes without the actual changes") + .option("--json", "Return the response as a JSON file") + .requiredOption("-f, --file ", "Exported packages file (relative or absolute path)") + .action(this.diffPackages); + + const variablesCommand = configCommand.command("variables") + .description("Commands related to variable configs"); + + variablesCommand.command("list") + .description("Command to list versioned variables of packages") + .option("-p, --profile ", "Profile which you want to use to list packages") + .option("--json", "Return response as json type", "") + .option("--keysByVersion ", "Mapping of package keys and versions", "") + .option("--keysByVersionFile ", "Package keys by version mappings file path.", "") + .action(this.listVariables); + + const listCommand = configurator.command("list"); + listCommand.command("assignments") + .description("Command to list possible variable assignments for a type") + .option("-p, --profile ", "Profile which you want to use to list possible variable assignments") + .option("--json", "Return response as json type", "") + .requiredOption("--type ", "Type of variable") + .option("--params ", "Variable query params") + .action(this.listAssignments); + } + + private async listActivePackages(context: Context, command: Command, options: OptionValues): Promise { + await new ConfigCommandService(context).listActivePackages(options.json, options.flavors, options.withDependencies, options.packageKeys, options.variableValue, options.variableType); } + private async batchExportPackages(context: Context, command: Command, options: OptionValues): Promise { + await new ConfigCommandService(context).batchExportPackages(options.packageKeys, options.withDependencies); + } + + private async batchImportPackages(context: Context, command: Command, options: OptionValues): Promise { + await new ConfigCommandService(context).batchImportPackages(options.file, options.overwrite); + } + + private async diffPackages(context: Context, command: Command, options: OptionValues): Promise { + await new ConfigCommandService(context).diffPackages(options.file, options.hasChanges, options.json); + } + + private async listVariables(context: Context, command: Command, options: OptionValues): Promise { + await new ConfigCommandService(context).listVariables(options.json, options.keysByVersion, options.keysByVersionFile); + } + + private async listAssignments(context: Context, command: Command, options: OptionValues): Promise { + await new VariableCommandService(context).listAssignments(options.type, options.json, options.params); + } } export = Module; \ No newline at end of file diff --git a/src/commands/configuration-management/studio.service.ts b/src/commands/configuration-management/studio.service.ts new file mode 100644 index 00000000..c19c95bd --- /dev/null +++ b/src/commands/configuration-management/studio.service.ts @@ -0,0 +1,280 @@ +import {IZipEntry} from "adm-zip"; +import * as AdmZip from "adm-zip"; +import { + NodeConfiguration, + NodeExportTransport, + PackageExportTransport, PackageKeyAndVersionPair, + StudioPackageManifest, VariableExportTransport, + VariableManifestTransport, +} from "./interfaces/package-export.interfaces"; +import { + ContentNodeTransport, + PackageManagerVariableType, + PackageWithVariableAssignments, StudioComputeNodeDescriptor, +} from "../studio/interfaces/package-manager.interfaces"; +import { BatchExportImportConstants } from "./interfaces/batch-export-import.constants"; +import { SpaceTransport } from "../studio/interfaces/space.interface"; +import { parse, stringify } from "../../core/utils/json"; +import { Context } from "../../core/command/cli-context"; +import { PackageApi } from "../studio/api/package-api"; +import { DataModelService } from "../studio/service/data-model.service"; +import { NodeApi } from "../studio/api/node-api"; +import { SpaceApi } from "../studio/api/space-api"; +import { StudioVariablesApi } from "../studio/api/studio-variables-api"; +import { SpaceService } from "../studio/service/space.service"; +import { StudioVariableService } from "../studio/service/studio-variable.service"; + +export class StudioService { + + private studioPackageApi: PackageApi; + private studioNodeApi: NodeApi; + private studioSpaceApi: SpaceApi; + private studioVariablesApi: StudioVariablesApi; + + private studioDataModelService: DataModelService; + private studioSpaceService: SpaceService; + private studioVariableService: StudioVariableService; + + constructor(context: Context) { + this.studioPackageApi = new PackageApi(context); + this.studioNodeApi = new NodeApi(context); + this.studioSpaceApi = new SpaceApi(context); + this.studioVariablesApi = new StudioVariablesApi(context); + + this.studioDataModelService = new DataModelService(context); + this.studioSpaceService = new SpaceService(context); + this.studioVariableService = new StudioVariableService(context); + } + + public async getExportPackagesWithStudioData(packagesToExport: PackageExportTransport[], withDependencies: boolean): Promise { + const studioPackagesWithDataModels = await this.studioPackageApi.findAllPackagesWithVariableAssignments(PackageManagerVariableType.DATA_MODEL); + + packagesToExport = this.setSpaceIdForStudioPackages(packagesToExport, studioPackagesWithDataModels); + + if (withDependencies) { + const dataModelDetailsByNode = await this.studioDataModelService.getDataModelDetailsForPackages(studioPackagesWithDataModels); + packagesToExport = this.setDataModelsForStudioPackages(packagesToExport, studioPackagesWithDataModels, dataModelDetailsByNode); + } + + return packagesToExport; + } + + public fixConnectionVariables(variables: VariableManifestTransport[]): VariableManifestTransport[] { + return variables.map(variableManifest => ({ + ...variableManifest, + variables: variableManifest.variables.map(variable => { + if (variable.type !== PackageManagerVariableType.CONNECTION) { + return variable; + } + + return this.fixConnectionVariable(variable); + }) + })); + } + + public async getStudioPackageManifests(studioPackageKeys: string[]): Promise { + return Promise.all(studioPackageKeys.map(async packageKey => { + const node = await this.studioNodeApi.findOneByKeyAndRootNodeKey(packageKey, packageKey); + const nodeSpace: SpaceTransport = await this.studioSpaceApi.findOne(node.spaceId); + const variableAssignments = await this.studioVariablesApi.getRuntimeVariableValues(packageKey, BatchExportImportConstants.APP_MODE_VIEWER); + + return { + packageKey: packageKey, + space: { + name: nodeSpace.name, + iconReference: nodeSpace.iconReference + }, + runtimeVariableAssignments: variableAssignments + } + })); + } + + public processPackageForExport(exportedPackage: IZipEntry, exportedVariables: VariableManifestTransport[]): AdmZip { + const packageZip = new AdmZip(exportedPackage.getData()); + this.deleteScenarioAssets(packageZip); + this.fixConnectionVariablesForRootNodeFiles(packageZip, exportedPackage.name, exportedVariables); + + return packageZip; + } + + public async processImportedPackages(configs: AdmZip, existingStudioPackages: ContentNodeTransport[], studioManifests: StudioPackageManifest[]): Promise { + if(studioManifests == null) { + return; + } + for (const manifest of studioManifests) { + const existingPackage = existingStudioPackages.find(existingPackage => existingPackage.key === manifest.packageKey); + if (existingPackage) { + await this.studioPackageApi.movePackageToSpace(existingPackage.id, manifest.space.id); + } + await this.assignRuntimeVariables(manifest); + } + } + + private setSpaceIdForStudioPackages(packages: PackageExportTransport[], studioPackages: PackageWithVariableAssignments[]): PackageExportTransport[] { + const studioPackageByKey = new Map(); + studioPackages.forEach(pkg => studioPackageByKey.set(pkg.key, pkg)); + + return packages.map(pkg => { + return studioPackageByKey.has(pkg.key) ? { + ...pkg, + spaceId: studioPackageByKey.get(pkg.key).spaceId + } : pkg; + }); + } + + private setDataModelsForStudioPackages(packages: PackageExportTransport[], + studioPackageWithDataModels: PackageWithVariableAssignments[], + dataModelDetailsByNode: Map): PackageExportTransport[] { + const studioPackageByKey = new Map(); + studioPackageWithDataModels.forEach(pkg => studioPackageByKey.set(pkg.key, pkg)); + + return packages.map(pkg => { + return studioPackageByKey.has(pkg.key) ? { + ...pkg, + datamodels: dataModelDetailsByNode.get(pkg.key) + .map(dataModel => ({ + name: dataModel.name, + poolId: dataModel.poolId, + dataModelId: dataModel.dataModelId + })) + } : pkg; + }); + } + + private fixConnectionVariable(variable: VariableExportTransport): VariableExportTransport { + if (!variable.value.appName) { + return variable; + } + + return { + ...variable, + metadata: { + ...variable.metadata, + appName: variable.value.appName + } + } + } + + private deleteScenarioAssets(packageZip: AdmZip): void { + packageZip.getEntries().filter(entry => entry.entryName.startsWith(BatchExportImportConstants.NODES_FOLDER_NAME) && entry.entryName.endsWith(BatchExportImportConstants.JSON_EXTENSION)) + .forEach(entry => { + const node: NodeExportTransport = parse(entry.getData().toString()); + if (node.type === BatchExportImportConstants.SCENARIO_NODE) { + packageZip.deleteFile(entry); + } + }); + } + + private fixConnectionVariablesForRootNodeFiles(packageZip: AdmZip, zipName: string, exportedVariables: VariableManifestTransport[]): void { + const packageKeyAndVersion = this.getPackageKeyAndVersion(zipName); + const connectionVariablesByKey = this.getConnectionVariablesByKeyForPackage(packageKeyAndVersion.packageKey, packageKeyAndVersion.version, exportedVariables); + + if (connectionVariablesByKey.size === 0) { + return; + } + + const packageEntry = packageZip.getEntry("package.json"); + + const exportedNode: NodeExportTransport = parse(packageEntry.getData().toString()); + const nodeContent: NodeConfiguration = exportedNode.configuration; + + nodeContent.variables = nodeContent.variables.map(variable => ({ + ...variable, + metadata: variable.type === PackageManagerVariableType.CONNECTION ? + connectionVariablesByKey.get(variable.key).metadata : variable.metadata + })); + + packageZip.updateFile(packageEntry, Buffer.from(stringify(exportedNode))); + } + + private getPackageKeyAndVersion(zipName: string): PackageKeyAndVersionPair { + const lastUnderscoreIndex = zipName.lastIndexOf("_"); + const packageKey = zipName.replace(BatchExportImportConstants.ZIP_EXTENSION, "").substring(0, lastUnderscoreIndex); + const packageVersion = zipName.replace(BatchExportImportConstants.ZIP_EXTENSION, "").substring(lastUnderscoreIndex + 1); + + return { + packageKey: packageKey, + version: packageVersion + } + } + + private getConnectionVariablesByKeyForPackage(packageKey: string, packageVersion: string, variables: VariableManifestTransport[]): Map { + const variablesByKey = new Map(); + const packageVariables = variables.find(exportedVariable => exportedVariable.packageKey === packageKey && exportedVariable.version === packageVersion); + + if (packageVariables && packageVariables.variables.length) { + packageVariables.variables.filter(variable => variable.type === PackageManagerVariableType.CONNECTION) + .forEach(variable => variablesByKey.set(variable.key, variable)); + } + + return variablesByKey; + } + + public async mapSpaces(exportedFiles: AdmZip, studioManifests: StudioPackageManifest[]): Promise { + if (studioManifests == null) { + return exportedFiles; + } + for (const file of exportedFiles.getEntries()) { + if(file.name.endsWith(BatchExportImportConstants.ZIP_EXTENSION)) { + const packageKey = this.getPackageKeyAndVersion(file.name).packageKey; + + if (this.isStudioPackage(studioManifests, packageKey)) { + const studioManifest = studioManifests.find(manifest => manifest.packageKey === packageKey); + + if (studioManifest) { + const spaceId = await this.findDesiredSpaceIdForPackage(studioManifest); + studioManifest.space.id = spaceId; + + const packageZip = new AdmZip(file.getData()); + packageZip.getEntries().forEach(nodeFile => { + if (nodeFile.entryName.endsWith(BatchExportImportConstants.JSON_EXTENSION)) { + const updatedNodeFile = this.updateSpaceIdForNode(nodeFile.getData().toString(), spaceId); + packageZip.updateFile(nodeFile, Buffer.from(updatedNodeFile)); + } + }); + exportedFiles.updateFile(file, packageZip.toBuffer()); + } + } + } + } + return exportedFiles; + } + + private isStudioPackage(studioManifests: StudioPackageManifest[], packageKey: string): boolean { + return studioManifests.some(manifest => manifest.packageKey === packageKey); + } + + private async findDesiredSpaceIdForPackage(studioPackageManifest: StudioPackageManifest): Promise { + const allSpaces = await this.studioSpaceService.refreshAndGetAllSpaces(); + + if (studioPackageManifest.space.id) { + const targetSpace = allSpaces.find(space => space.id === studioPackageManifest.space.id); + if (!targetSpace) { + throw Error("Provided space ID does not exist."); + } + return targetSpace.id; + } + + const targetSpaceByName = allSpaces.find(space => space.name === studioPackageManifest.space.name); + if (targetSpaceByName) { + return targetSpaceByName.id; + } + + const spaceTransport = await this.studioSpaceService.createSpace(studioPackageManifest.space.name, studioPackageManifest.space.iconReference); + return spaceTransport.id; + } + + private async assignRuntimeVariables(manifest: StudioPackageManifest): Promise { + if (manifest.runtimeVariableAssignments.length) { + await this.studioVariableService.assignVariableValues(manifest.packageKey, manifest.runtimeVariableAssignments); + } + } + + private updateSpaceIdForNode(nodeContent: string, spaceId: string): string { + const exportedNode: NodeExportTransport = parse(nodeContent); + const oldSpaceId = exportedNode.spaceId; + + nodeContent = nodeContent.replace(new RegExp(oldSpaceId, "g"), spaceId); + return nodeContent; + } +} diff --git a/src/commands/configuration-management/variable-command.service.ts b/src/commands/configuration-management/variable-command.service.ts new file mode 100644 index 00000000..dbf699dd --- /dev/null +++ b/src/commands/configuration-management/variable-command.service.ts @@ -0,0 +1,19 @@ +import { VariableService } from "./variable.service"; +import { Context } from "../../core/command/cli-context"; + +export class VariableCommandService { + + private variableService: VariableService; + + constructor(context: Context) { + this.variableService = new VariableService(context); + } + + public async listAssignments(variableType: string, jsonResponse: boolean, params: string): Promise { + if (jsonResponse) { + await this.variableService.findAndExportCandidateAssignments(variableType, params); + } else { + await this.variableService.listCandidateAssignments(variableType, params); + } + } +} diff --git a/src/commands/configuration-management/variable.service.ts b/src/commands/configuration-management/variable.service.ts new file mode 100644 index 00000000..77c8b85a --- /dev/null +++ b/src/commands/configuration-management/variable.service.ts @@ -0,0 +1,106 @@ +import { v4 as uuidv4 } from "uuid"; +import { Context } from "../../core/command/cli-context"; +import { FatalError, logger } from "../../core/utils/logger"; +import { StudioService } from "./studio.service"; +import { FileService, fileService } from "../../core/utils/file-service"; +import { PackageKeyAndVersionPair, VariableManifestTransport } from "./interfaces/package-export.interfaces"; +import { BatchImportExportApi } from "./api/batch-import-export-api"; +import { URLSearchParams } from "url"; +import { VariableAssignmentCandidatesApi } from "./api/variable-assignment-candidates-api"; + +export class VariableService { + + private batchImportExportApi: BatchImportExportApi; + private variableAssignmentCandidatesApi: VariableAssignmentCandidatesApi; + private studioService: StudioService; + + constructor(context: Context) { + this.batchImportExportApi = new BatchImportExportApi(context); + this.variableAssignmentCandidatesApi = new VariableAssignmentCandidatesApi(context); + this.studioService = new StudioService(context); + } + + public async listVariables(keysByVersion: string[], keysByVersionFile: string): Promise { + const variableManifests = await this.getVersionedVariablesByKeyVersionPairs(keysByVersion, keysByVersionFile); + + variableManifests.forEach(variableManifest => { + logger.info(JSON.stringify(variableManifest)); + }); + } + + public async listCandidateAssignments(type: string, params: string): Promise { + const parsedParams = this.parseParams(params); + const assignments = await this.variableAssignmentCandidatesApi.getCandidateAssignments(type, parsedParams); + + assignments.forEach(assignment => { + logger.info(JSON.stringify(assignment)); + }); + } + + public async findAndExportCandidateAssignments(type: string, params: string): Promise { + const parsedParams = this.parseParams(params); + const assignments = await this.variableAssignmentCandidatesApi.getCandidateAssignments(type, parsedParams); + + this.exportToJson(assignments) + } + + public async exportVariables(keysByVersion: string[], keysByVersionFile: string): Promise { + const variableManifests = await this.getVersionedVariablesByKeyVersionPairs(keysByVersion, keysByVersionFile); + + this.exportToJson(variableManifests); + } + + private async getVersionedVariablesByKeyVersionPairs(keysByVersion: string[], keysByVersionFile: string): Promise { + const variablesExportRequest: PackageKeyAndVersionPair[] = await this.buildKeyVersionPairs(keysByVersion, keysByVersionFile); + + const variableManifests = await this.batchImportExportApi.findVariablesWithValuesByPackageKeysAndVersion(variablesExportRequest); + return this.studioService.fixConnectionVariables(variableManifests); + } + + private async buildKeyVersionPairs(keysByVersion: string[], keysByVersionFile: string): Promise { + let variablesExportRequest: PackageKeyAndVersionPair[] = []; + + if (keysByVersion.length !== 0) { + variablesExportRequest = this.buildKeyAndVersionPairsFromArrayInput(keysByVersion); + } else if (keysByVersion.length === 0 && keysByVersionFile !== "") { + variablesExportRequest = await fileService.readFileToJson(keysByVersionFile); + } else { + throw new FatalError("Please provide keysByVersion mappings or file path!"); + } + + return variablesExportRequest; + } + + private buildKeyAndVersionPairsFromArrayInput(keysByVersion: string[]): PackageKeyAndVersionPair[] { + return keysByVersion.map(keyAndVersion => { + const keyAndVersionSplit = keyAndVersion.split(":"); + return { + packageKey: keyAndVersionSplit[0], + version: keyAndVersionSplit[1] + }; + }); + } + + private exportToJson(data: any): void { + const filename = uuidv4() + ".json"; + fileService.writeToFileWithGivenName(JSON.stringify(data), filename); + logger.info(FileService.fileDownloadedMessage + filename); + } + + private parseParams(params?: string): URLSearchParams { + const queryParams = new URLSearchParams(); + + if (params) { + try { + params.split(",").forEach((param: string) => { + const paramKeyValuePair: string[] = param.split("="); + queryParams.set(paramKeyValuePair[0], paramKeyValuePair[1]); + }) + } catch (e) { + throw new FatalError(`Problem parsing query params: ${e}`); + } + } + + return queryParams; + } +} diff --git a/src/commands/data-pipeline/data-pool/data-pool-manager.factory.ts b/src/commands/data-pipeline/data-pool/data-pool-manager.factory.ts index 5b733006..9a367007 100644 --- a/src/commands/data-pipeline/data-pool/data-pool-manager.factory.ts +++ b/src/commands/data-pipeline/data-pool/data-pool-manager.factory.ts @@ -1,7 +1,7 @@ import * as path from "path"; import { DataPoolManager } from "./data-pool.manager"; import { FatalError, logger } from "../../../core/utils/logger"; -import * as fs from "node:fs"; +import * as fs from "fs"; import { Context } from "../../../core/command/cli-context"; export class DataPoolManagerFactory { diff --git a/src/commands/studio/command-service/widget.command.ts b/src/commands/studio/command-service/widget.command.ts index ef58409d..cc483507 100644 --- a/src/commands/studio/command-service/widget.command.ts +++ b/src/commands/studio/command-service/widget.command.ts @@ -1,6 +1,6 @@ import { execSync } from "child_process"; import { GracefulError, logger } from "../../../core/utils/logger"; -import * as fs from "node:fs"; +import * as fs from "fs"; import * as path from "path"; import { ContentService } from "../../../core/http/http-shared/content.service"; import { Context } from "../../../core/command/cli-context"; diff --git a/tests/commands/action-flows/analyze-action-flows.spec.ts b/tests/commands/action-flows/analyze-action-flows.spec.ts new file mode 100644 index 00000000..784f3e13 --- /dev/null +++ b/tests/commands/action-flows/analyze-action-flows.spec.ts @@ -0,0 +1,70 @@ +import * as path from "path"; +import { mockedAxiosInstance } from "../../utls/http-requests-mock"; +import { loggingTestTransport, mockWriteFileSync } from "../../jest.setup"; +import { FileService } from "../../../src/core/utils/file-service"; +import { ActionFlowCommandService } from "../../../src/commands/action-flows/action-flow/action-flow-command.service"; +import { testContext } from "../../utls/test-context"; + +describe("Analyze action-flows", () => { + + const packageId = "123-456-789"; + const mockAnalyzeResponse = { + "actionFlows": [ + { + "key": "987_asset_key", + "rootNodeKey": "123_root_key_node", + "parentNodeKey": "555_parent_node_key", + "name": "T2T - simple package Automation", + "scenarioId": "321", + "webHookUrl": null, + "version": "10", + "sensorType": null, + "schedule": { + "type": "indefinitely", + "interval": 900, + }, + "teamSpecific": { + "connections": [], + "variables": [], + "celonisApps": [], + "callingOtherAf": [], + "datastructures": [], + }, + }, + ], + "connections": [], + "dataPools": [], + "dataModels": [], + "skills": [], + "analyses": [], + "datastructures": [], + "mappings": [], + "actionFlowsTeamId": "1234", + }; + + it("Should call import API and return non-json response", async () => { + const resp = { data: mockAnalyzeResponse }; + (mockedAxiosInstance.get as jest.Mock).mockResolvedValue(resp); + + await new ActionFlowCommandService(testContext).analyzeActionFlows(packageId, false); + + expect(loggingTestTransport.logMessages.length).toBe(1); + expect(loggingTestTransport.logMessages[0].message).toContain(JSON.stringify(mockAnalyzeResponse, null, 4)); + + expect(mockedAxiosInstance.get).toHaveBeenCalledWith(`https://myTeam.celonis.cloud/ems-automation/api/root/${packageId}/export/assets/analyze`, expect.anything()); + }); + + it("Should call import API and return json response", async () => { + const resp = { data: mockAnalyzeResponse }; + (mockedAxiosInstance.get as jest.Mock).mockResolvedValue(resp); + + await new ActionFlowCommandService(testContext).analyzeActionFlows(packageId, true); + + expect(loggingTestTransport.logMessages.length).toBe(1); + expect(loggingTestTransport.logMessages[0].message).toContain(FileService.fileDownloadedMessage); + const expectedFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + + expect(mockWriteFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), expectedFileName), JSON.stringify(mockAnalyzeResponse, null, 4), { encoding: "utf-8" }); + expect(mockedAxiosInstance.get).toHaveBeenCalledWith(`https://myTeam.celonis.cloud/ems-automation/api/root/${packageId}/export/assets/analyze`, expect.anything()); + }); +}); \ No newline at end of file diff --git a/tests/commands/action-flows/export-action-flows.spec.ts b/tests/commands/action-flows/export-action-flows.spec.ts new file mode 100644 index 00000000..09e9d9cb --- /dev/null +++ b/tests/commands/action-flows/export-action-flows.spec.ts @@ -0,0 +1,181 @@ +import * as AdmZip from "adm-zip"; +import * as fs from "fs"; +import { parse, stringify } from "yaml"; +import { mockAxiosGet } from "../../utls/http-requests-mock"; +import { ActionFlowCommandService } from "../../../src/commands/action-flows/action-flow/action-flow-command.service"; +import { loggingTestTransport, mockWriteSync } from "../../jest.setup"; +import { FileService } from "../../../src/core/utils/file-service"; +import { mockExistsSyncOnce, mockReadFileSync } from "../../utls/fs-mock-utils"; +import { ActionFlowService } from "../../../src/commands/action-flows/action-flow/action-flow.service"; +import { testContext } from "../../utls/test-context"; + +describe("Export action-flows", () => { + + const packageId = "123-456-789"; + const actionFlowFileName = "20240711-scenario-1234.json"; + const actionFlowConfig = { + "name": "T2T - simple package Automation", + "flow": [ + { + "id": 6, + "module": "util:FunctionSleep", + "version": 1, + "parameters": {}, + "metadata": { + "expect": [ + { + "name": "duration", + "type": "uinteger", + "label": "Delay", + "required": true, + "validate": { + "max": 300, + "min": 1, + }, + }, + ], + "restore": {}, + "designer": { + "x": 300, + "y": 0, + }, + }, + "mapper": { + "duration": "1", + }, + }, + ], + "metadata": { + "instant": false, + "version": 1, + "designer": { + "orphans": [], + }, + "scenario": { + "dlq": false, + "dataloss": false, + "maxErrors": 3, + "autoCommit": true, + "roundtrips": 1, + "sequential": false, + "confidential": false, + "freshVariables": false, + "autoCommitTriggerLast": true, + }, + }, + "io": { + "output_spec": [], + "input_spec": [], + }, + }; + + beforeEach(() => { + (fs.openSync as jest.Mock).mockReturnValue(100); + }); + + it("Should call export API and return the zip response", async () => { + const zipExport = new AdmZip(); + zipExport.addFile(actionFlowFileName, Buffer.from(stringify(actionFlowConfig))); + + mockAxiosGet(`https://myTeam.celonis.cloud/ems-automation/api/root/${packageId}/export/assets`, zipExport.toBuffer()); + + await new ActionFlowCommandService(testContext).exportActionFlows(packageId, null); + + expect(loggingTestTransport.logMessages.length).toBe(1); + const expectedZipFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + expect(fs.openSync).toHaveBeenCalledWith(expectedZipFileName, expect.anything(), expect.anything()); + expect(mockWriteSync).toHaveBeenCalled(); + + const fileBuffer = mockWriteSync.mock.calls[0][1]; + const receivedZip = new AdmZip(fileBuffer); + + expect(receivedZip.getEntries().length).toBe(1); + const receivedZipEntry = receivedZip.getEntries()[0]; + const receivedActionFlowConfig = parse(receivedZipEntry.getData().toString()); + expect(receivedZipEntry.name).toEqual(actionFlowFileName); + expect(receivedActionFlowConfig).toEqual(actionFlowConfig); + }); + + it("Should call export API and attach the provided file to the zip response", async () => { + const metadata = { + "actionFlows": [ + { + "key": "123_asset_key", + "rootNodeKey": "543_root_key", + "parentNodeKey": "9099_parent_key", + "name": "T2T - simple package Automation", + "scenarioId": "1234", + "webHookUrl": null, + "version": "10", + "sensorType": null, + "schedule": { + "type": "indefinitely", + "interval": 900, + }, + "teamSpecific": { + "connections": [], + "variables": [], + "celonisApps": [], + "callingOtherAf": [], + "datastructures": [], + }, + }, + ], + "connections": [], + "dataPools": [], + "dataModels": [], + "skills": [], + "analyses": [], + "datastructures": [], + "mappings": [], + "actionFlowsTeamId": "555", + }; + + mockExistsSyncOnce(); + mockReadFileSync(stringify(metadata)); + const zipExport = new AdmZip(); + zipExport.addFile(actionFlowFileName, Buffer.from(stringify(actionFlowConfig))); + + mockAxiosGet(`https://myTeam.celonis.cloud/ems-automation/api/root/${packageId}/export/assets`, zipExport.toBuffer()); + await new ActionFlowCommandService(testContext).exportActionFlows(packageId, ActionFlowService.METADATA_FILE_NAME); + + expect(loggingTestTransport.logMessages.length).toBe(1); + const expectedZipFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + expect(fs.openSync).toHaveBeenCalledWith(expectedZipFileName, expect.anything(), expect.anything()); + expect(mockWriteSync).toHaveBeenCalled(); + + const fileBuffer = mockWriteSync.mock.calls[0][1]; + const receivedZip = new AdmZip(fileBuffer); + + expect(receivedZip.getEntries().length).toBe(2); + expect(receivedZip.getEntries().filter(entry => entry.name === ActionFlowService.METADATA_FILE_NAME).length).toBe(1); + + const receivedMetadataZipEntry = receivedZip.getEntries().filter(entry => entry.name === ActionFlowService.METADATA_FILE_NAME)[0]; + const receivedActionFlowZipEntry = receivedZip.getEntries().filter(entry => entry.name !== ActionFlowService.METADATA_FILE_NAME)[0]; + const receivedMetadataFile = parse(receivedMetadataZipEntry.getData().toString()); + const receivedActionFlowFile = parse(receivedActionFlowZipEntry.getData().toString()); + + expect(receivedActionFlowZipEntry.name).toEqual(actionFlowFileName); + expect(receivedActionFlowFile).toEqual(actionFlowConfig); + expect(receivedMetadataFile).toEqual(metadata); + }); + + it("Should throw error if metadata files does not exist", async () => { + const zipExport = new AdmZip(); + zipExport.addFile(actionFlowFileName, Buffer.from(stringify(actionFlowConfig))); + + mockAxiosGet(`https://myTeam.celonis.cloud/ems-automation/api/root/${packageId}/export/assets`, zipExport.toBuffer()); + + const error = new Error("mock exit"); + jest.spyOn(process, "exit").mockImplementation(() => { + throw error; + }); + + try { + await new ActionFlowCommandService(testContext).exportActionFlows(packageId, ActionFlowService.METADATA_FILE_NAME); + } catch (e) { + expect(e).toBe(error); + expect(process.exit).toHaveBeenCalledWith(1); + } + }); +}); \ No newline at end of file diff --git a/tests/commands/action-flows/import-action-flows.spec.ts b/tests/commands/action-flows/import-action-flows.spec.ts new file mode 100644 index 00000000..9ff02fb8 --- /dev/null +++ b/tests/commands/action-flows/import-action-flows.spec.ts @@ -0,0 +1,56 @@ +import * as path from "path"; +import * as AdmZip from "adm-zip"; +import { mockedAxiosInstance } from "../../utls/http-requests-mock"; +import { mockCreateReadStream } from "../../utls/fs-mock-utils"; +import { ActionFlowCommandService } from "../../../src/commands/action-flows/action-flow/action-flow-command.service"; +import { loggingTestTransport, mockWriteFileSync } from "../../jest.setup"; +import { FileService } from "../../../src/core/utils/file-service"; +import { testContext } from "../../utls/test-context"; + +describe("Import action-flows", () => { + + const packageId = "123-456-789"; + const mockImportResponse = { + "status": "SUCCESS", + "eventLog": [ + { + "status": "SUCCESS", + "assetType": "SCENARIO", + "assetId": "asset-id-automation", + "eventType": "IMPORT", + "log": "updated action flow with key [asset-id-automation]", + "mapping": null, + }, + ], + }; + + it("Should call import API and return non-json response", async () => { + const resp = { data: mockImportResponse }; + (mockedAxiosInstance.post as jest.Mock).mockResolvedValue(resp); + const zip = new AdmZip(); + mockCreateReadStream(zip.toBuffer()); + + await new ActionFlowCommandService(testContext).importActionFlows(packageId, "tmp", true, false); + + expect(loggingTestTransport.logMessages.length).toBe(1); + expect(loggingTestTransport.logMessages[0].message).toContain(JSON.stringify(mockImportResponse, null, 4)); + + expect(mockedAxiosInstance.post).toHaveBeenCalledWith(`https://myTeam.celonis.cloud/ems-automation/api/root/${packageId}/import/assets`, expect.anything(), expect.anything()); + }); + + it("Should call import API and return json response", async () => { + const resp = { data: mockImportResponse }; + (mockedAxiosInstance.post as jest.Mock).mockResolvedValue(resp); + const zip = new AdmZip(); + mockCreateReadStream(zip.toBuffer()); + + await new ActionFlowCommandService(testContext).importActionFlows(packageId, "tmp", true, true); + + expect(loggingTestTransport.logMessages.length).toBe(1); + expect(loggingTestTransport.logMessages[0].message).toContain(FileService.fileDownloadedMessage); + const expectedFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + + expect(mockWriteFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), expectedFileName), JSON.stringify(mockImportResponse, null, 4), { encoding: "utf-8" }); + expect(mockedAxiosInstance.post).toHaveBeenCalledWith(`https://myTeam.celonis.cloud/ems-automation/api/root/${packageId}/import/assets`, expect.anything(), expect.anything()); + }); +}); \ No newline at end of file diff --git a/tests/commands/configuration-management/config-diff.spec.ts b/tests/commands/configuration-management/config-diff.spec.ts new file mode 100644 index 00000000..9c93cd15 --- /dev/null +++ b/tests/commands/configuration-management/config-diff.spec.ts @@ -0,0 +1,179 @@ +import * as path from "path"; +import { mockCreateReadStream, mockExistsSync, mockReadFileSync } from "../../utls/fs-mock-utils"; +import { + PackageManifestTransport +} from "../../../src/commands/configuration-management/interfaces/package-export.interfaces"; +import { + NodeConfigurationChangeType, + PackageDiffMetadata, + PackageDiffTransport, +} from "../../../src/commands/configuration-management/interfaces/diff-package.interfaces"; +import { mockAxiosPost } from "../../utls/http-requests-mock"; +import { ConfigCommandService } from "../../../src/commands/configuration-management/config-command.service"; +import { testContext } from "../../utls/test-context"; +import { loggingTestTransport, mockWriteFileSync } from "../../jest.setup"; +import { FileService } from "../../../src/core/utils/file-service"; +import { ConfigUtils } from "../../utls/config-utils"; + +describe("Config diff", () => { + + beforeEach(() => { + mockExistsSync(); + }); + + it("Should show on terminal if packages have changes with hasChanges set to true and jsonResponse false", async () => { + const manifest: PackageManifestTransport[] = []; + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("package-key", "STUDIO")); + + const firstPackageNode = ConfigUtils.buildPackageNode("package-key", {metadata: {description: "test"}, variables: [], dependencies: []}); + const firstChildNode = ConfigUtils.buildChildNode("key-1", "package-key", "TEST"); + const firstPackageZip = ConfigUtils.buildExportPackageZip(firstPackageNode, [firstChildNode], "1.0.0"); + const exportedPackagesZip = ConfigUtils.buildBatchExportZip(manifest, [firstPackageZip]); + + mockReadFileSync(exportedPackagesZip.toBuffer()); + mockCreateReadStream(exportedPackagesZip.toBuffer()); + + const diffResponse: PackageDiffMetadata[] = [{ + packageKey: "package-key", + hasChanges: true + }]; + + mockAxiosPost("https://myTeam.celonis.cloud/package-manager/api/core/packages/diff/configuration/has-changes", diffResponse); + + await new ConfigCommandService(testContext).diffPackages("./packages.zip", true, false); + + expect(loggingTestTransport.logMessages.length).toBe(1); + expect(loggingTestTransport.logMessages[0].message).toContain( + JSON.stringify(diffResponse, null, 2) + ); + }); + + it("Should show diff on terminal with hasChanges set to false and jsonResponse false", async () => { + const manifest: PackageManifestTransport[] = []; + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("package-key", "STUDIO")); + + const firstPackageNode = ConfigUtils.buildPackageNode("package-key", {metadata: {description: "test"}, variables: [], dependencies: []}); + const firstChildNode = ConfigUtils.buildChildNode("key-1", "package-key", "TEST"); + const firstPackageZip = ConfigUtils.buildExportPackageZip(firstPackageNode, [firstChildNode], "1.0.0"); + const exportedPackagesZip = ConfigUtils.buildBatchExportZip(manifest, [firstPackageZip]); + + mockReadFileSync(exportedPackagesZip.toBuffer()); + mockCreateReadStream(exportedPackagesZip.toBuffer()); + + const diffResponse: PackageDiffTransport[] = [{ + packageKey: "package-key", + packageChanges: [ + { + op: "add", + path: "/test", + from: "bbbb", + value: JSON.parse("123"), + fromValue: null + }], + nodesWithChanges: [{ + nodeKey: firstChildNode.key, + name: firstChildNode.name, + type: firstChildNode.type, + changeType: NodeConfigurationChangeType.ADDED, + changes: [{ + op: "add", + path: "/test", + from: "bbb", + value: JSON.parse("234"), + fromValue: null + }] + }] + }]; + + mockAxiosPost("https://myTeam.celonis.cloud/package-manager/api/core/packages/diff/configuration", diffResponse); + + await new ConfigCommandService(testContext).diffPackages("./packages.zip", false, false); + + expect(loggingTestTransport.logMessages.length).toBe(1); + expect(loggingTestTransport.logMessages[0].message).toContain( + JSON.stringify(diffResponse, null, 2) + ); + }); + + it("Should generate a json file with diff info when hasChanges is set to false and jsonResponse is set to true", async () => { + const manifest: PackageManifestTransport[] = []; + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("package-key", "STUDIO")); + + const firstPackageNode = ConfigUtils.buildPackageNode("package-key", {metadata: {description: "test"}, variables: [], dependencies: []}); + const firstChildNode = ConfigUtils.buildChildNode("key-1", "package-key", "TEST"); + const firstPackageZip = ConfigUtils.buildExportPackageZip(firstPackageNode, [firstChildNode], "1.0.0"); + const exportedPackagesZip = ConfigUtils.buildBatchExportZip(manifest, [firstPackageZip]); + + mockReadFileSync(exportedPackagesZip.toBuffer()); + mockCreateReadStream(exportedPackagesZip.toBuffer()); + + const diffResponse: PackageDiffTransport[] = [{ + packageKey: "package-key", + packageChanges: [ + { + op: "add", + path: "/test", + from: "bbbb", + value: JSON.parse("123"), + fromValue: null + }], + nodesWithChanges: [{ + nodeKey: firstChildNode.key, + name: firstChildNode.name, + type: firstChildNode.type, + changeType: NodeConfigurationChangeType.ADDED, + changes: [{ + op: "add", + path: "/test", + from: "bbb", + value: JSON.parse("234"), + fromValue: null + }] + }] + }]; + + mockAxiosPost("https://myTeam.celonis.cloud/package-manager/api/core/packages/diff/configuration", diffResponse); + + await new ConfigCommandService(testContext).diffPackages("./packages.zip", false, true); + + const expectedFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + + expect(mockWriteFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), expectedFileName), expect.any(String), {encoding: "utf-8"}); + const exportedPackageDiffTransport = JSON.parse(mockWriteFileSync.mock.calls[0][1]) as PackageDiffTransport[]; + expect(exportedPackageDiffTransport.length).toBe(1); + + const exportedFirstPackageDiffTransport = exportedPackageDiffTransport.filter(diffTransport => diffTransport.packageKey === firstPackageNode.key); + expect(exportedFirstPackageDiffTransport).toEqual(diffResponse); + }); + + it("Should generate a json file with info whether packages have changes when hasChanges is set to true and jsonResponse is set to true", async () => { + const manifest: PackageManifestTransport[] = []; + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("package-key", "STUDIO")); + + const firstPackageNode = ConfigUtils.buildPackageNode("package-key", {metadata: {description: "test"}, variables: [], dependencies: []}); + const firstChildNode = ConfigUtils.buildChildNode("key-1", "package-key", "TEST"); + const firstPackageZip = ConfigUtils.buildExportPackageZip(firstPackageNode, [firstChildNode], "1.0.0"); + const exportedPackagesZip = ConfigUtils.buildBatchExportZip(manifest, [firstPackageZip]); + + mockReadFileSync(exportedPackagesZip.toBuffer()); + mockCreateReadStream(exportedPackagesZip.toBuffer()); + + const diffResponse: PackageDiffMetadata[] = [{ + packageKey: "package-key", + hasChanges: true + }]; + + mockAxiosPost("https://myTeam.celonis.cloud/package-manager/api/core/packages/diff/configuration/has-changes", diffResponse); + + await new ConfigCommandService(testContext).diffPackages("./packages.zip", true, true); + + const expectedFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + + expect(mockWriteFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), expectedFileName), expect.any(String), {encoding: "utf-8"}); + const exportedPackageDiffTransport = JSON.parse(mockWriteFileSync.mock.calls[0][1]) as PackageDiffTransport[]; + expect(exportedPackageDiffTransport.length).toBe(1); + + const exportedFirstPackageDiffTransport = exportedPackageDiffTransport.filter(diffTransport => diffTransport.packageKey === firstPackageNode.key); + expect(exportedFirstPackageDiffTransport).toEqual(diffResponse); + }); +}); \ No newline at end of file diff --git a/tests/commands/configuration-management/config-export.spec.ts b/tests/commands/configuration-management/config-export.spec.ts new file mode 100644 index 00000000..a93c4632 --- /dev/null +++ b/tests/commands/configuration-management/config-export.spec.ts @@ -0,0 +1,552 @@ +import * as fs from "fs"; +import AdmZip = require("adm-zip"); +import { mockAxiosGet, mockAxiosPost, mockedPostRequestBodyByUrl } from "../../utls/http-requests-mock"; +import { + BatchExportImportConstants +} from "../../../src/commands/configuration-management/interfaces/batch-export-import.constants"; +import { + DependencyTransport, NodeConfiguration, NodeExportTransport, + PackageManifestTransport, StudioPackageManifest, VariableManifestTransport, +} from "../../../src/commands/configuration-management/interfaces/package-export.interfaces"; +import { + PackageManagerVariableType, VariableDefinition, + VariablesAssignments, +} from "../../../src/commands/studio/interfaces/package-manager.interfaces"; +import { loggingTestTransport, mockWriteSync } from "../../jest.setup"; +import { FileService } from "../../../src/core/utils/file-service"; +import { parse, stringify } from "../../../src/core/utils/json"; +import { ConfigCommandService } from "../../../src/commands/configuration-management/config-command.service"; +import { testContext } from "../../utls/test-context"; +import { ConfigUtils } from "../../utls/config-utils"; +import { PacmanApiUtils } from "../../utls/pacman-api.utils"; + +describe("Config export", () => { + + const firstSpace = PacmanApiUtils.buildSpaceTransport("space-1", "First space", "Icon1"); + const secondSpace = PacmanApiUtils.buildSpaceTransport("space-2", "Second space", "Icon2"); + + beforeEach(() => { + (fs.openSync as jest.Mock).mockReturnValue(100); + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/spaces/space-1", {...firstSpace}); + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/spaces/space-2", {...secondSpace}); + }); + + it("Should export studio file for studio packageKeys", async () => { + const manifest: PackageManifestTransport[] = []; + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("key-1", BatchExportImportConstants.STUDIO)); + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("key-2", BatchExportImportConstants.STUDIO)); + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("key-3", "TEST")); + const exportedPackagesZip = ConfigUtils.buildBatchExportZip(manifest, []); + + const firstStudioPackage = PacmanApiUtils.buildContentNodeTransport("key-1", "space-1"); + const firstPackageRuntimeVariable: VariablesAssignments = { + key: "varKey", + type: PackageManagerVariableType.PLAIN_TEXT, + value: "default-value" as unknown as object + }; + + const secondStudioPackage = PacmanApiUtils.buildContentNodeTransport("key-2", "space-2"); + + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/batch?packageKeys=key-1&packageKeys=key-2&packageKeys=key-3&withDependencies=true", exportedPackagesZip.toBuffer()); + mockAxiosPost("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/batch/variables-with-assignments", []); + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/nodes/${firstStudioPackage.key}/${firstStudioPackage.key}`, firstStudioPackage); + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/nodes/${secondStudioPackage.key}/${secondStudioPackage.key}`, secondStudioPackage); + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/nodes/by-package-key/${firstStudioPackage.key}/variables/runtime-values?appMode=VIEWER`, [firstPackageRuntimeVariable]); + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/nodes/by-package-key/${secondStudioPackage.key}/variables/runtime-values?appMode=VIEWER`, []); + + await new ConfigCommandService(testContext).batchExportPackages(["key-1", "key-2", "key-3"], true); + + const expectedFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + expect(fs.openSync).toHaveBeenCalledWith(expectedFileName, expect.anything(), expect.anything()); + expect(mockWriteSync).toHaveBeenCalled(); + + const fileBuffer = mockWriteSync.mock.calls[0][1]; + const actualZip = new AdmZip(fileBuffer); + + const studioManifest: StudioPackageManifest[] = parse(actualZip.getEntry(BatchExportImportConstants.STUDIO_FILE_NAME).getData().toString()); + expect(studioManifest).toHaveLength(2); + expect(studioManifest).toContainEqual({ + packageKey: firstStudioPackage.key, + space: { + name: firstSpace.name, + iconReference: firstSpace.iconReference + }, + runtimeVariableAssignments: [firstPackageRuntimeVariable] + }); + expect(studioManifest).toContainEqual({ + packageKey: secondStudioPackage.key, + space: { + name: secondSpace.name, + iconReference: secondSpace.iconReference + }, + runtimeVariableAssignments: [] + }); + }) + + it("Should export variables file with connection variables fixed", async () => { + const firstPackageDependencies = new Map(); + firstPackageDependencies.set("1.0.0", []); + + const secondPackageDependencies = new Map(); + secondPackageDependencies.set("1.0.0", []); + secondPackageDependencies.set("1.1.1", []); + + const manifest: PackageManifestTransport[] = []; + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("key-1", BatchExportImportConstants.STUDIO, firstPackageDependencies)); + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("key-2", BatchExportImportConstants.STUDIO, secondPackageDependencies)); + + const firstPackageVariableDefinition: VariableDefinition[] = [ + { + key: "key1-var", + type: PackageManagerVariableType.DATA_MODEL, + runtime: false + }, + { + key: "key-1-connection", + type: PackageManagerVariableType.CONNECTION, + runtime: false + } + ]; + + const firstPackageNode = ConfigUtils.buildPackageNode("key-1", {variables: firstPackageVariableDefinition}); + const firstPackageZip = ConfigUtils.buildExportPackageZip(firstPackageNode, [], "1.0.0"); + + const secondPackageVariableDefinition: VariableDefinition[] = [ + { + key: "key2-var", + type: PackageManagerVariableType.DATA_MODEL, + runtime: false + }, + { + key: "key-2-connection", + type: PackageManagerVariableType.CONNECTION, + runtime: false + } + ]; + + const secondPackageNode = ConfigUtils.buildPackageNode("key-2", {variables: secondPackageVariableDefinition}); + const secondPackageZip = ConfigUtils.buildExportPackageZip(secondPackageNode, [], "1.0.0"); + + const exportedPackagesZip = ConfigUtils.buildBatchExportZip(manifest, [firstPackageZip, secondPackageZip]); + + const exportedVariables: VariableManifestTransport[] = [ + { + packageKey: "key-1", + version: "1.0.0", + variables: [ + { + key: "key1-var", + type: PackageManagerVariableType.DATA_MODEL, + value: "dm-id" as unknown as object, + metadata: {} + }, + { + key: "key-1-connection", + type: PackageManagerVariableType.CONNECTION, + value: { + appName: "celonis", + connectionId: "connection-id" + } as unknown as object, + metadata: null + } + ] + }, + { + packageKey: "key-2", + version: "1.0.0", + variables: [ + { + key: "key2-var", + type: PackageManagerVariableType.DATA_MODEL, + value: "dm-id" as unknown as object, + metadata: {} + }, + { + key: "key-2-connection", + type: PackageManagerVariableType.CONNECTION, + value: "connection-id", + metadata: { + appName: "nameOfApp" + } + } + ] + }, + ]; + + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/batch?packageKeys=key-1&packageKeys=key-2&withDependencies=true", exportedPackagesZip.toBuffer()); + mockAxiosPost("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/batch/variables-with-assignments", [...exportedVariables]); + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/nodes/${firstPackageNode.key}/${firstPackageNode.key}`, {...firstPackageNode, spaceId: "space-1"}); + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/nodes/${secondPackageNode.key}/${secondPackageNode.key}`, {...secondPackageNode, spaceId: "space-2"}); + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/nodes/by-package-key/${firstPackageNode.key}/variables/runtime-values?appMode=VIEWER`, []); + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/nodes/by-package-key/${secondPackageNode.key}/variables/runtime-values?appMode=VIEWER`, []); + + await new ConfigCommandService(testContext).batchExportPackages(["key-1", "key-2"], true); + + const expectedFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + expect(fs.openSync).toHaveBeenCalledWith(expectedFileName, expect.anything(), expect.anything()); + expect(mockWriteSync).toHaveBeenCalled(); + + const fileBuffer = mockWriteSync.mock.calls[0][1]; + const actualZip = new AdmZip(fileBuffer); + + const exportedVariablesFileContent: VariableManifestTransport[] = parse(actualZip.getEntry(BatchExportImportConstants.VARIABLES_FILE_NAME).getData().toString()); + expect(exportedVariablesFileContent).toHaveLength(2); + expect(exportedVariablesFileContent).toContainEqual({ + packageKey: "key-1", + version: "1.0.0", + variables: [ + { + key: "key1-var", + type: PackageManagerVariableType.DATA_MODEL, + value: "dm-id", + metadata: {} + }, + { + key: "key-1-connection", + type: PackageManagerVariableType.CONNECTION, + value: { + appName: "celonis", + connectionId: "connection-id" + }, + metadata: { + appName: "celonis" + } + } + ] + }); + expect(exportedVariablesFileContent).toContainEqual({ + packageKey: "key-2", + version: "1.0.0", + variables: [ + { + key: "key2-var", + type: PackageManagerVariableType.DATA_MODEL, + value: "dm-id", + metadata: {} + }, + { + key: "key-2-connection", + type: PackageManagerVariableType.CONNECTION, + value: "connection-id", + metadata: { + appName: "nameOfApp" + } + } + ] + }); + + const variableExportRequest = parse(mockedPostRequestBodyByUrl.get("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/batch/variables-with-assignments")); + expect(variableExportRequest).toBeTruthy(); + expect(variableExportRequest).toHaveLength(3); + expect(variableExportRequest).toContainEqual({ + packageKey: "key-1", + version: "1.0.0" + }); + expect(variableExportRequest).toContainEqual({ + packageKey: "key-2", + version: "1.0.0" + }); + expect(variableExportRequest).toContainEqual({ + packageKey: "key-2", + version: "1.1.1" + }); + }) + + it("Should remove SCENARIO asset files of packages", async () => { + const manifest: PackageManifestTransport[] = []; + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("key-1", BatchExportImportConstants.STUDIO)); + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("key-2", BatchExportImportConstants.STUDIO)); + + const firstPackageNode = ConfigUtils.buildPackageNode("key-1", {}); + const firstPackageScenarioChild = ConfigUtils.buildChildNode("child-1-scenario", firstPackageNode.key, "SCENARIO"); + const firstPackageTestChild = ConfigUtils.buildChildNode("child-2", firstPackageNode.key, "TEST"); + const firstPackageZip = ConfigUtils.buildExportPackageZip(firstPackageNode, [firstPackageScenarioChild, firstPackageTestChild], "1.0.0"); + + const secondPackageNode = ConfigUtils.buildPackageNode("key-2", {}); + const secondPackageScenarioChild = ConfigUtils.buildChildNode("child-3-scenario", secondPackageNode.key, "SCENARIO"); + const secondPackageTestChild = ConfigUtils.buildChildNode("child-4", secondPackageNode.key, "TEST"); + const secondPackageZip = ConfigUtils.buildExportPackageZip(secondPackageNode, [secondPackageScenarioChild, secondPackageTestChild], "1.0.0"); + + const exportedPackagesZip = ConfigUtils.buildBatchExportZip(manifest, [firstPackageZip, secondPackageZip]); + + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/batch?packageKeys=key-1&packageKeys=key-2&withDependencies=true", exportedPackagesZip.toBuffer()); + mockAxiosPost("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/batch/variables-with-assignments", []); + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/nodes/${firstPackageNode.key}/${firstPackageNode.key}`, {...firstPackageNode, spaceId: "space-1"}); + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/nodes/${secondPackageNode.key}/${secondPackageNode.key}`, {...secondPackageNode, spaceId: "space-2"}); + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/nodes/by-package-key/${firstPackageNode.key}/variables/runtime-values?appMode=VIEWER`, []); + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/nodes/by-package-key/${secondPackageNode.key}/variables/runtime-values?appMode=VIEWER`, []); + + await new ConfigCommandService(testContext).batchExportPackages(["key-1", "key-2"], true); + + const expectedFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + expect(fs.openSync).toHaveBeenCalledWith(expectedFileName, expect.anything(), expect.anything()); + expect(mockWriteSync).toHaveBeenCalled(); + + const fileBuffer = mockWriteSync.mock.calls[0][1]; + const actualZip = new AdmZip(fileBuffer); + + const firstPackageExportedZip = new AdmZip(actualZip.getEntry("key-1_1.0.0.zip").getData()); + expect(firstPackageExportedZip.getEntry("nodes/child-1-scenario.json")).toBeNull(); + expect(firstPackageExportedZip.getEntry("nodes/child-2.json").getData().toString()).toEqual(stringify(firstPackageTestChild)); + + const secondPackageExportedZip = new AdmZip(actualZip.getEntry("key-2_1.0.0.zip").getData()); + expect(secondPackageExportedZip.getEntry("nodes/child-3-scenario.json")).toBeNull(); + expect(secondPackageExportedZip.getEntry("nodes/child-4.json").getData().toString()).toEqual(stringify(secondPackageTestChild)); + }) + + it("Should add appName to metadata for CONNECTION variables of package.json files", async () => { + const manifest: PackageManifestTransport[] = []; + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("key-1", BatchExportImportConstants.STUDIO)); + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("key-2", BatchExportImportConstants.STUDIO)); + + const firstPackageVariableDefinition: VariableDefinition[] = [ + { + key: "key1-var", + type: PackageManagerVariableType.DATA_MODEL, + runtime: false + }, + { + key: "key-1-connection", + type: PackageManagerVariableType.CONNECTION, + runtime: false + } + ]; + + const firstPackageNode = ConfigUtils.buildPackageNode("key-1", {variables: firstPackageVariableDefinition}); + const firstPackageZip = ConfigUtils.buildExportPackageZip(firstPackageNode, [], "1.0.0"); + + const secondPackageVariableDefinition: VariableDefinition[] = [ + { + key: "key2-var", + type: PackageManagerVariableType.CONNECTION, + runtime: false, + metadata: { + appName: "celonis" + } + }, + { + key: "key-2-connection", + type: PackageManagerVariableType.CONNECTION, + runtime: false + } + ]; + + const secondPackageNode = ConfigUtils.buildPackageNode("key-2", {variables: secondPackageVariableDefinition}); + const secondPackageZip = ConfigUtils.buildExportPackageZip(secondPackageNode, [], "1.0.0"); + + const exportedPackagesZip = ConfigUtils.buildBatchExportZip(manifest, [firstPackageZip, secondPackageZip]); + + const exportedVariables: VariableManifestTransport[] = [ + { + packageKey: "key-1", + version: "1.0.0", + variables: [ + { + key: "key1-var", + type: PackageManagerVariableType.DATA_MODEL, + value: "dm-id" as unknown as object, + metadata: {} + }, + { + key: "key-1-connection", + type: PackageManagerVariableType.CONNECTION, + value: { + appName: "celonis", + connectionId: "connection-id" + } as unknown as object, + metadata: null + } + ] + }, + { + packageKey: "key-2", + version: "1.0.0", + variables: [ + { + key: "key2-var", + type: PackageManagerVariableType.CONNECTION, + value: { + appName: "celonis", + connectionId: "connection-id" + } as unknown as object, + metadata: { + appName: "celonis" + } + }, + { + key: "key-2-connection", + type: PackageManagerVariableType.CONNECTION, + value: "connection-id", + metadata: { + appName: "nameOfApp" + } + } + ] + }, + ]; + + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/batch?packageKeys=key-1&packageKeys=key-2&withDependencies=true", exportedPackagesZip.toBuffer()); + mockAxiosPost("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/batch/variables-with-assignments", exportedVariables); + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/nodes/${firstPackageNode.key}/${firstPackageNode.key}`, {...firstPackageNode, spaceId: "space-1"}); + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/nodes/${secondPackageNode.key}/${secondPackageNode.key}`, {...secondPackageNode, spaceId: "space-2"}); + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/nodes/by-package-key/${firstPackageNode.key}/variables/runtime-values?appMode=VIEWER`, []); + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/nodes/by-package-key/${secondPackageNode.key}/variables/runtime-values?appMode=VIEWER`, []); + + await new ConfigCommandService(testContext).batchExportPackages(["key-1", "key-2"], true); + + const expectedFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + expect(fs.openSync).toHaveBeenCalledWith(expectedFileName, expect.anything(), expect.anything()); + expect(mockWriteSync).toHaveBeenCalled(); + + const fileBuffer = mockWriteSync.mock.calls[0][1]; + const actualZip = new AdmZip(fileBuffer); + + const firstPackageExportedZip = new AdmZip(actualZip.getEntry("key-1_1.0.0.zip").getData()); + const firstPackageExportedNode: NodeExportTransport = parse(firstPackageExportedZip.getEntry("package.json").getData().toString()); + expect(firstPackageExportedNode).toBeTruthy(); + const firstPackageContent: NodeConfiguration = firstPackageExportedNode.configuration; + expect(firstPackageContent.variables).toHaveLength(2); + expect(firstPackageContent.variables).toEqual([ + { + ...firstPackageVariableDefinition[0], + }, + { + ...firstPackageVariableDefinition[1], + metadata: { + appName: "celonis" + } + } + ]); + + const secondPackageExportedZip = new AdmZip(actualZip.getEntry("key-2_1.0.0.zip").getData()); + const secondPackageExportedNode: NodeExportTransport = parse(secondPackageExportedZip.getEntry("package.json").getData().toString()); + expect(secondPackageExportedNode).toBeTruthy(); + const secondPackageContent: NodeConfiguration = secondPackageExportedNode.configuration; + expect(secondPackageContent.variables).toHaveLength(2); + expect(secondPackageContent.variables).toEqual([{ + ...secondPackageVariableDefinition[0], + }, + { + ...secondPackageVariableDefinition[1], + metadata: { + appName: "nameOfApp" + } + } + ]); + }) + + it("Should export with SCENARIO nodes removed and CONNECTION variables fixed for package key with multiple underscores", async () => { + const manifest: PackageManifestTransport[] = []; + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("key_with_underscores_1", BatchExportImportConstants.STUDIO)); + + const firstPackageVariableDefinition: VariableDefinition[] = [ + { + key: "key1-var", + type: PackageManagerVariableType.DATA_MODEL, + runtime: false + }, + { + key: "key-1-connection", + type: PackageManagerVariableType.CONNECTION, + runtime: false + }, + { + key: "key-1-another-connection", + type: PackageManagerVariableType.CONNECTION, + runtime: false + } + ]; + + const firstPackageNode = ConfigUtils.buildPackageNode("key_with_underscores_1", {variables: firstPackageVariableDefinition}); + const firstPackageScenarioChild = ConfigUtils.buildChildNode("child-1-scenario", firstPackageNode.key, "SCENARIO"); + const firstPackageZip = ConfigUtils.buildExportPackageZip(firstPackageNode, [firstPackageScenarioChild], "1.0.0"); + + const exportedPackagesZip = ConfigUtils.buildBatchExportZip(manifest, [firstPackageZip]); + + const exportedVariables: VariableManifestTransport[] = [ + { + packageKey: "key_with_underscores_1", + version: "1.0.0", + variables: [ + { + key: "key1-var", + type: PackageManagerVariableType.DATA_MODEL, + value: "dm-id" as unknown as object, + metadata: {} + }, + { + key: "key-1-connection", + type: PackageManagerVariableType.CONNECTION, + value: { + appName: "celonis", + connectionId: "connection-id" + } as unknown as object, + metadata: null + }, + { + key: "key-1-another-connection", + type: PackageManagerVariableType.CONNECTION, + value: "connection-id", + metadata: { + appName: "nameOfApp" + } + } + ] + } + ]; + + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/batch?packageKeys=key_with_underscores_1&withDependencies=true", exportedPackagesZip.toBuffer()); + mockAxiosPost("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/batch/variables-with-assignments", exportedVariables); + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/nodes/${firstPackageNode.key}/${firstPackageNode.key}`, {...firstPackageNode, spaceId: "space-1"}); + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/nodes/by-package-key/${firstPackageNode.key}/variables/runtime-values?appMode=VIEWER`, []); + + await new ConfigCommandService(testContext).batchExportPackages(["key_with_underscores_1"], true); + + const expectedFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + expect(fs.openSync).toHaveBeenCalledWith(expectedFileName, expect.anything(), expect.anything()); + expect(mockWriteSync).toHaveBeenCalled(); + + const fileBuffer = mockWriteSync.mock.calls[0][1]; + const actualZip = new AdmZip(fileBuffer); + + const firstPackageExportedZip = new AdmZip(actualZip.getEntry("key_with_underscores_1_1.0.0.zip").getData()); + const firstPackageExportedNode: NodeExportTransport = parse(firstPackageExportedZip.getEntry("package.json").getData().toString()); + expect(firstPackageExportedNode).toBeTruthy(); + const firstPackageContent: NodeConfiguration = firstPackageExportedNode.configuration; + expect(firstPackageContent.variables).toHaveLength(3); + expect(firstPackageContent.variables).toEqual([ + { + ...firstPackageVariableDefinition[0], + }, + { + ...firstPackageVariableDefinition[1], + metadata: { + appName: "celonis" + } + }, { + ...firstPackageVariableDefinition[2], + metadata: { + appName: "nameOfApp" + } + } + ]); + + expect(firstPackageExportedZip.getEntry("nodes/child-1-scenario.json")).toBeNull(); + }) + + it("Should export by packageKeys without dependencies", async () => { + const manifest: PackageManifestTransport[] = []; + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("key-1", "TEST")); + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("key-2", "TEST")); + const exportedPackagesZip = ConfigUtils.buildBatchExportZip(manifest, []); + + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/batch?packageKeys=key-1&packageKeys=key-2&packageKeys=key-3&withDependencies=false", exportedPackagesZip.toBuffer()); + mockAxiosPost("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/batch/variables-with-assignments", []); + + await new ConfigCommandService(testContext).batchExportPackages(["key-1", "key-2", "key-3"], false); + + const expectedFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + expect(fs.openSync).toHaveBeenCalledWith(expectedFileName, expect.anything(), expect.anything()); + expect(mockWriteSync).toHaveBeenCalled(); + }) +}) \ No newline at end of file diff --git a/tests/commands/configuration-management/config-import.spec.ts b/tests/commands/configuration-management/config-import.spec.ts new file mode 100644 index 00000000..bb0d0220 --- /dev/null +++ b/tests/commands/configuration-management/config-import.spec.ts @@ -0,0 +1,322 @@ +import * as path from "path"; +import { mockCreateReadStream, mockExistsSync, mockReadFileSync } from "../../utls/fs-mock-utils"; +import { + PackageManifestTransport, PostPackageImportData, StudioPackageManifest, +} from "../../../src/commands/configuration-management/interfaces/package-export.interfaces"; +import { + mockAxiosGet, + mockAxiosPost, + mockAxiosPut, + mockedAxiosInstance, + mockedPostRequestBodyByUrl, +} from "../../utls/http-requests-mock"; +import { ConfigCommandService } from "../../../src/commands/configuration-management/config-command.service"; +import { testContext } from "../../utls/test-context"; +import { loggingTestTransport, mockWriteFileSync } from "../../jest.setup"; +import { SpaceTransport } from "../../../src/commands/studio/interfaces/space.interface"; +import { + ContentNodeTransport, + PackageManagerVariableType, VariablesAssignments, +} from "../../../src/commands/studio/interfaces/package-manager.interfaces"; +import { + BatchExportImportConstants +} from "../../../src/commands/configuration-management/interfaces/batch-export-import.constants"; +import { ConfigUtils } from "../../utls/config-utils"; +import { stringify } from "../../../src/core/utils/json"; + +describe("Config import", () => { + + const LOG_MESSAGE: string = "Config import report file: "; + + beforeEach(() => { + mockExistsSync(); + }) + + it.each([ + true, + false + ]) ("Should batch import package configs with overwrite %p", async (overwrite: boolean) => { + const manifest: PackageManifestTransport[] = []; + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("key-1", "TEST")); + const exportedPackagesZip = ConfigUtils.buildBatchExportZip(manifest, []); + + mockReadFileSync(exportedPackagesZip.toBuffer()); + mockCreateReadStream(exportedPackagesZip.toBuffer()); + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/packages", []); + + const importResponse: PostPackageImportData[] = [{ + packageKey: "key-1", + importedVersions: [{ + oldVersion: "1.0.2", + newVersion: "1.0.0" + }] + }]; + + mockAxiosPost("https://myTeam.celonis.cloud/package-manager/api/core/packages/import/batch", importResponse); + + await new ConfigCommandService(testContext).batchImportPackages("./export_file.zip", overwrite); + + const expectedFileName = loggingTestTransport.logMessages[0].message.split(LOG_MESSAGE)[1]; + expect(mockWriteFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), expectedFileName), JSON.stringify(importResponse), {encoding: "utf-8"}); + }) + + it("Should batch import configs & map space ID as specified in manifest file for Studio Packages", async () => { + const manifest: PackageManifestTransport[] = []; + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("key-2", "STUDIO")); + + const studioManifest: StudioPackageManifest[] = []; + studioManifest.push(ConfigUtils.buildStudioManifestForKeyWithSpace("key-2", "spaceName", "space-id")); + + const firstPackageNode = ConfigUtils.buildPackageNode("key-2", {variables: []}); + const firstPackageZip = ConfigUtils.buildExportPackageZip(firstPackageNode, [], "1.0.0"); + const exportedPackagesZip = ConfigUtils.buildBatchExportZipWithStudioManifest(manifest, studioManifest,[firstPackageZip]); + + const space: SpaceTransport = { + id: "space-id", + name: "space", + iconReference: "earth" + }; + + const otherSpace: SpaceTransport = { + id: "spaceId", + name: "spaceName", + iconReference: "earth" + }; + + mockReadFileSync(exportedPackagesZip.toBuffer()); + mockCreateReadStream(exportedPackagesZip.toBuffer()); + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/packages", []); + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/spaces", [space, otherSpace]); + + const importResponse: PostPackageImportData[] = [{ + packageKey: "key-1", + importedVersions: [{ + oldVersion: "1.0.2", + newVersion: "1.0.0" + }] + }]; + + mockAxiosPost("https://myTeam.celonis.cloud/package-manager/api/core/packages/import/batch", importResponse); + + await new ConfigCommandService(testContext).batchImportPackages("./export_file.zip", true); + + const expectedFileName = loggingTestTransport.logMessages[0].message.split(LOG_MESSAGE)[1]; + expect(mockWriteFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), expectedFileName), JSON.stringify(importResponse), {encoding: "utf-8"}); + }) + + it("Should fail to map space ID as the space id specified in manifest file cannot be found", async () => { + const manifest: PackageManifestTransport[] = []; + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("key-2", "STUDIO")); + + const studioManifest: StudioPackageManifest[] = []; + studioManifest.push(ConfigUtils.buildStudioManifestForKeyWithSpace("key-2", "spaceName", "space")); + + const firstPackageNode = ConfigUtils.buildPackageNode("key-2", {variables: []}); + const firstPackageZip = ConfigUtils.buildExportPackageZip(firstPackageNode, [], "1.0.0"); + const exportedPackagesZip = ConfigUtils.buildBatchExportZipWithStudioManifest(manifest, studioManifest,[firstPackageZip]); + + const space: SpaceTransport = { + id: "space-id", + name: "space", + iconReference: "earth" + }; + + mockReadFileSync(exportedPackagesZip.toBuffer()); + mockCreateReadStream(exportedPackagesZip.toBuffer()); + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/packages", []); + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/spaces", [space]); + + await expect( + new ConfigCommandService(testContext).batchImportPackages("./export_file.zip", true) + ).rejects.toThrow("Provided space ID does not exist."); + }) + + it("Should batch import configs & map space ID as specified in manifest file for Studio Packages & move to space for existing packages.", async () => { + const manifest: PackageManifestTransport[] = []; + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("key-2", "STUDIO")); + + const studioManifest: StudioPackageManifest[] = []; + studioManifest.push(ConfigUtils.buildStudioManifestForKeyWithSpace("key-2", "space", "spaceId")); + + const firstPackageNode = ConfigUtils.buildPackageNode("key-2", {variables: []}); + const firstPackageZip = ConfigUtils.buildExportPackageZip(firstPackageNode, [], "1.0.0"); + const exportedPackagesZip = ConfigUtils.buildBatchExportZipWithStudioManifest(manifest, studioManifest,[firstPackageZip]); + + + const existingNode: Partial = { + id: "node-id", + key: "key-2" + } + + const space: SpaceTransport = { + id: "spaceId", + name: "space", + iconReference: "earth" + }; + + mockReadFileSync(exportedPackagesZip.toBuffer()); + mockCreateReadStream(exportedPackagesZip.toBuffer()); + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/packages", [existingNode]); + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/spaces", [space]); + mockAxiosPut("https://myTeam.celonis.cloud/package-manager/api/packages/node-id/move/spaceId", {}); + + const importResponse: PostPackageImportData[] = [{ + packageKey: "key-1", + importedVersions: [{ + oldVersion: "1.0.2", + newVersion: "1.0.0" + }] + }]; + + mockAxiosPost("https://myTeam.celonis.cloud/package-manager/api/core/packages/import/batch", importResponse); + + await new ConfigCommandService(testContext).batchImportPackages("./export_file.zip", true); + const expectedFileName = loggingTestTransport.logMessages[0].message.split(LOG_MESSAGE)[1]; + expect(mockWriteFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), expectedFileName), JSON.stringify(importResponse), {encoding: "utf-8"}); + expect(mockedAxiosInstance.put).toHaveBeenCalledWith("https://myTeam.celonis.cloud/package-manager/api/packages/node-id/move/spaceId", expect.anything(), expect.anything()); + }) + + it("Should batch import configs & map space ID by finding space with name as specified in manifest file", async () => { + const manifest: PackageManifestTransport[] = []; + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("key-2", "STUDIO")); + + const studioManifest: StudioPackageManifest[] = []; + studioManifest.push(ConfigUtils.buildStudioManifestForKeyWithSpace("key-2", "spaceName", null)); + + const firstPackageNode = ConfigUtils.buildPackageNode("key-2", {variables: []}); + const firstPackageZip = ConfigUtils.buildExportPackageZip(firstPackageNode, [], "1.0.0"); + const exportedPackagesZip = ConfigUtils.buildBatchExportZipWithStudioManifest(manifest, studioManifest,[firstPackageZip]); + + const space: SpaceTransport = { + id: "spaceId", + name: "spaceName", + iconReference: "earth" + }; + mockReadFileSync(exportedPackagesZip.toBuffer()); + mockCreateReadStream(exportedPackagesZip.toBuffer()); + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/packages", []); + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/spaces", [space]); + + const importResponse: PostPackageImportData[] = [{ + packageKey: "key-1", + importedVersions: [{ + oldVersion: "1.0.2", + newVersion: "1.0.0" + }] + }]; + + mockAxiosPost("https://myTeam.celonis.cloud/package-manager/api/core/packages/import/batch", importResponse); + + await new ConfigCommandService(testContext).batchImportPackages("./export_file.zip", true); + + const expectedFileName = loggingTestTransport.logMessages[0].message.split(LOG_MESSAGE)[1]; + expect(mockWriteFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), expectedFileName), JSON.stringify(importResponse), {encoding: "utf-8"}); + expect(mockedAxiosInstance.put).not.toHaveBeenCalledWith("https://myTeam.celonis.cloud/package-manager/api/spaces", expect.anything(), expect.anything()); + }) + + it("Should batch import configs & create new space", async () => { + const manifest: PackageManifestTransport[] = []; + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("key-2", "STUDIO")); + + const studioManifest: StudioPackageManifest[] = []; + studioManifest.push(ConfigUtils.buildStudioManifestForKeyWithSpace("key-2", "otherName", null)); + + const firstPackageNode = ConfigUtils.buildPackageNode("key-2", {variables: []}); + const firstPackageZip = ConfigUtils.buildExportPackageZip(firstPackageNode, [], "1.0.0"); + const exportedPackagesZip = ConfigUtils.buildBatchExportZipWithStudioManifest(manifest, studioManifest,[firstPackageZip]); + + const space: SpaceTransport = { + id: "space-id", + name: "space-name", + iconReference: "earth" + }; + + const newSpace: SpaceTransport = { + id: "otherId", + name: "otherName", + iconReference: "earth" + }; + + mockReadFileSync(exportedPackagesZip.toBuffer()); + mockCreateReadStream(exportedPackagesZip.toBuffer()); + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/packages", []); + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/spaces", [space]); + mockAxiosPost("https://myTeam.celonis.cloud/package-manager/api/spaces", [newSpace]); + + + const importResponse: PostPackageImportData[] = [{ + packageKey: "key-1", + importedVersions: [{ + oldVersion: "1.0.2", + newVersion: "1.0.0" + }] + }]; + + mockAxiosPost("https://myTeam.celonis.cloud/package-manager/api/core/packages/import/batch", importResponse); + + await new ConfigCommandService(testContext).batchImportPackages("./export_file.zip", true); + + const expectedFileName = loggingTestTransport.logMessages[0].message.split(LOG_MESSAGE)[1]; + expect(mockWriteFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), expectedFileName), JSON.stringify(importResponse), {encoding: "utf-8"}); + expect(mockedAxiosInstance.put).not.toHaveBeenCalledWith("https://myTeam.celonis.cloud/package-manager/api/spaces", expect.anything(), expect.anything()); + }) + + it("Should assign studio runtime variable values after import", async () => { + const manifest: PackageManifestTransport[] = []; + manifest.push(ConfigUtils.buildManifestForKeyAndFlavor("key-1", "STUDIO")); + const exportedPackagesZip = ConfigUtils.buildBatchExportZip(manifest, []); + const variableAssignment: VariablesAssignments = { + key: "variable-1", + type: PackageManagerVariableType.PLAIN_TEXT, + value: "some-value" as unknown as object + } + const studioManifest: StudioPackageManifest[] = [{ + packageKey: "key-1", + space: { + id: "space-id", + name: "space", + iconReference: "earth" + }, + runtimeVariableAssignments: [variableAssignment] + }]; + exportedPackagesZip.addFile(BatchExportImportConstants.STUDIO_FILE_NAME, Buffer.from(stringify(studioManifest))); + + mockReadFileSync(exportedPackagesZip.toBuffer()); + mockCreateReadStream(exportedPackagesZip.toBuffer()); + + const importResponse: PostPackageImportData[] = [{ + packageKey: "key-1", + importedVersions: [{ + oldVersion: "1.0.2", + newVersion: "1.0.0" + }] + }]; + + const node: Partial = { + id: "node-id", + key: "key-1" + } + + const space: SpaceTransport = { + id: "space-id", + name: "space", + iconReference: "earth" + }; + + const assignVariablesUrl = "https://myTeam.celonis.cloud/package-manager/api/nodes/by-package-key/key-1/variables/values"; + + mockAxiosPost("https://myTeam.celonis.cloud/package-manager/api/core/packages/import/batch", importResponse); + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/nodes/key-1/key-1", node); + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/spaces", [space]); + mockAxiosPut("https://myTeam.celonis.cloud/package-manager/api/packages/node-id/move/space-id", {}); + mockAxiosPost(assignVariablesUrl, {}); + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/packages", [node]); + + await new ConfigCommandService(testContext).batchImportPackages("./export_file.zip", true); + + const expectedFileName = loggingTestTransport.logMessages[0].message.split(LOG_MESSAGE)[1]; + expect(mockWriteFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), expectedFileName), JSON.stringify(importResponse), {encoding: "utf-8"}); + + expect(mockedPostRequestBodyByUrl.get(assignVariablesUrl)).toEqual(JSON.stringify([variableAssignment])); + }) +}) diff --git a/tests/commands/configuration-management/config-list-variables.spec.ts b/tests/commands/configuration-management/config-list-variables.spec.ts new file mode 100644 index 00000000..9c028a92 --- /dev/null +++ b/tests/commands/configuration-management/config-list-variables.spec.ts @@ -0,0 +1,177 @@ +import * as path from "path"; +import * as fs from "fs"; +import { parse } from "../../../src/core/utils/json"; +import { + PackageKeyAndVersionPair, + VariableManifestTransport, +} from "../../../src/commands/configuration-management/interfaces/package-export.interfaces"; +import { PackageManagerVariableType } from "../../../src/commands/studio/interfaces/package-manager.interfaces"; +import { mockAxiosPost, mockedPostRequestBodyByUrl } from "../../utls/http-requests-mock"; +import { ConfigCommandService } from "../../../src/commands/configuration-management/config-command.service"; +import { testContext } from "../../utls/test-context"; +import { loggingTestTransport, mockWriteFileSync } from "../../jest.setup"; +import { FileService } from "../../../src/core/utils/file-service"; + +describe("Config listVariables", () => { + + const firstManifest: VariableManifestTransport = { + packageKey: "key-1", + version: "1.0.0", + variables: [ + { + key: "key1-var", + type: PackageManagerVariableType.DATA_MODEL, + value: "dm-id" as unknown as object, + metadata: {} + }, + { + key: "key-1-connection", + type: PackageManagerVariableType.CONNECTION, + value: { + appName: "celonis", + connectionId: "connection-id" + } as unknown as object, + metadata: null + } + ] + }; + + const secondManifest: VariableManifestTransport = { + packageKey: "key-2", + version: "1.0.0", + variables: [ + { + key: "key2-var", + type: PackageManagerVariableType.CONNECTION, + value: { + appName: "celonis", + connectionId: "connection-id" + } as unknown as object, + metadata: { + appName: "celonis" + } + } + ] + }; + + const thirdManifest: VariableManifestTransport = { + packageKey: "key-3", + version: "1.0.0", + variables: [ + { + key: "key2-var", + type: PackageManagerVariableType.CONNECTION, + value: "connection-id", + metadata: { + appName: "celonis" + } + } + ] + } + + const fixedVariableManifests: VariableManifestTransport[] = [ + { + ...firstManifest, + variables: [ + { + ...firstManifest.variables[0] + }, + { + ...firstManifest.variables[1], + metadata: { + appName: "celonis" + } + } + ] + }, + { + ...secondManifest + }, + { + ...thirdManifest + } + ]; + + const packageKeyAndVersionPairs: PackageKeyAndVersionPair[] = [ + { + packageKey: "key-1", + version: "1.0.0" + }, + { + packageKey: "key-2", + version: "1.0.0" + }, + { + packageKey: "key-3", + version: "1.0.0" + } + ]; + + beforeEach(() => { + mockAxiosPost("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/batch/variables-with-assignments", [{...firstManifest}, {...secondManifest}, {...thirdManifest}]); + }) + + it("Should list fixed variables for non-json response", async () => { + await new ConfigCommandService(testContext).listVariables(false, ["key-1:1.0.0", "key-2:1.0.0", "key-3:1.0.0"], null); + + expect(loggingTestTransport.logMessages.length).toBe(3); + expect(loggingTestTransport.logMessages[0].message).toContain(JSON.stringify(fixedVariableManifests[0])); + expect(loggingTestTransport.logMessages[1].message).toContain(JSON.stringify(fixedVariableManifests[1])); + expect(loggingTestTransport.logMessages[2].message).toContain(JSON.stringify(fixedVariableManifests[2])); + + const variableExportRequest = parse(mockedPostRequestBodyByUrl.get("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/batch/variables-with-assignments")); + expect(variableExportRequest).toEqual(packageKeyAndVersionPairs); + }) + + it("Should export fixed variables for json response", async () => { + await new ConfigCommandService(testContext).listVariables(true, ["key-1:1.0.0", "key-2:1.0.0", "key-3:1.0.0"], null); + + expect(loggingTestTransport.logMessages.length).toBe(1); + expect(loggingTestTransport.logMessages[0].message).toContain(FileService.fileDownloadedMessage); + + const expectedFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + expect(mockWriteFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), expectedFileName), JSON.stringify(fixedVariableManifests), {encoding: "utf-8"}); + + const variableExportRequest = parse(mockedPostRequestBodyByUrl.get("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/batch/variables-with-assignments")); + expect(variableExportRequest).toEqual(packageKeyAndVersionPairs); + }) + + it("Should list fixed variables for non-json response and keysByVersion file mapping", async () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(packageKeyAndVersionPairs)); + + await new ConfigCommandService(testContext).listVariables(false, [], "key_version_mapping.json"); + + expect(loggingTestTransport.logMessages.length).toBe(3); + expect(loggingTestTransport.logMessages[0].message).toContain(JSON.stringify(fixedVariableManifests[0])); + expect(loggingTestTransport.logMessages[1].message).toContain(JSON.stringify(fixedVariableManifests[1])); + expect(loggingTestTransport.logMessages[2].message).toContain(JSON.stringify(fixedVariableManifests[2])); + + const variableExportRequest = parse(mockedPostRequestBodyByUrl.get("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/batch/variables-with-assignments")); + expect(variableExportRequest).toEqual(packageKeyAndVersionPairs); + }) + + it("Should export fixed variables for json response and keysByVersion file mapping", async () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(packageKeyAndVersionPairs)); + + await new ConfigCommandService(testContext).listVariables(true, [], "key_version_mapping.json"); + + expect(loggingTestTransport.logMessages.length).toBe(1); + expect(loggingTestTransport.logMessages[0].message).toContain(FileService.fileDownloadedMessage); + + const expectedFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + expect(mockWriteFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), expectedFileName), JSON.stringify(fixedVariableManifests), {encoding: "utf-8"}); + + const variableExportRequest = parse(mockedPostRequestBodyByUrl.get("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/batch/variables-with-assignments")); + expect(variableExportRequest).toEqual(packageKeyAndVersionPairs); + }) + + it("Should throw error if no mapping and no file path is provided", async () => { + try { + await new ConfigCommandService(testContext).listVariables(true, [], ""); + } catch (e) { + expect(e.message).toEqual("Please provide keysByVersion mappings or file path!"); + } + }) +}) \ No newline at end of file diff --git a/tests/commands/configuration-management/config-list.spec.ts b/tests/commands/configuration-management/config-list.spec.ts new file mode 100644 index 00000000..b1c6af02 --- /dev/null +++ b/tests/commands/configuration-management/config-list.spec.ts @@ -0,0 +1,218 @@ +import * as path from "path"; +import { stringify } from "../../../src/core/utils/json"; +import { PacmanApiUtils } from "../../utls/pacman-api.utils"; +import { mockAxiosGet } from "../../utls/http-requests-mock"; +import { ConfigCommandService } from "../../../src/commands/configuration-management/config-command.service"; +import { testContext } from "../../utls/test-context"; +import { loggingTestTransport, mockWriteFileSync } from "../../jest.setup"; +import { + ContentNodeTransport, + PackageWithVariableAssignments, StudioComputeNodeDescriptor, +} from "../../../src/commands/studio/interfaces/package-manager.interfaces"; +import { FileService } from "../../../src/core/utils/file-service"; +import { + PackageExportTransport +} from "../../../src/commands/configuration-management/interfaces/package-export.interfaces"; + +describe("Config list", () => { + + it.each([ + "", + "STUDIO,TEST" + ])( + "Should list all packages by key for non-json response with flavors: %p", + async (flavors: string) => { + const firstPackage = PacmanApiUtils.buildPackageExportTransport("key-1", "name-1"); + const secondPackage = PacmanApiUtils.buildPackageExportTransport("key-2", "name-2"); + + const flavorsArray = flavors !== "" ? flavors.split(",") : []; + + const urlParams = new URLSearchParams(); + urlParams.set("withDependencies", "false"); + flavorsArray.forEach(flavor => urlParams.append("flavors", flavor)); + + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/list?" + urlParams.toString(), [firstPackage, secondPackage]); + + await new ConfigCommandService(testContext).listActivePackages(false, flavorsArray, false, [], null, null); + + expect(loggingTestTransport.logMessages.length).toBe(2); + expect(loggingTestTransport.logMessages[0].message).toContain(`${firstPackage.name} - Key: "${firstPackage.key}"`); + expect(loggingTestTransport.logMessages[1].message).toContain(`${secondPackage.name} - Key: "${secondPackage.key}"`); + } + ) + + it("Should export all packages for json response with spaceId set for studio packages", async () => { + const firstPackage = PacmanApiUtils.buildPackageExportTransport("key-1", "name-1"); + const secondPackage = PacmanApiUtils.buildPackageExportTransport("key-2", "name-2"); + + const studioPackage: ContentNodeTransport = PacmanApiUtils.buildContentNodeTransport("key-1", "spaceId-1"); + + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/list?withDependencies=false", [{...firstPackage}, {...secondPackage}]); + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/packages/with-variable-assignments?type=DATA_MODEL", [studioPackage]); + + await new ConfigCommandService(testContext).listActivePackages(true, [], false, [], null, null); + + const expectedFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + + expect(mockWriteFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), expectedFileName), expect.any(String), {encoding: "utf-8"}); + + const exportedTransports = JSON.parse(mockWriteFileSync.mock.calls[0][1]) as PackageExportTransport[]; + expect(exportedTransports.length).toBe(2); + + const exportedFirstPackage = exportedTransports.filter(transport => transport.key === firstPackage.key)[0]; + const exportedSecondPackage = exportedTransports.filter(transport => transport.key === secondPackage.key)[0]; + + expect(exportedSecondPackage).toEqual(secondPackage); + expect(exportedFirstPackage).toEqual({...firstPackage, spaceId: "spaceId-1"}); + }) + + it("Should export all packages with dependencies and data models set for json response and withDependencies option", async () => { + const firstPackage = PacmanApiUtils.buildPackageExportTransport("key-1", "name-1"); + const secondPackage = PacmanApiUtils.buildPackageExportTransport("key-2", "name-2"); + + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/list?withDependencies=true", [{...firstPackage}, {...secondPackage}]); + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/packages/with-variable-assignments?type=DATA_MODEL", []); + + const dataModelVariableAssignmentResponse: PackageWithVariableAssignments = { + id: "var-id", + key: firstPackage.key, + name: "var-name", + createdBy: "", + spaceId: undefined, + variableAssignments: [ + { + key: "var-key", + value: "datamodel-id" as unknown as object, + type: "DATA_MODEL" + } + ] + }; + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/packages/with-variable-assignments?type=DATA_MODEL", [dataModelVariableAssignmentResponse]); + + const dataModelDetailResponse: StudioComputeNodeDescriptor = { + name: "pool-name", + dataModelId: "datamodel-id", + poolId: "pool-id" + }; + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/compute-pools/data-models/details", [dataModelDetailResponse]); + + await new ConfigCommandService(testContext).listActivePackages(true, [], true, [], null, null); + + const expectedFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + + expect(mockWriteFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), expectedFileName), expect.any(String), {encoding: "utf-8"}); + + const exportedTransports = JSON.parse(mockWriteFileSync.mock.calls[0][1]) as PackageExportTransport[]; + expect(exportedTransports.length).toBe(2); + + const exportedFirstPackage = exportedTransports.filter(transport => transport.key === firstPackage.key)[0]; + const exportedSecondPackage = exportedTransports.filter(transport => transport.key === secondPackage.key)[0]; + + expect(exportedSecondPackage).toEqual(secondPackage); + expect(exportedFirstPackage).toEqual({...firstPackage, datamodels: [{...dataModelDetailResponse}]}); + }) + + it("Should export packagesByKeys with spaceId set for studio packages", async () => { + const firstPackage = PacmanApiUtils.buildPackageExportTransport("key-1", "name-1"); + const secondPackage = PacmanApiUtils.buildPackageExportTransport("key-2", "name-2"); + + const studioPackage: ContentNodeTransport = PacmanApiUtils.buildContentNodeTransport("key-1", "spaceId-1"); + + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/list-by-keys?packageKeys=key-1&packageKeys=key-2&withDependencies=false", [{...firstPackage}, {...secondPackage}]); + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/packages/with-variable-assignments?type=DATA_MODEL", [studioPackage]); + + await new ConfigCommandService(testContext).listActivePackages(true, [], false, [firstPackage.key, secondPackage.key], null, null); + + const expectedFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + + expect(mockWriteFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), expectedFileName), expect.any(String), {encoding: "utf-8"}); + + const exportedTransports = JSON.parse(mockWriteFileSync.mock.calls[0][1]) as PackageExportTransport[]; + expect(exportedTransports.length).toBe(2); + + const exportedFirstPackage = exportedTransports.filter(transport => transport.key === firstPackage.key)[0]; + const exportedSecondPackage = exportedTransports.filter(transport => transport.key === secondPackage.key)[0]; + + expect(exportedSecondPackage).toEqual(secondPackage); + expect(exportedFirstPackage).toEqual({...firstPackage, spaceId: "spaceId-1"}); + }) + + it("Should export packagesByKeys with dependencies and data models set for withDependencies option", async () => { + const firstPackage = PacmanApiUtils.buildPackageExportTransport("key-1", "name-1"); + const secondPackage = PacmanApiUtils.buildPackageExportTransport("key-2", "name-2"); + + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/list-by-keys?packageKeys=key-1&packageKeys=key-2&withDependencies=true", [{...firstPackage}, {...secondPackage}]); + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/packages/with-variable-assignments?type=DATA_MODEL", []); + + const dataModelVariableAssignmentResponse: PackageWithVariableAssignments = { + id: "var-id", + key: firstPackage.key, + name: "var-name", + createdBy: "", + spaceId: undefined, + variableAssignments: [ + { + key: "var-key", + value: "datamodel-id" as unknown as object, + type: "DATA_MODEL" + } + ] + }; + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/packages/with-variable-assignments?type=DATA_MODEL", [dataModelVariableAssignmentResponse]); + + const dataModelDetailResponse: StudioComputeNodeDescriptor = { + name: "pool-name", + dataModelId: "datamodel-id", + poolId: "pool-id" + }; + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/compute-pools/data-models/details", [dataModelDetailResponse]); + + await new ConfigCommandService(testContext).listActivePackages(true, [], true, [firstPackage.key, secondPackage.key], null, null); + + const expectedFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + + expect(mockWriteFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), expectedFileName), expect.any(String), {encoding: "utf-8"}); + + const exportedTransports = JSON.parse(mockWriteFileSync.mock.calls[0][1]) as PackageExportTransport[]; + expect(exportedTransports.length).toBe(2); + + const exportedFirstPackage = exportedTransports.filter(transport => transport.key === firstPackage.key)[0]; + const exportedSecondPackage = exportedTransports.filter(transport => transport.key === secondPackage.key)[0]; + + expect(exportedSecondPackage).toEqual(secondPackage); + expect(exportedFirstPackage).toEqual({...firstPackage, datamodels: [{...dataModelDetailResponse}]}); + }) + + it("Should list all packages filtered by variable value", async () => { + const firstPackage = PacmanApiUtils.buildPackageExportTransport("key-1", "name-1"); + const secondPackage = PacmanApiUtils.buildPackageExportTransport("key-2", "name-2"); + + + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/list-by-variable-value?variableValue=1", [{...firstPackage}, {...secondPackage}]); + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/packages/with-variable-assignments?type=DATA_MODEL", []); + + await new ConfigCommandService(testContext).listActivePackages(false, [], false, [], "1", null); + + expect(loggingTestTransport.logMessages.length).toBe(2); + expect(loggingTestTransport.logMessages[0].message).toContain(`${firstPackage.name} - Key: "${firstPackage.key}"`); + expect(loggingTestTransport.logMessages[1].message).toContain(`${secondPackage.name} - Key: "${secondPackage.key}"`); + }) + + it("Should export all packages for json response filtered by variable value", async () => { + const firstPackage = PacmanApiUtils.buildPackageExportTransport("key-1", "name-1"); + const secondPackage = PacmanApiUtils.buildPackageExportTransport("key-2", "name-2"); + + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/list-by-variable-value?variableValue=1", [{...firstPackage}, {...secondPackage}]); + mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/packages/with-variable-assignments?type=DATA_MODEL", []); + + await new ConfigCommandService(testContext).listActivePackages(true, [], false, [], "1", null); + + const expectedFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + + expect(mockWriteFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), expectedFileName), expect.any(String), {encoding: "utf-8"}); + + const exportedTransports = JSON.parse(mockWriteFileSync.mock.calls[0][1]) as PackageExportTransport[]; + expect(exportedTransports.length).toBe(2); + }) + +}) \ No newline at end of file diff --git a/tests/commands/configuration-management/list-assignments.spec.ts b/tests/commands/configuration-management/list-assignments.spec.ts new file mode 100644 index 00000000..1230a4f7 --- /dev/null +++ b/tests/commands/configuration-management/list-assignments.spec.ts @@ -0,0 +1,66 @@ +import * as path from "path"; +import { mockedAxiosInstance } from "../../utls/http-requests-mock"; +import { VariableCommandService } from "../../../src/commands/configuration-management/variable-command.service"; +import { testContext } from "../../utls/test-context"; +import { loggingTestTransport, mockWriteFileSync } from "../../jest.setup"; +import { FileService } from "../../../src/core/utils/file-service"; + +describe("List assignments", () => { + + it("Should list assignments for supported type and non-json response", async () => { + const mockAssignmentValues = [ + {id: "id-1"}, + {id: "id-2"} + ]; + const resp = {data: mockAssignmentValues}; + (mockedAxiosInstance.get as jest.Mock).mockResolvedValue(resp); + + await new VariableCommandService(testContext).listAssignments("DATA_MODEL", false, ""); + + expect(loggingTestTransport.logMessages.length).toBe(2); + expect(loggingTestTransport.logMessages[0].message).toContain('{"id":"id-1"}'); + expect(loggingTestTransport.logMessages[1].message).toContain('{"id":"id-2"}'); + + expect(mockedAxiosInstance.get).toHaveBeenCalledWith("https://myTeam.celonis.cloud/package-manager/api/compute-pools/pools-with-data-models", expect.anything()) + }) + + it("Should export assignments for supported type and json response", async () => { + const mockAssignmentValues = [ + {id: "id-1"}, + {id: "id-2"} + ]; + const resp = {data: mockAssignmentValues}; + (mockedAxiosInstance.get as jest.Mock).mockResolvedValue(resp); + + await new VariableCommandService(testContext).listAssignments("DATA_MODEL", true, ""); + + expect(loggingTestTransport.logMessages.length).toBe(1); + expect(loggingTestTransport.logMessages[0].message).toContain(FileService.fileDownloadedMessage); + + const expectedFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + + expect(mockWriteFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), expectedFileName), JSON.stringify(mockAssignmentValues), {encoding: "utf-8"}); + }) + + it("Should contain url params in the url", async () => { + const mockAssignmentValues = [{id: "id-1"}]; + const resp = {data: mockAssignmentValues}; + (mockedAxiosInstance.get as jest.Mock).mockResolvedValue(resp); + + await new VariableCommandService(testContext).listAssignments("CONNECTION", false, "param1=value1,param2=value2"); + + expect(mockedAxiosInstance.get).toHaveBeenCalledWith("https://myTeam.celonis.cloud/process-automation-v2/api/connections?param1=value1¶m2=value2", expect.anything()) + }) + + it("Should throw error for unsupported variable types", async () => { + const type: string = "DUMMY_UNSUPPORTED_TYPE"; + + try { + await new VariableCommandService(testContext).listAssignments(type, false, ""); + } catch (e) { + if (!(e.message === `Variable type ${type} not supported.`)) { + fail(); + } + } + }) +}) \ No newline at end of file diff --git a/tests/jest.setup.ts b/tests/jest.setup.ts new file mode 100644 index 00000000..ae3002dd --- /dev/null +++ b/tests/jest.setup.ts @@ -0,0 +1,28 @@ +// Mock the modules using Jest +import * as fs from "fs"; +import { mockAxios } from "./utls/http-requests-mock"; +import { LoggingTestTransport } from "./utls/logging-test-transport"; +import { logger } from "../src/core/utils/logger"; + +mockAxios(); +jest.mock("fs"); + +const mockWriteFileSync = jest.fn(); +(fs.writeFileSync as jest.Mock).mockImplementation(mockWriteFileSync); + +const mockWriteSync = jest.fn(); +(fs.writeSync as jest.Mock).mockImplementation(mockWriteSync); + +afterEach(() => { + jest.clearAllMocks(); +}); + +let loggingTestTransport: LoggingTestTransport; + +beforeEach(() => { + jest.clearAllMocks(); + loggingTestTransport = new LoggingTestTransport({}); + logger.add(loggingTestTransport); +}); + +export {loggingTestTransport, mockWriteFileSync, mockWriteSync}; \ No newline at end of file diff --git a/tests/mocks/package.json b/tests/mocks/package.json new file mode 100644 index 00000000..525ca75b --- /dev/null +++ b/tests/mocks/package.json @@ -0,0 +1,3 @@ +{ + "version": "mocked-version" +} \ No newline at end of file diff --git a/tests/utls/config-utils.ts b/tests/utls/config-utils.ts new file mode 100644 index 00000000..74825257 --- /dev/null +++ b/tests/utls/config-utils.ts @@ -0,0 +1,107 @@ +import AdmZip = require("adm-zip"); +import { + DependencyTransport, NodeConfiguration, + NodeExportTransport, + PackageManifestTransport, StudioPackageManifest, +} from "../../src/commands/configuration-management/interfaces/package-export.interfaces"; +import { stringify } from "../../src/core/utils/json"; +import { SpaceTransport } from "../../src/commands/studio/interfaces/space.interface"; + +export class ConfigUtils { + + public static buildBatchExportZipWithStudioManifest(manifest: PackageManifestTransport[], studioManifest: StudioPackageManifest[], packageZips: AdmZip[]): AdmZip { + + const zipExport = new AdmZip(); + zipExport.addFile("manifest.json", Buffer.from(stringify(manifest))); + zipExport.addFile("studio.json", Buffer.from(stringify(studioManifest))); + packageZips.forEach(packageZip => { + const fileName = `${packageZip.getZipComment()}.zip` + packageZip.addZipComment("") + zipExport.addFile(fileName, packageZip.toBuffer()); + }) + + return zipExport; + } + public static buildBatchExportZip(manifest: PackageManifestTransport[], packageZips: AdmZip[]): AdmZip { + + const zipExport = new AdmZip(); + zipExport.addFile("manifest.json", Buffer.from(stringify(manifest))); + packageZips.forEach(packageZip => { + const fileName = `${packageZip.getZipComment()}.zip` + packageZip.addZipComment("") + zipExport.addFile(fileName, packageZip.toBuffer()); + }) + + return zipExport; + } + + public static buildExportPackageZip(packageNode: NodeExportTransport, childNodes: NodeExportTransport[], version: string): AdmZip { + const zipExport = new AdmZip(); + + zipExport.addFile("package.json", Buffer.from(stringify(packageNode))); + zipExport.addFile("nodes/", Buffer.alloc(0)); + + childNodes.forEach(child => { + zipExport.addFile(`nodes/${child.key}.json`, Buffer.from(stringify(child))); + }); + + zipExport.addZipComment(`${packageNode.key}_${version}`); + + return zipExport; + } + + public static buildManifestForKeyAndFlavor(key: string, flavor: string, dependenciesByVersion?: Map): PackageManifestTransport { + return { + packageKey: key, + flavor: flavor, + activeVersion: "", + dependenciesByVersion: dependenciesByVersion ?? {} as Map + }; + } + + public static buildPackageNode(key: string, configuration: NodeConfiguration): NodeExportTransport { + return { + key, + parentNodeKey: key, + name: "name", + type: "PACKAGE", + exportSerializationType: "YAML", + configuration: configuration, + schemaVersion: 1, + invalidContent: false, + serializedDocument: null, + spaceId: null + }; + } + + public static buildChildNode(key: string, parentKey: string, type: string): NodeExportTransport { + return { + key, + parentNodeKey: parentKey, + name: "name", + type: type, + exportSerializationType: "YAML", + configuration: {}, + schemaVersion: 1, + invalidContent: false, + serializedDocument: null, + spaceId: null + }; + } + + public static buildStudioManifestForKeyWithSpace(key: string, spaceName: string, spaceId: string): StudioPackageManifest { + const space: Partial = { + name: spaceName + }; + + if (spaceId) { + space.id = spaceId; + } + + return { + packageKey: key, + space: space, + runtimeVariableAssignments: [] + }; + } +} diff --git a/tests/utls/fs-mock-utils.ts b/tests/utls/fs-mock-utils.ts new file mode 100644 index 00000000..2c01ceab --- /dev/null +++ b/tests/utls/fs-mock-utils.ts @@ -0,0 +1,21 @@ +import * as fs from "fs"; +import {Readable} from "stream"; + +export function mockReadFileSync(data: any): void { + (fs.readFileSync as jest.Mock).mockReturnValue(data); +} + +export function mockCreateReadStream(data: any): void { + const stream = new Readable(); + stream.push(data); + stream.push(null); + (fs.createReadStream as jest.Mock).mockReturnValue(stream); +} + +export function mockExistsSync(): void { + (fs.existsSync as jest.Mock).mockReturnValue(true); +} + +export function mockExistsSyncOnce(): void { + (fs.existsSync as jest.Mock).mockReturnValueOnce(true); +} \ No newline at end of file diff --git a/tests/utls/http-requests-mock.ts b/tests/utls/http-requests-mock.ts new file mode 100644 index 00000000..d02d1c17 --- /dev/null +++ b/tests/utls/http-requests-mock.ts @@ -0,0 +1,84 @@ +import { AxiosInstance } from "axios"; +import {Readable} from "stream"; +import { AxiosInitializer } from "../../src/core/http/axios-initializer"; + +const mockedAxiosInstance = {} as AxiosInstance; + +const mockedGetResponseByUrl = new Map(); +const mockedPostResponseByUrl = new Map(); +const mockedPostRequestBodyByUrl = new Map(); + +const mockAxios = () : void => { + AxiosInitializer.initializeAxios = jest.fn().mockReturnValue(mockedAxiosInstance); + + mockedAxiosInstance.get = jest.fn(); + mockedAxiosInstance.post = jest.fn(); + mockedAxiosInstance.put = jest.fn(); +} + +const mockAxiosGet = (url: string, responseData: any) => { + mockedGetResponseByUrl.set(url, responseData); + (mockedAxiosInstance.get as jest.Mock).mockImplementation(requestUrl => { + if (mockedGetResponseByUrl.has(requestUrl)) { + const response = { data: mockedGetResponseByUrl.get(requestUrl) }; + + if (response.data instanceof Buffer) { + const readableStream = new Readable(); + readableStream.push(response.data) + readableStream.push(null); + return Promise.resolve({ + status: 200, + data: readableStream, + }); + } else { + return Promise.resolve(response); + } + } else { + fail("API call not mocked.") + } + }); +}; + +const mockAxiosPost = (url: string, responseData: any) => { + mockedPostResponseByUrl.set(url, responseData); + + (mockedAxiosInstance.post as jest.Mock).mockImplementation((requestUrl: string, data: any) => { + if (mockedPostResponseByUrl.has(requestUrl)) { + const response = { data: mockedPostResponseByUrl.get(requestUrl) }; + mockedPostRequestBodyByUrl.set(requestUrl, data); + + return Promise.resolve(response); + } else { + fail("API call not mocked.") + } + }) +} + +const mockAxiosPut = (url: string, responseData: any) => { + mockedPostResponseByUrl.set(url, responseData); + (mockedAxiosInstance.put as jest.Mock).mockImplementation((requestUrl: string, data: any) => { + if (mockedPostResponseByUrl.has(requestUrl)) { + const response = { data: mockedPostResponseByUrl.get(requestUrl) }; + mockedPostRequestBodyByUrl.set(requestUrl, data); + + return Promise.resolve(response); + } else { + fail("API call not mocked.") + } + }) +} + +afterEach(() => { + mockedGetResponseByUrl.clear(); + mockedPostResponseByUrl.clear(); + mockedPostRequestBodyByUrl.clear(); +}) + +export { + mockedAxiosInstance, + mockAxios, + mockAxiosGet, + mockAxiosPost, + mockAxiosPut, + mockedPostRequestBodyByUrl +}; diff --git a/tests/utls/logging-test-transport.ts b/tests/utls/logging-test-transport.ts new file mode 100644 index 00000000..f3848e04 --- /dev/null +++ b/tests/utls/logging-test-transport.ts @@ -0,0 +1,19 @@ +import * as Transport from "winston-transport"; +import {LogEntry} from "winston"; + +export class LoggingTestTransport extends Transport { + public logMessages: LogEntry[] = []; + + constructor(options: any) { + super(options); + } + + public log(logEntry: LogEntry, callback: () => void): void { + if (logEntry.level.includes("debug")) { + callback(); + return; + } + this.logMessages.push(logEntry); + callback(); + } +} \ No newline at end of file diff --git a/tests/utls/pacman-api.utils.ts b/tests/utls/pacman-api.utils.ts new file mode 100644 index 00000000..42ba8a26 --- /dev/null +++ b/tests/utls/pacman-api.utils.ts @@ -0,0 +1,43 @@ +import { + PackageExportTransport +} from "../../src/commands/configuration-management/interfaces/package-export.interfaces"; +import { ContentNodeTransport } from "../../src/commands/studio/interfaces/package-manager.interfaces"; +import { SpaceTransport } from "../../src/commands/studio/interfaces/space.interface"; + +export class PacmanApiUtils { + public static buildPackageExportTransport = (key: string, name: string): PackageExportTransport => { + return { + id: "", + key, + name, + changeDate: null, + activatedDraftId: "", + workingDraftId: "", + flavor: "", + version: "", + dependencies: null, + }; + } + + public static buildContentNodeTransport = (key: string, spaceId: string): ContentNodeTransport => { + return { + id: "", + key, + name: "", + rootNodeKey: "", + workingDraftId: "", + activatedDraftId: "", + rootNodeId: "", + assetMetadataTransport: null, + spaceId + } + } + + public static buildSpaceTransport = (id: string, name: string = "space-name", iconReference: string = "icon"): SpaceTransport => { + return { + id, + name, + iconReference, + }; + } +} diff --git a/tests/utls/test-context.ts b/tests/utls/test-context.ts new file mode 100644 index 00000000..e41d6937 --- /dev/null +++ b/tests/utls/test-context.ts @@ -0,0 +1,13 @@ +import { Context } from "../../src/core/command/cli-context"; +import { HttpClient } from "../../src/core/http/http-client"; + +const testContext = new Context({}); +testContext.profile = { + name: "test", + type: "Key", + team: "https://myTeam.celonis.cloud/", + apiToken: "test-token", + authenticationType: "Bearer" +} +testContext.httpClient = new HttpClient(testContext); +export { testContext };