diff --git a/src/commands/configuration-management/api/node-api.ts b/src/commands/configuration-management/api/node-api.ts index 62e36d4b..f6c9482b 100644 --- a/src/commands/configuration-management/api/node-api.ts +++ b/src/commands/configuration-management/api/node-api.ts @@ -1,6 +1,6 @@ import { HttpClient } from "../../../core/http/http-client"; import { Context } from "../../../core/command/cli-context"; -import { NodeTransport } from "../interfaces/node.interfaces"; +import { NodeTransport, SaveNodeTransport, UpdateNodeTransport } from "../interfaces/node.interfaces"; import { FatalError } from "../../../core/utils/logger"; export class NodeApi { @@ -33,6 +33,37 @@ export class NodeApi { }); } + public async createStagingNode(packageKey: string, transport: SaveNodeTransport, validateOnly: boolean): Promise { + const suffix = validateOnly ? "?validate=true" : ""; + + return this.httpClient() + .post(`/pacman/api/core/staging/packages/${packageKey}/nodes${suffix}`, transport) + .catch((e) => { + throw new FatalError(`Problem creating node in package ${packageKey}: ${e}`); + }); + } + + public async updateStagingNode(packageKey: string, nodeKey: string, transport: UpdateNodeTransport, validateOnly: boolean): Promise { + const suffix = validateOnly ? "?validate=true" : ""; + + return this.httpClient() + .put(`/pacman/api/core/staging/packages/${packageKey}/nodes/${nodeKey}${suffix}`, transport) + .catch((e) => { + throw new FatalError(`Problem updating node ${nodeKey} in package ${packageKey}: ${e}`); + }); + } + + public async archiveStagingNode(packageKey: string, nodeKey: string, force: boolean): Promise { + const queryParams = new URLSearchParams(); + queryParams.set("force", force.toString()); + + return this.httpClient() + .delete(`/pacman/api/core/staging/packages/${packageKey}/nodes/${nodeKey}/archive?${queryParams.toString()}`) + .catch((e) => { + throw new FatalError(`Problem archiving node ${nodeKey} in package ${packageKey}: ${e}`); + }); + } + public async findVersionedNodesByPackage(packageKey: string, version: string, withConfiguration: boolean, limit: number, offset: number): Promise { const queryParams = new URLSearchParams(); queryParams.set("version", version); diff --git a/src/commands/configuration-management/interfaces/node.interfaces.ts b/src/commands/configuration-management/interfaces/node.interfaces.ts index c191b9f7..7889a143 100644 --- a/src/commands/configuration-management/interfaces/node.interfaces.ts +++ b/src/commands/configuration-management/interfaces/node.interfaces.ts @@ -20,3 +20,25 @@ export interface NodeTransport { schemaVersion: number; flavor?: string; } + +export interface SaveNodeTransport { + key: string; + name: string; + parentNodeKey: string; + type: string; + configuration?: NodeConfiguration; + invalidConfiguration?: string; + invalidContent?: boolean; + schemaVersion?: number; + [key: string]: any; +} + +export interface UpdateNodeTransport { + name: string; + parentNodeKey: string; + configuration?: NodeConfiguration; + invalidConfiguration?: string; + invalidContent?: boolean; + schemaVersion?: number; + [key: string]: any; +} diff --git a/src/commands/configuration-management/module.ts b/src/commands/configuration-management/module.ts index c79ab348..9294350e 100644 --- a/src/commands/configuration-management/module.ts +++ b/src/commands/configuration-management/module.ts @@ -12,6 +12,7 @@ import { NodeDiffService } from "./node-diff.service"; import { NodeDependencyService } from "./node-dependency.service"; import { PackageVersionCommandService } from "./package-version-command.service"; import { PackageValidationService } from "./package-validation.service"; +import { fileService } from "../../core/utils/file-service"; class Module extends IModule { @@ -116,6 +117,32 @@ class Module extends IModule { .option("--json", "Return the response as a JSON file") .action(this.findNode); + nodesCommand.command("create") + .description("Create a new staging node in a package") + .requiredOption("--packageKey ", "Identifier of the package") + .option("--body ", "Node payload as JSON string") + .option("-f, --file ", "Path to a JSON file containing the node payload") + .option("--validate", "Only validate the payload without persisting. Returns success if valid.", false) + .option("--json", "Return the response as a JSON file") + .action(this.createNode); + + nodesCommand.command("update") + .description("Update a staging node in a package") + .requiredOption("--packageKey ", "Identifier of the package") + .requiredOption("--nodeKey ", "Identifier of the node") + .option("--body ", "Node payload as JSON string") + .option("-f, --file ", "Path to a JSON file containing the node payload") + .option("--validate", "Only validate the payload without persisting. Returns success if valid.", false) + .option("--json", "Return the response as a JSON file") + .action(this.updateNode); + + nodesCommand.command("delete") + .description("Delete (archive) a staging node in a package") + .requiredOption("--packageKey ", "Identifier of the package") + .requiredOption("--nodeKey ", "Identifier of the node") + .option("--force", "Force delete even if the node has dependants", false) + .action(this.archiveNode); + nodesCommand.command("list") .description("List nodes in a specific package version") .requiredOption("--packageKey ", "Identifier of the package") @@ -249,6 +276,33 @@ class Module extends IModule { await new NodeService(context).findNode(options.packageKey, options.nodeKey, options.withConfiguration, options.packageVersion ?? null, options.json); } + private async createNode(context: Context, command: Command, options: OptionValues): Promise { + const body = Module.resolveBody(options.body, options.file); + await new NodeService(context).createNode(options.packageKey, body, options.validate, options.json); + } + + private async updateNode(context: Context, command: Command, options: OptionValues): Promise { + const body = Module.resolveBody(options.body, options.file); + await new NodeService(context).updateNode(options.packageKey, options.nodeKey, body, options.validate, options.json); + } + + private static resolveBody(body: string | undefined, file: string | undefined): string { + if (body && file) { + throw new Error("Please provide either --body or --file, but not both."); + } + if (!body && !file) { + throw new Error("Please provide either --body or --file."); + } + if (file) { + return fileService.readFile(file); + } + return body!; + } + + private async archiveNode(context: Context, command: Command, options: OptionValues): Promise { + await new NodeService(context).archiveNode(options.packageKey, options.nodeKey, options.force); + } + private async listNodes(context: Context, command: Command, options: OptionValues): Promise { await new NodeService(context).listNodes(options.packageKey, options.packageVersion, options.limit, options.offset, options.withConfiguration, options.json); } diff --git a/src/commands/configuration-management/node.service.ts b/src/commands/configuration-management/node.service.ts index 7fc9c853..72a64151 100644 --- a/src/commands/configuration-management/node.service.ts +++ b/src/commands/configuration-management/node.service.ts @@ -3,7 +3,7 @@ import { Context } from "../../core/command/cli-context"; import { fileService, FileService } from "../../core/utils/file-service"; import { logger } from "../../core/utils/logger"; import { v4 as uuidv4 } from "uuid"; -import { NodeTransport } from "./interfaces/node.interfaces"; +import { NodeTransport, SaveNodeTransport, UpdateNodeTransport } from "./interfaces/node.interfaces"; export class NodeService { private nodeApi: NodeApi; @@ -40,6 +40,47 @@ export class NodeService { } } + public async createNode(packageKey: string, body: string, validateOnly: boolean, jsonResponse: boolean): Promise { + const transport: SaveNodeTransport = JSON.parse(body); + const node = await this.nodeApi.createStagingNode(packageKey, transport, validateOnly); + + if (validateOnly) { + logger.info(`Validation successful for node ${transport.key} in package ${packageKey}.`); + return; + } + + if (jsonResponse) { + const filename = uuidv4() + ".json"; + fileService.writeToFileWithGivenName(JSON.stringify(node, null, 2), filename); + logger.info(FileService.fileDownloadedMessage + filename); + } else { + this.printNode(node as NodeTransport); + } + } + + public async updateNode(packageKey: string, nodeKey: string, body: string, validateOnly: boolean, jsonResponse: boolean): Promise { + const transport: UpdateNodeTransport = JSON.parse(body); + const node = await this.nodeApi.updateStagingNode(packageKey, nodeKey, transport, validateOnly); + + if (validateOnly) { + logger.info(`Validation successful for node ${nodeKey} in package ${packageKey}.`); + return; + } + + if (jsonResponse) { + const filename = uuidv4() + ".json"; + fileService.writeToFileWithGivenName(JSON.stringify(node, null, 2), filename); + logger.info(FileService.fileDownloadedMessage + filename); + } else { + this.printNode(node as NodeTransport); + } + } + + public async archiveNode(packageKey: string, nodeKey: string, force: boolean): Promise { + await this.nodeApi.archiveStagingNode(packageKey, nodeKey, force); + logger.info(`Node ${nodeKey} in package ${packageKey} archived successfully.`); + } + private printNode(node: NodeTransport): void { logger.info(`ID: ${node.id}`); logger.info(`Key: ${node.key}`); diff --git a/tests/commands/configuration-management/config-node-archive.spec.ts b/tests/commands/configuration-management/config-node-archive.spec.ts new file mode 100644 index 00000000..1b87daf1 --- /dev/null +++ b/tests/commands/configuration-management/config-node-archive.spec.ts @@ -0,0 +1,29 @@ +import { mockAxiosDelete } from "../../utls/http-requests-mock"; +import { NodeService } from "../../../src/commands/configuration-management/node.service"; +import { testContext } from "../../utls/test-context"; +import { loggingTestTransport } from "../../jest.setup"; + +describe("Node archive", () => { + const packageKey = "package-key"; + const nodeKey = "node-key"; + + it("Should archive node without force", async () => { + const apiUrl = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${packageKey}/nodes/${nodeKey}/archive?force=false`; + mockAxiosDelete(apiUrl); + + await new NodeService(testContext).archiveNode(packageKey, nodeKey, false); + + expect(loggingTestTransport.logMessages.length).toBe(1); + expect(loggingTestTransport.logMessages[0].message).toContain(`Node ${nodeKey} in package ${packageKey} archived successfully.`); + }); + + it("Should archive node with force", async () => { + const apiUrl = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${packageKey}/nodes/${nodeKey}/archive?force=true`; + mockAxiosDelete(apiUrl); + + await new NodeService(testContext).archiveNode(packageKey, nodeKey, true); + + expect(loggingTestTransport.logMessages.length).toBe(1); + expect(loggingTestTransport.logMessages[0].message).toContain(`Node ${nodeKey} in package ${packageKey} archived successfully.`); + }); +}); diff --git a/tests/commands/configuration-management/config-node-create.spec.ts b/tests/commands/configuration-management/config-node-create.spec.ts new file mode 100644 index 00000000..e5d85e95 --- /dev/null +++ b/tests/commands/configuration-management/config-node-create.spec.ts @@ -0,0 +1,87 @@ +import { NodeTransport, SaveNodeTransport } from "../../../src/commands/configuration-management/interfaces/node.interfaces"; +import { mockAxiosPost, mockedPostRequestBodyByUrl } from "../../utls/http-requests-mock"; +import { NodeService } from "../../../src/commands/configuration-management/node.service"; +import { testContext } from "../../utls/test-context"; +import { loggingTestTransport, mockWriteFileSync } from "../../jest.setup"; +import { FileService } from "../../../src/core/utils/file-service"; +import * as path from "path"; + +describe("Node create", () => { + const packageKey = "package-key"; + const apiUrl = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${packageKey}/nodes`; + const validateOnlyUrl = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${packageKey}/nodes?validate=true`; + + const createdNode: NodeTransport = { + id: "new-node-id", + key: "new-node-key", + name: "New Node", + packageNodeKey: packageKey, + parentNodeKey: packageKey, + packageNodeId: "package-node-id", + type: "VIEW", + invalidContent: false, + creationDate: new Date().toISOString(), + changeDate: new Date().toISOString(), + createdBy: "user-id", + updatedBy: "user-id", + schemaVersion: 2, + flavor: "STUDIO", + }; + + const saveTransport: SaveNodeTransport = { + key: "new-node-key", + name: "New Node", + parentNodeKey: packageKey, + type: "VIEW", + configuration: { someKey: "someValue" }, + }; + + it("Should create node and print result", async () => { + mockAxiosPost(apiUrl, createdNode); + + await new NodeService(testContext).createNode(packageKey, JSON.stringify(saveTransport), false, false); + + expect(loggingTestTransport.logMessages.length).toBe(11); + expect(loggingTestTransport.logMessages[0].message).toContain(`ID: ${createdNode.id}`); + expect(loggingTestTransport.logMessages[1].message).toContain(`Key: ${createdNode.key}`); + expect(loggingTestTransport.logMessages[2].message).toContain(`Name: ${createdNode.name}`); + }); + + it("Should create node and return as JSON", async () => { + mockAxiosPost(apiUrl, createdNode); + + await new NodeService(testContext).createNode(packageKey, JSON.stringify(saveTransport), 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 savedTransport = JSON.parse(mockWriteFileSync.mock.calls[0][1]) as NodeTransport; + + expect(savedTransport).toEqual(createdNode); + }); + + it("Should send correct request body", async () => { + mockAxiosPost(apiUrl, createdNode); + + await new NodeService(testContext).createNode(packageKey, JSON.stringify(saveTransport), false, false); + + const requestBody = JSON.parse(mockedPostRequestBodyByUrl.get(apiUrl)); + expect(requestBody.key).toBe(saveTransport.key); + expect(requestBody.name).toBe(saveTransport.name); + expect(requestBody.parentNodeKey).toBe(saveTransport.parentNodeKey); + expect(requestBody.type).toBe(saveTransport.type); + }); + + it("Should call validate-only endpoint when validateOnly=true", async () => { + mockAxiosPost(validateOnlyUrl, undefined); + + await new NodeService(testContext).createNode(packageKey, JSON.stringify(saveTransport), true, false); + + expect(mockWriteFileSync).not.toHaveBeenCalled(); + expect(loggingTestTransport.logMessages.length).toBe(1); + expect(loggingTestTransport.logMessages[0].message).toContain( + `Validation successful for node ${saveTransport.key} in package ${packageKey}.` + ); + }); +}); diff --git a/tests/commands/configuration-management/config-node-update.spec.ts b/tests/commands/configuration-management/config-node-update.spec.ts new file mode 100644 index 00000000..77e9ae44 --- /dev/null +++ b/tests/commands/configuration-management/config-node-update.spec.ts @@ -0,0 +1,85 @@ +import { NodeTransport, UpdateNodeTransport } from "../../../src/commands/configuration-management/interfaces/node.interfaces"; +import { mockAxiosPut, mockedPostRequestBodyByUrl } from "../../utls/http-requests-mock"; +import { NodeService } from "../../../src/commands/configuration-management/node.service"; +import { testContext } from "../../utls/test-context"; +import { loggingTestTransport, mockWriteFileSync } from "../../jest.setup"; +import { FileService } from "../../../src/core/utils/file-service"; +import * as path from "path"; + +describe("Node update", () => { + const packageKey = "package-key"; + const nodeKey = "node-key"; + const apiUrl = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${packageKey}/nodes/${nodeKey}`; + const validateOnlyUrl = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${packageKey}/nodes/${nodeKey}?validate=true`; + + const updatedNode: NodeTransport = { + id: "node-id", + key: nodeKey, + name: "Updated Node Name", + packageNodeKey: packageKey, + parentNodeKey: packageKey, + packageNodeId: "package-node-id", + type: "VIEW", + invalidContent: false, + creationDate: new Date().toISOString(), + changeDate: new Date().toISOString(), + createdBy: "user-id", + updatedBy: "user-id", + schemaVersion: 2, + flavor: "STUDIO", + }; + + const updateTransport: UpdateNodeTransport = { + name: "Updated Node Name", + parentNodeKey: packageKey, + configuration: { updatedKey: "updatedValue" }, + }; + + it("Should update node and print result", async () => { + mockAxiosPut(apiUrl, updatedNode); + + await new NodeService(testContext).updateNode(packageKey, nodeKey, JSON.stringify(updateTransport), false, false); + + expect(loggingTestTransport.logMessages.length).toBe(11); + expect(loggingTestTransport.logMessages[0].message).toContain(`ID: ${updatedNode.id}`); + expect(loggingTestTransport.logMessages[1].message).toContain(`Key: ${updatedNode.key}`); + expect(loggingTestTransport.logMessages[2].message).toContain(`Name: ${updatedNode.name}`); + }); + + it("Should update node and return as JSON", async () => { + mockAxiosPut(apiUrl, updatedNode); + + await new NodeService(testContext).updateNode(packageKey, nodeKey, JSON.stringify(updateTransport), 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 savedTransport = JSON.parse(mockWriteFileSync.mock.calls[0][1]) as NodeTransport; + + expect(savedTransport).toEqual(updatedNode); + }); + + it("Should send correct request body", async () => { + mockAxiosPut(apiUrl, updatedNode); + + await new NodeService(testContext).updateNode(packageKey, nodeKey, JSON.stringify(updateTransport), false, false); + + const requestBody = JSON.parse(mockedPostRequestBodyByUrl.get(apiUrl)); + expect(requestBody.name).toBe(updateTransport.name); + expect(requestBody.parentNodeKey).toBe(updateTransport.parentNodeKey); + expect(requestBody.configuration).toEqual(updateTransport.configuration); + }); + + it("Should call validate-only endpoint when validateOnly=true", async () => { + mockAxiosPut(validateOnlyUrl, undefined); + + await new NodeService(testContext).updateNode(packageKey, nodeKey, JSON.stringify(updateTransport), true, false); + + expect(mockWriteFileSync).not.toHaveBeenCalled(); + expect(loggingTestTransport.logMessages.length).toBe(1); + expect(loggingTestTransport.logMessages[0].message).toContain( + `Validation successful for node ${nodeKey} in package ${packageKey}.` + ); + }); +}); diff --git a/tests/utls/http-requests-mock.ts b/tests/utls/http-requests-mock.ts index d02d1c17..8dea73fa 100644 --- a/tests/utls/http-requests-mock.ts +++ b/tests/utls/http-requests-mock.ts @@ -7,6 +7,7 @@ const mockedAxiosInstance = {} as AxiosInstance; const mockedGetResponseByUrl = new Map(); const mockedPostResponseByUrl = new Map(); const mockedPostRequestBodyByUrl = new Map(); +const mockedDeleteResponseByUrl = new Map(); const mockAxios = () : void => { AxiosInitializer.initializeAxios = jest.fn().mockReturnValue(mockedAxiosInstance); @@ -14,6 +15,7 @@ const mockAxios = () : void => { mockedAxiosInstance.get = jest.fn(); mockedAxiosInstance.post = jest.fn(); mockedAxiosInstance.put = jest.fn(); + mockedAxiosInstance.delete = jest.fn(); } const mockAxiosGet = (url: string, responseData: any) => { @@ -68,10 +70,22 @@ const mockAxiosPut = (url: string, responseData: any) => { }) } +const mockAxiosDelete = (url: string) => { + mockedDeleteResponseByUrl.set(url, undefined); + (mockedAxiosInstance.delete as jest.Mock).mockImplementation((requestUrl: string) => { + if (mockedDeleteResponseByUrl.has(requestUrl)) { + return Promise.resolve({ data: undefined, status: 204 }); + } else { + fail("API call not mocked.") + } + }) +} + afterEach(() => { mockedGetResponseByUrl.clear(); mockedPostResponseByUrl.clear(); mockedPostRequestBodyByUrl.clear(); + mockedDeleteResponseByUrl.clear(); }) export { @@ -80,5 +94,6 @@ export { mockAxiosGet, mockAxiosPost, mockAxiosPut, + mockAxiosDelete, mockedPostRequestBodyByUrl };