diff --git a/src/commands/bookmarks/bookmarks-command.service.ts b/src/commands/bookmarks/bookmarks-command.service.ts index 3c903f2..78ac052 100644 --- a/src/commands/bookmarks/bookmarks-command.service.ts +++ b/src/commands/bookmarks/bookmarks-command.service.ts @@ -1,22 +1,25 @@ import { Context } from "../../core/command/cli-context"; import { BookmarksApi } from "./bookmarks-api"; import { fileService, FileService } from "../../core/utils/file-service"; +import { CuiFileService } from "../../core/utils/cui-file-service"; import { logger } from "../../core/utils/logger"; export class BookmarksCommandService { private readonly bookmarksApi: BookmarksApi; + private readonly cuiFileService: CuiFileService; constructor(context: Context) { this.bookmarksApi = new BookmarksApi(context); + this.cuiFileService = new CuiFileService(context); } public async exportBookmarks(packageKey: string, file?: string): Promise { const exportData = await this.bookmarksApi.exportBookmarks(packageKey); const fileName = file ?? `bookmarks-${packageKey}.json`; - fileService.writeToFileWithGivenName(JSON.stringify(exportData, null, 4), fileName); - logger.info(FileService.fileDownloadedMessage + fileName); + const writtenFilename = await this.cuiFileService.writeToFileWithGivenName(JSON.stringify(exportData, null, 4), fileName); + logger.info(FileService.fileDownloadedMessage + writtenFilename); } public async importBookmarks(packageKey: string, file: string): Promise { diff --git a/src/core/http/http-shared/base.manager.ts b/src/core/http/http-shared/base.manager.ts index b0327b8..1bcdc3f 100644 --- a/src/core/http/http-shared/base.manager.ts +++ b/src/core/http/http-shared/base.manager.ts @@ -1,10 +1,7 @@ -import * as fs from "fs"; -import * as path from "path"; import { FatalError, logger } from "../../utils/logger"; import { ManagerConfig } from "./manager-config.interface"; import { HttpClient } from "../http-client"; import { Context } from "../../command/cli-context"; -import { FileConstants } from "../../utils/file.constants"; import { CuiFileService } from "../../utils/cui-file-service"; export abstract class BaseManager { @@ -18,24 +15,14 @@ export abstract class BaseManager { } public async pull(): Promise { - return new Promise((resolve, reject) => { - this.httpClient() - .get(this.getConfig().pullUrl) - .then(data => { - try { - const filename = this.writeToFile(data); - logger.info(this.fileDownloadedMessage + filename); - resolve(); - } catch (e) { - logger.error(new FatalError(e)); - reject(); - } - }) - .catch(err => { - logger.error(new FatalError(err)); - reject(); - }); - }); + try { + const data = await this.httpClient().get(this.getConfig().pullUrl); + const filename = await this.writeToFile(data); + logger.info(this.fileDownloadedMessage + filename); + } catch (err) { + logger.error(new FatalError(err)); + throw err; + } } public async pullFile(): Promise { @@ -90,23 +77,17 @@ export abstract class BaseManager { } } - protected writeToFile(data: any): string { - const filename = this.getConfig().exportFileName; - this.writeToFileWithGivenName(data, filename); - return filename; + protected async writeToFile(data: any): Promise { + return this.cuiFileService.writeToFileWithGivenName( + this.getSerializedFileContent(data), + this.getConfig().exportFileName + ); } protected async writeStreamToFile(data: Buffer): Promise { return this.cuiFileService.writeZipToFileWithGivenName(data, this.getConfig().exportFileName); } - protected writeToFileWithGivenName(data: any, filename: string): void { - fs.writeFileSync(path.resolve(process.cwd(), filename), this.getSerializedFileContent(data), { - encoding: "utf-8", - mode: FileConstants.DEFAULT_FILE_PERMISSIONS, - }); - } - protected abstract getConfig(): ManagerConfig; protected abstract getBody(): object; diff --git a/tests/commands/cui-marking-single-file-exports.spec.ts b/tests/commands/cui-marking-single-file-exports.spec.ts new file mode 100644 index 0000000..736138c --- /dev/null +++ b/tests/commands/cui-marking-single-file-exports.spec.ts @@ -0,0 +1,116 @@ +import { resolve } from "node:path"; +import { readFileSync } from "node:fs"; +import AdmZip = require("adm-zip"); +import { mockAxiosGet, mockAxiosGetWithStatus } from "../utls/http-requests-mock"; +import { testContext } from "../utls/test-context"; +import { loggingTestTransport } from "../jest.setup"; +import { FileService } from "../../src/core/utils/file-service"; +import { CuiFileService } from "../../src/core/utils/cui-file-service"; +import { parse } from "../../src/core/utils/yaml"; +import { AssetCommandService } from "../../src/commands/studio/command-service/asset-command.service"; +import { SkillCommandService } from "../../src/commands/action-flows/skill/skill-command.service"; +import { DataPoolCommandService } from "../../src/commands/data-pipeline/data-pool/data-pool-command.service"; +import { ViewBookmarksCommandService } from "../../src/commands/view/view-bookmarks-command.service"; +import { AnalysisBookmarksCommandService } from "../../src/commands/analysis/analysis-bookmarks-command.service"; +import { BookmarksCommandService } from "../../src/commands/bookmarks/bookmarks-command.service"; + +const COVER_URL = "https://myTeam.celonis.cloud/api/team/cui-settings/cui-pdf-cover"; +const PDF_BYTES = Buffer.from("%PDF-1.4 cover sheet"); + +const PROJECT_ID = "project-1"; +const SKILL_ID = "skill-1"; +const POOL_ID = "pool-1"; +const BOARD_ID = "board-1"; +const ANALYSIS_ID = "analysis-1"; +const PACKAGE_KEY = "my-package"; + +function markAsClassified(): void { + mockAxiosGetWithStatus(COVER_URL, 200, { + resolvedCuiMarking: { categories: [{ code: "PRVCY", name: "Privacy" }] }, + coverPage: { pdfContent: PDF_BYTES.toString("base64"), encoding: "base64" }, + }); +} + +function loggedFileName(): string { + const prefix = FileService.fileDownloadedMessage; + const message = loggingTestTransport.logMessages.map(entry => entry.message).find(entry => entry.includes(prefix)); + return message.split(prefix)[1]; +} + +function markedArchive(expectedName: string): AdmZip { + const filename = loggedFileName(); + expect(filename).toEqual(`${CuiFileService.CLASSIFIED_PREFIX}${expectedName}.zip`); + + const archive = new AdmZip(readFileSync(resolve(process.cwd(), filename))); + expect(archive.getEntry(CuiFileService.COVER_SHEET_FILE_NAME).getData().equals(PDF_BYTES)).toBe(true); + return archive; +} + +function markedEntry(expectedName: string, entryName: string): string { + return markedArchive(expectedName).getEntry(entryName).getData().toString(); +} + +function markedJson(expectedName: string, entryName: string): any { + return JSON.parse(markedEntry(expectedName, entryName)); +} + +describe("CUI marking of single-file exports", () => { + + beforeEach(() => { + markAsClassified(); + }); + + it("Should mark the exported asset as YAML", async () => { + const asset = { key: "asset-1", name: "My Asset", rootNodeKey: PACKAGE_KEY }; + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/nodes/asset/export/${PACKAGE_KEY}.asset-1`, asset); + + await new AssetCommandService(testContext).pullAsset(`${PACKAGE_KEY}.asset-1`); + + expect(parse(markedEntry("asset_asset-1", "asset_asset-1.yml"))).toEqual(asset); + }); + + it("Should mark the exported skill", async () => { + const skill = { id: SKILL_ID, name: "My Skill" }; + mockAxiosGet(`https://myTeam.celonis.cloud/action-engine/api/projects/${PROJECT_ID}/skills/${SKILL_ID}/export`, skill); + + await new SkillCommandService(testContext).pullSkill(null, PROJECT_ID, SKILL_ID); + + expect(markedJson(`skill_${SKILL_ID}`, `skill_${SKILL_ID}.json`)).toEqual(skill); + }); + + it("Should mark the exported data pool", async () => { + const dataPool = { id: POOL_ID, name: "Pool 1" }; + mockAxiosGet(`https://myTeam.celonis.cloud/integration/api/pools/${POOL_ID}/export`, dataPool); + + await new DataPoolCommandService(testContext).pullDataPool(POOL_ID); + + expect(markedJson(`data-pool_${POOL_ID}`, `data-pool_${POOL_ID}.json`)).toEqual(dataPool); + }); + + it("Should mark the exported view bookmarks", async () => { + const bookmarks = [{ bookmark: { name: "My View Bookmark" } }]; + mockAxiosGet(`https://myTeam.celonis.cloud/blueprint/api/bookmarks/export?boardId=${BOARD_ID}&type=USER`, bookmarks); + + await new ViewBookmarksCommandService(testContext).pullViewBookmarks(BOARD_ID, undefined); + + expect(markedJson(`studio_view_bookmarks_${BOARD_ID}`, `studio_view_bookmarks_${BOARD_ID}.json`)).toEqual(bookmarks); + }); + + it("Should mark the exported analysis bookmarks", async () => { + const bookmarks = [{ bookmark: { name: "My Analysis Bookmark" } }]; + mockAxiosGet(`https://myTeam.celonis.cloud/process-analytics/api/bookmarks/export?analysisId=${ANALYSIS_ID}&type=USER`, bookmarks); + + await new AnalysisBookmarksCommandService(testContext).pullAnalysisBookmarks(ANALYSIS_ID, "USER"); + + expect(markedJson(`studio_analysis_bookmarks_${ANALYSIS_ID}`, `studio_analysis_bookmarks_${ANALYSIS_ID}.json`)).toEqual(bookmarks); + }); + + it("Should mark the exported package bookmarks under the user-chosen name", async () => { + const bookmarks = { packageKey: PACKAGE_KEY, entries: [] }; + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/packages/${PACKAGE_KEY}/bookmarks/export`, bookmarks); + + await new BookmarksCommandService(testContext).exportBookmarks(PACKAGE_KEY, "custom.json"); + + expect(markedJson("custom", "custom.json")).toEqual(bookmarks); + }); +}); diff --git a/tests/commands/data-pipeline/data-pool-update.spec.ts b/tests/commands/data-pipeline/data-pool-update.spec.ts new file mode 100644 index 0000000..4a5ca37 --- /dev/null +++ b/tests/commands/data-pipeline/data-pool-update.spec.ts @@ -0,0 +1,52 @@ +import { mockAxiosPut, mockAxiosPutError, mockedAxiosInstance, mockedPostRequestBodyByUrl } from "../../utls/http-requests-mock"; +import { DataPoolCommandService } from "../../../src/commands/data-pipeline/data-pool/data-pool-command.service"; +import { testContext } from "../../utls/test-context"; +import { loggingTestTransport } from "../../jest.setup"; +import { writeJsonTempFile } from "../../utls/fs-utils"; + +describe("Update data pool", () => { + + const poolId = "pool-1"; + const updateUrl = `https://myTeam.celonis.cloud/integration/api/pools/${poolId}`; + const file = "data-pool-update.json"; + + const dataPool = { + id: poolId, + name: "Updated Pool", + objects: [], + }; + + beforeEach(() => { + writeJsonTempFile(file, { dataPool }); + }); + + it("Should call the update API and log success", async () => { + mockAxiosPut(updateUrl, dataPool); + + await new DataPoolCommandService(testContext).updateDataPool(poolId, file); + + expect(mockedAxiosInstance.put).toHaveBeenCalledWith(updateUrl, expect.anything(), expect.anything()); + expect(loggingTestTransport.logMessages).toHaveLength(1); + expect(loggingTestTransport.logMessages[0].message).toContain("Data Pool was updated successfully!"); + }); + + it("Should send the data pool from the file as request body", async () => { + mockAxiosPut(updateUrl, dataPool); + + await new DataPoolCommandService(testContext).updateDataPool(poolId, file); + + expect(JSON.parse(mockedPostRequestBodyByUrl.get(updateUrl))).toEqual(dataPool); + }); + + it("Should log a fatal error and reject when the update API fails", async () => { + const exitSpy = jest.spyOn(process, "exit").mockImplementation((() => undefined) as never); + mockAxiosPutError(updateUrl, 500, { message: "Internal Server Error" }); + + await expect(new DataPoolCommandService(testContext).updateDataPool(poolId, file)).rejects.toBeUndefined(); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(loggingTestTransport.logMessages[0].message).toContain("Internal Server Error"); + + exitSpy.mockRestore(); + }); +}); diff --git a/tests/core/http/base.manager.spec.ts b/tests/core/http/base.manager.spec.ts new file mode 100644 index 0000000..056f92e --- /dev/null +++ b/tests/core/http/base.manager.spec.ts @@ -0,0 +1,89 @@ +import { BaseManager } from "../../../src/core/http/http-shared/base.manager"; +import { ManagerConfig } from "../../../src/core/http/http-shared/manager-config.interface"; +import { FatalError } from "../../../src/core/utils/logger"; +import { loggingTestTransport } from "../../jest.setup"; +import { mockAxiosGetError, mockAxiosPostError } from "../../utls/http-requests-mock"; +import { testContext } from "../../utls/test-context"; + +const TEAM_URL = "https://myTeam.celonis.cloud"; +const PULL_PATH = "/api/test/pull"; +const PUSH_PATH = "/api/test/push"; +const FIND_ALL_PATH = "/api/test/find-all"; +const ERROR_BODY = { message: "Internal Server Error" }; + +class TestManager extends BaseManager { + public constructor() { + super(testContext); + } + + protected getConfig(): ManagerConfig { + return { + pullUrl: PULL_PATH, + pushUrl: PUSH_PATH, + findAllUrl: FIND_ALL_PATH, + exportFileName: "test-export.json", + onPushSuccessMessage: () => "Pushed successfully", + onFindAll: () => undefined, + }; + } + + protected getBody(): object { + return { key: "test" }; + } + + protected getSerializedFileContent(data: any): string { + return JSON.stringify(data); + } +} + +describe("BaseManager error handling", () => { + + let manager: TestManager; + let exitSpy: jest.SpyInstance; + + beforeEach(() => { + manager = new TestManager(); + exitSpy = jest.spyOn(process, "exit").mockImplementation((() => undefined) as never); + }); + + afterEach(() => { + exitSpy.mockRestore(); + }); + + const expectFatalErrorLogged = (): void => { + expect(exitSpy).toHaveBeenCalledWith(1); + expect(loggingTestTransport.logMessages[0].message).toContain("Internal Server Error"); + }; + + it("Should log a fatal error and reject when the pull API fails", async () => { + mockAxiosGetError(TEAM_URL + PULL_PATH, 500, ERROR_BODY); + + await expect(manager.pull()).rejects.toThrow(FatalError); + + expectFatalErrorLogged(); + }); + + it("Should log a fatal error and reject when the file download fails", async () => { + mockAxiosPostError(TEAM_URL + PULL_PATH, 500, ERROR_BODY); + + await expect(manager.pullFile()).rejects.toEqual(JSON.stringify(ERROR_BODY)); + + expectFatalErrorLogged(); + }); + + it("Should log a fatal error and reject when the push API fails", async () => { + mockAxiosPostError(TEAM_URL + PUSH_PATH, 500, ERROR_BODY); + + await expect(manager.push()).rejects.toBeUndefined(); + + expectFatalErrorLogged(); + }); + + it("Should log a fatal error and reject when the find all API fails", async () => { + mockAxiosGetError(TEAM_URL + FIND_ALL_PATH, 500, ERROR_BODY); + + await expect(manager.findAll()).rejects.toThrow(FatalError); + + expectFatalErrorLogged(); + }); +}); diff --git a/tests/utls/http-requests-mock.ts b/tests/utls/http-requests-mock.ts index 1405cb0..3878734 100644 --- a/tests/utls/http-requests-mock.ts +++ b/tests/utls/http-requests-mock.ts @@ -12,6 +12,7 @@ const mockedGetErrorByUrl = new Map(); const mockedPostResponseByUrl = new Map(); const mockedPostErrorByUrl = new Map(); const mockedPostRequestBodyByUrl = new Map(); +const mockedPutErrorByUrl = new Map(); const mockedDeleteResponseByUrl = new Map(); const mockAxios = () : void => { @@ -64,6 +65,20 @@ const mockAxios = () : void => { } fail("API call not mocked.") }); + + (mockedAxiosInstance.put as jest.Mock).mockImplementation((requestUrl: string, data: any) => { + if (mockedPutErrorByUrl.has(requestUrl)) { + const { status, data: errorData } = mockedPutErrorByUrl.get(requestUrl)!; + return Promise.reject({ response: { status, data: errorData } }); + } + if (mockedPostResponseByUrl.has(requestUrl)) { + const response = { data: mockedPostResponseByUrl.get(requestUrl) }; + mockedPostRequestBodyByUrl.set(requestUrl, data); + + return Promise.resolve(response); + } + fail("API call not mocked.") + }); } const mockAxiosGet = (url: string, responseData: any) => { @@ -95,17 +110,13 @@ const mockAxiosPostError = (url: string, status: number, data: any) => { 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); + mockedPutErrorByUrl.delete(url); +}; - return Promise.resolve(response); - } else { - fail("API call not mocked.") - } - }) -} +const mockAxiosPutError = (url: string, status: number, data: any) => { + mockedPutErrorByUrl.set(url, { status, data }); + mockedPostResponseByUrl.delete(url); +}; const mockAxiosDelete = (url: string) => { mockedDeleteResponseByUrl.set(url, undefined); @@ -125,6 +136,7 @@ afterEach(() => { mockedPostResponseByUrl.clear(); mockedPostErrorByUrl.clear(); mockedPostRequestBodyByUrl.clear(); + mockedPutErrorByUrl.clear(); mockedDeleteResponseByUrl.clear(); }) @@ -137,6 +149,7 @@ export { mockAxiosPost, mockAxiosPostError, mockAxiosPut, + mockAxiosPutError, mockAxiosDelete, mockedPostRequestBodyByUrl };