Skip to content

Commit e08c05a

Browse files
SP-355: Implement diff with file (#362)
1 parent d480a00 commit e08c05a

7 files changed

Lines changed: 320 additions & 21 deletions

File tree

docs/user-guide/config-commands.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -717,7 +717,9 @@ content-cli config nodes list --packageKey my-package --packageVersion 1.2.3 --l
717717
718718
## Diffing Node Configurations
719719
720-
The **config nodes diff** command allows you to compare two versions of a node's configuration within a package.
720+
The **config nodes diff** command allows you to compare two versions of a node's configuration within a package, or to compare a local node JSON file against a remote version.
721+
722+
Exactly one of `--compareVersion` or `--file` must be provided; the two options are mutually exclusive.
721723
722724
### Diff Two Versions of a Node
723725
@@ -764,6 +766,18 @@ The `changes` field contains configuration changes in JSON Patch format with the
764766
765767
The `metadataChanges` field follows the same structure but represents changes to node metadata rather than configuration.
766768
769+
### Diff a Local Node File Against a Database Version
770+
771+
To diff a local node JSON file (the compare side) against a version of the node stored remotely (the base side), use the `--file` option instead of `--compareVersion`:
772+
773+
```bash
774+
content-cli config nodes diff --packageKey <packageKey> --nodeKey <nodeKey> --baseVersion <STAGING|version> --file <node.json>
775+
```
776+
777+
The file must follow the `NodeExportTransport` shape — the format produced by `config export --unzip` under `<packageKey>_<version>/nodes/<nodeKey>.json`. `--baseVersion` accepts either `STAGING` or a specific package version. `--file` and `--compareVersion` are mutually exclusive; exactly one must be provided.
778+
779+
If no node with the given key exists for the resolved base version, the file is diffed against an empty configuration `{}`.
780+
767781
### Export Node Diff as JSON
768782
769783
To export the node diff as a JSON file, use the `--json` option:

src/commands/configuration-management/api/node-diff-api.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import * as FormData from "form-data";
12
import { HttpClient } from "../../../core/http/http-client";
23
import { Context } from "../../../core/command/cli-context";
3-
import { GetNodeDiffRequest, NodeConfigurationDiffTransport } from "../interfaces/node-diff.interfaces";
4+
import {
5+
GetNodeDiffRequest,
6+
GetNodeDiffWithFileRequest,
7+
NodeConfigurationDiffTransport,
8+
} from "../interfaces/node-diff.interfaces";
49
import { FatalError } from "../../../core/utils/logger";
510

611
export class NodeDiffApi {
@@ -22,4 +27,21 @@ export class NodeDiffApi {
2227
throw new FatalError(`Problem getting the node diff: ${exception}`);
2328
});
2429
}
30+
31+
public async diffWithFile(request: GetNodeDiffWithFileRequest): Promise<NodeConfigurationDiffTransport> {
32+
const queryParams = new URLSearchParams();
33+
queryParams.set("baseVersion", request.baseVersion);
34+
35+
const formData = new FormData();
36+
formData.append("file", request.file);
37+
38+
return this.httpClient()
39+
.postFile(
40+
`/pacman/api/core/packages/${request.packageKey}/nodes/${request.nodeKey}/diff/configuration/with-file?${queryParams.toString()}`,
41+
formData,
42+
)
43+
.catch(exception => {
44+
throw new FatalError(`Problem getting the node diff: ${exception}`);
45+
});
46+
}
2547
}

src/commands/configuration-management/interfaces/node-diff.interfaces.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ReadStream } from "node:fs";
12
import { ConfigurationChangeTransport, NodeConfigurationChangeType } from "./diff-package.interfaces";
23

34
export interface GetNodeDiffRequest {
@@ -7,6 +8,13 @@ export interface GetNodeDiffRequest {
78
compareVersion: string;
89
}
910

11+
export interface GetNodeDiffWithFileRequest {
12+
packageKey: string;
13+
nodeKey: string;
14+
baseVersion: string;
15+
file: ReadStream;
16+
}
17+
1018
export interface NodeConfigurationDiffTransport {
1119
packageKey: string,
1220
nodeKey: string,

src/commands/configuration-management/module.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,8 @@ class Module extends IModule {
163163
.requiredOption("--packageKey <packageKey>", "Identifier of the package")
164164
.requiredOption("--nodeKey <nodeKey>", "Identifier of the node")
165165
.requiredOption("--baseVersion <baseVersion>", "Base version of the node")
166-
.requiredOption("--compareVersion <compareVersion>", "Compare version of the node")
166+
.option("--compareVersion <compareVersion>", "Compare version of the node, mutually exclusive with --file (exactly one required)")
167+
.option("-f, --file <file>", "Local node JSON file to diff against the base version, mutually exclusive with --compareVersion (exactly one required)")
167168
.option("--json", "Return the response as a JSON file")
168169
.action(this.diffNode);
169170

@@ -311,7 +312,14 @@ class Module extends IModule {
311312
}
312313

313314
private async diffNode(context: Context, command: Command, options: OptionValues): Promise<void> {
314-
await new NodeDiffService(context).diff(options.packageKey, options.nodeKey, options.baseVersion, options.compareVersion, options.json);
315+
if ((options.file && options.compareVersion) || (!options.file && !options.compareVersion)) {
316+
throw new Error("Please provide either --compareVersion or --file, but not both.");
317+
}
318+
if (options.file) {
319+
await new NodeDiffService(context).diffWithFile(options.packageKey, options.nodeKey, options.baseVersion, options.file, options.json);
320+
} else {
321+
await new NodeDiffService(context).diff(options.packageKey, options.nodeKey, options.baseVersion, options.compareVersion, options.json);
322+
}
315323
}
316324

317325
private async listNodeDependencies(context: Context, command: Command, options: OptionValues): Promise<void> {

src/commands/configuration-management/node-diff.service.ts

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as fs from "node:fs";
12
import { v4 as uuidv4 } from "uuid";
23
import { logger } from "../../core/utils/logger";
34
import { fileService, FileService } from "../../core/utils/file-service";
@@ -27,23 +28,52 @@ export class NodeDiffService {
2728
});
2829

2930
if (jsonResponse) {
30-
const filename = uuidv4() + ".json";
31-
fileService.writeToFileWithGivenName(JSON.stringify(nodeDiff, null, 2), filename);
32-
logger.info(FileService.fileDownloadedMessage + filename);
31+
this.exportDiffAsJson(nodeDiff);
3332
} else {
34-
logger.info(`Package Key: ${nodeDiff.packageKey}`);
35-
logger.info(`Node Key: ${nodeDiff.nodeKey}`);
36-
logger.info(`Name: ${nodeDiff.name}`);
37-
logger.info(`Type: ${nodeDiff.type}`);
38-
logger.info(`Is invalid configuration: ${nodeDiff.invalidContent}`);
39-
if (nodeDiff.parentNodeKey) {
40-
logger.info(`Parent Node Key: ${nodeDiff.parentNodeKey}`);
41-
}
42-
logger.info(`Change Date: ${new Date(nodeDiff.changeDate).toISOString()}`);
43-
logger.info(`Updated By: ${nodeDiff.updatedBy}`);
44-
logger.info(`Change Type: ${nodeDiff.changeType}`);
45-
logger.info(`Changes: ${JSON.stringify(nodeDiff.changes)}`);
46-
logger.info(`Metadata Changes: ${JSON.stringify(nodeDiff.metadataChanges)}`);
33+
this.logDiff(nodeDiff);
4734
}
4835
}
36+
37+
public async diffWithFile(
38+
packageKey: string,
39+
nodeKey: string,
40+
baseVersion: string,
41+
file: string,
42+
jsonResponse: boolean
43+
): Promise<void> {
44+
const nodeDiff: NodeConfigurationDiffTransport = await this.nodeDiffApi.diffWithFile({
45+
packageKey,
46+
nodeKey,
47+
baseVersion,
48+
file: fs.createReadStream(file),
49+
});
50+
51+
if (jsonResponse) {
52+
this.exportDiffAsJson(nodeDiff);
53+
} else {
54+
this.logDiff(nodeDiff);
55+
}
56+
}
57+
58+
private exportDiffAsJson(nodeDiff: NodeConfigurationDiffTransport): void {
59+
const filename = uuidv4() + ".json";
60+
fileService.writeToFileWithGivenName(JSON.stringify(nodeDiff, null, 2), filename);
61+
logger.info(FileService.fileDownloadedMessage + filename);
62+
}
63+
64+
private logDiff(nodeDiff: NodeConfigurationDiffTransport): void {
65+
logger.info(`Package Key: ${nodeDiff.packageKey}`);
66+
logger.info(`Node Key: ${nodeDiff.nodeKey}`);
67+
logger.info(`Name: ${nodeDiff.name}`);
68+
logger.info(`Type: ${nodeDiff.type}`);
69+
logger.info(`Is invalid configuration: ${nodeDiff.invalidContent}`);
70+
if (nodeDiff.parentNodeKey) {
71+
logger.info(`Parent Node Key: ${nodeDiff.parentNodeKey}`);
72+
}
73+
logger.info(`Change Date: ${new Date(nodeDiff.changeDate).toISOString()}`);
74+
logger.info(`Updated By: ${nodeDiff.updatedBy}`);
75+
logger.info(`Change Type: ${nodeDiff.changeType}`);
76+
logger.info(`Changes: ${JSON.stringify(nodeDiff.changes)}`);
77+
logger.info(`Metadata Changes: ${JSON.stringify(nodeDiff.metadataChanges)}`);
78+
}
4979
}

tests/commands/configuration-management/config-node-diff.spec.ts

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
import { NodeConfigurationDiffTransport } from "../../../src/commands/configuration-management/interfaces/node-diff.interfaces";
22
import { NodeConfigurationChangeType } from "../../../src/commands/configuration-management/interfaces/diff-package.interfaces";
3-
import { mockAxiosGet } from "../../utls/http-requests-mock";
3+
import {
4+
mockAxiosGet,
5+
mockAxiosPost,
6+
mockedAxiosInstance,
7+
mockedPostRequestBodyByUrl,
8+
} from "../../utls/http-requests-mock";
49
import { NodeDiffService } from "../../../src/commands/configuration-management/node-diff.service";
510
import { testContext } from "../../utls/test-context";
611
import { loggingTestTransport, mockWriteFileSync } from "../../jest.setup";
712
import { FileService } from "../../../src/core/utils/file-service";
13+
import { mockCreateReadStream } from "../../utls/fs-mock-utils";
14+
import * as FormData from "form-data";
815
import * as path from "path";
916

1017
describe("Node diff", () => {
@@ -290,4 +297,130 @@ describe("Node diff", () => {
290297
const changeDateLog = loggingTestTransport.logMessages.find(log => log.message.includes("Change Date:"));
291298
expect(changeDateLog.message).toContain(specificDate);
292299
});
300+
301+
it("Should throw a FatalError when the diff API call fails", async () => {
302+
(mockedAxiosInstance.get as jest.Mock).mockRejectedValueOnce(new Error("network down"));
303+
304+
await expect(
305+
new NodeDiffService(testContext).diff(packageKey, nodeKey, baseVersion, compareVersion, false)
306+
).rejects.toThrow(/Problem getting the node diff/);
307+
308+
expect(mockWriteFileSync).not.toHaveBeenCalled();
309+
});
310+
311+
it("Should request the diff with both baseVersion and compareVersion query parameters", async () => {
312+
const expectedUrl = `https://myTeam.celonis.cloud/pacman/api/core/packages/${packageKey}/nodes/${nodeKey}/diff/configuration?baseVersion=${baseVersion}&compareVersion=${compareVersion}`;
313+
mockAxiosGet(expectedUrl, nodeDiff);
314+
315+
await new NodeDiffService(testContext).diff(packageKey, nodeKey, baseVersion, compareVersion, false);
316+
317+
expect(mockedAxiosInstance.get as jest.Mock).toHaveBeenCalledTimes(1);
318+
const calledUrl = (mockedAxiosInstance.get as jest.Mock).mock.calls[0][0];
319+
expect(calledUrl).toBe(expectedUrl);
320+
});
321+
322+
describe("With file", () => {
323+
const file = "./node.json";
324+
const nodeJsonContent = Buffer.from(JSON.stringify({ key: nodeKey, configuration: { foo: "bar" } }));
325+
326+
beforeEach(() => {
327+
mockCreateReadStream(nodeJsonContent);
328+
});
329+
330+
it("Should diff a node file against STAGING and log all fields", async () => {
331+
const url = `https://myTeam.celonis.cloud/pacman/api/core/packages/${packageKey}/nodes/${nodeKey}/diff/configuration/with-file?baseVersion=STAGING`;
332+
mockAxiosPost(url, nodeDiff);
333+
334+
await new NodeDiffService(testContext).diffWithFile(packageKey, nodeKey, "STAGING", file, false);
335+
336+
expect(loggingTestTransport.logMessages.length).toBe(11);
337+
expect(loggingTestTransport.logMessages[0].message.trim()).toEqual(`Package Key: ${nodeDiff.packageKey}`);
338+
expect(loggingTestTransport.logMessages[1].message.trim()).toEqual(`Node Key: ${nodeDiff.nodeKey}`);
339+
expect(loggingTestTransport.logMessages[2].message.trim()).toEqual(`Name: ${nodeDiff.name}`);
340+
expect(loggingTestTransport.logMessages[3].message.trim()).toEqual(`Type: ${nodeDiff.type}`);
341+
expect(loggingTestTransport.logMessages[4].message.trim()).toEqual(
342+
`Is invalid configuration: ${nodeDiff.invalidContent}`
343+
);
344+
expect(loggingTestTransport.logMessages[5].message.trim()).toEqual(
345+
`Parent Node Key: ${nodeDiff.parentNodeKey}`
346+
);
347+
expect(loggingTestTransport.logMessages[6].message.trim()).toEqual(`Change Date: ${nodeDiff.changeDate}`);
348+
expect(loggingTestTransport.logMessages[7].message.trim()).toEqual(`Updated By: ${nodeDiff.updatedBy}`);
349+
expect(loggingTestTransport.logMessages[8].message.trim()).toEqual(`Change Type: ${nodeDiff.changeType}`);
350+
expect(loggingTestTransport.logMessages[9].message.trim()).toEqual(
351+
`Changes: ${JSON.stringify(nodeDiff.changes)}`
352+
);
353+
expect(loggingTestTransport.logMessages[10].message.trim()).toEqual(
354+
`Metadata Changes: ${JSON.stringify(nodeDiff.metadataChanges)}`
355+
);
356+
});
357+
358+
it("Should diff a node file against STAGING and return as JSON", async () => {
359+
const url = `https://myTeam.celonis.cloud/pacman/api/core/packages/${packageKey}/nodes/${nodeKey}/diff/configuration/with-file?baseVersion=STAGING`;
360+
mockAxiosPost(url, nodeDiff);
361+
362+
await new NodeDiffService(testContext).diffWithFile(packageKey, nodeKey, "STAGING", file, true);
363+
364+
const expectedFileName = loggingTestTransport.logMessages[0].message.split(
365+
FileService.fileDownloadedMessage
366+
)[1];
367+
368+
expect(mockWriteFileSync).toHaveBeenCalledWith(
369+
path.resolve(process.cwd(), expectedFileName),
370+
expect.any(String),
371+
{ encoding: "utf-8", mode: 0o600 }
372+
);
373+
374+
const savedDiff = JSON.parse(mockWriteFileSync.mock.calls[0][1]) as NodeConfigurationDiffTransport;
375+
376+
expect(savedDiff.packageKey).toEqual(nodeDiff.packageKey);
377+
expect(savedDiff.nodeKey).toEqual(nodeDiff.nodeKey);
378+
expect(savedDiff.changeType).toEqual(nodeDiff.changeType);
379+
expect(savedDiff.changes).toEqual(nodeDiff.changes);
380+
expect(savedDiff.metadataChanges).toEqual(nodeDiff.metadataChanges);
381+
});
382+
383+
it("Should diff a node file against a specific base version", async () => {
384+
const specificBaseVersion = "1.0.0";
385+
const url = `https://myTeam.celonis.cloud/pacman/api/core/packages/${packageKey}/nodes/${nodeKey}/diff/configuration/with-file?baseVersion=${specificBaseVersion}`;
386+
mockAxiosPost(url, nodeDiff);
387+
388+
await new NodeDiffService(testContext).diffWithFile(packageKey, nodeKey, specificBaseVersion, file, false);
389+
390+
expect(loggingTestTransport.logMessages.length).toBe(11);
391+
expect(loggingTestTransport.logMessages[0].message.trim()).toEqual(`Package Key: ${nodeDiff.packageKey}`);
392+
});
393+
394+
it("Should send the node file as multipart/form-data with a 'file' field", async () => {
395+
const url = `https://myTeam.celonis.cloud/pacman/api/core/packages/${packageKey}/nodes/${nodeKey}/diff/configuration/with-file?baseVersion=STAGING`;
396+
mockAxiosPost(url, nodeDiff);
397+
398+
await new NodeDiffService(testContext).diffWithFile(packageKey, nodeKey, "STAGING", file, false);
399+
400+
const sentBody = mockedPostRequestBodyByUrl.get(url);
401+
expect(sentBody).toBeInstanceOf(FormData);
402+
403+
const headers = (sentBody as FormData).getHeaders();
404+
expect(headers["content-type"]).toMatch(/^multipart\/form-data; boundary=/);
405+
406+
// form-data keeps the registered parts in its internal `_streams` array. Each form
407+
// field is represented by a header string followed by the value; assert that the
408+
// header chunk for the 'file' field is present.
409+
const streams: unknown[] = ((sentBody as unknown) as { _streams: unknown[] })._streams;
410+
const fileFieldHeader = streams.find(
411+
chunk => typeof chunk === "string" && chunk.includes('name="file"')
412+
);
413+
expect(fileFieldHeader).toBeDefined();
414+
});
415+
416+
it("Should throw a FatalError when the diff-with-file API call fails", async () => {
417+
(mockedAxiosInstance.post as jest.Mock).mockRejectedValueOnce(new Error("upload failed"));
418+
419+
await expect(
420+
new NodeDiffService(testContext).diffWithFile(packageKey, nodeKey, "STAGING", file, false)
421+
).rejects.toThrow(/Problem getting the node diff/);
422+
423+
expect(mockWriteFileSync).not.toHaveBeenCalled();
424+
});
425+
});
293426
});

0 commit comments

Comments
 (0)