From b7361b38de086c4b4aabcfd6da7aa00171de304c Mon Sep 17 00:00:00 2001 From: zgjimhaziri Date: Mon, 15 Jun 2026 18:51:23 +0200 Subject: [PATCH] SP-992: Add Git capabilities to config package export/import Add --gitProfile/--gitBranch options to 'config package export' and 'config package import', reusing the existing GitService that already backs the 't2tc package' and legacy 'config' commands. - export: when --gitBranch is set, the package is pushed unzipped to the branch (--zip is ignored for the Git path). - import: when --gitBranch is set, the branch is pulled and the package is imported; --gitBranch is mutually exclusive with --file/--directory. Includes-AI-Code: true Co-authored-by: Cursor --- docs/user-guide/config-commands.md | 32 ++++++++- .../configuration-management/module.ts | 18 +++-- .../single-package-export.service.ts | 21 +++++- .../single-package-import.service.ts | 42 +++++++++--- src/core/utils/file-service.ts | 8 +++ .../configuration-management/module.spec.ts | 68 ++++++++++++++++++- .../single-package-export.spec.ts | 45 +++++++++++- .../single-package-import.spec.ts | 59 +++++++++++++--- tests/core/utils/file.service.spec.ts | 20 ++++++ 9 files changed, 280 insertions(+), 33 deletions(-) diff --git a/docs/user-guide/config-commands.md b/docs/user-guide/config-commands.md index 02d8e79..fdefc72 100644 --- a/docs/user-guide/config-commands.md +++ b/docs/user-guide/config-commands.md @@ -113,7 +113,7 @@ Where `-f` is the shorthand for `--file`. You can also point at an unzipped dire content-cli config package import -p -d ``` -`--file` and `--directory` are mutually exclusive — provide exactly one. +`--file`, `--directory`, and `--gitBranch` are mutually exclusive — provide exactly one. See [Git Integration for Import](#git-integration-for-import) for the `--gitBranch` workflow. This command requires **edit permission** on the target package, or **create** permission when the package does not yet exist (see [Permissions](#permissions)). @@ -168,6 +168,19 @@ content-cli config package import -p -f --json info: File downloaded successfully. New filename: 9560f81f-f746-4117-83ee-dd1f614ad624.json ``` +#### Git Integration for Import + +Instead of `--file` / `--directory`, you can pull the package straight from a Git branch with the following **Git options**: + +- `--gitProfile ` – the Git profile to use for the Git operations. If omitted, the default profile is used. +- `--gitBranch ` – the branch in the Git repository the package is pulled from and imported. + +The CLI clones the configured repository at the given branch, expects the branch contents to be the flat [package layout](#package-zip-format), zips them, and imports the package. `--gitBranch` cannot be combined with `--file` or `--directory`. + +```bash +content-cli config package import -p --gitProfile myGitProfile --gitBranch feature-branch +``` + ### Export a Package `config package export` is the counterpart of [`config package import`](#import-a-package): it exports a single package's **staging (draft) version** in the package format. As with the `config nodes` commands, "staging" refers to the current draft state of the package — the unpublished working version, not a published package version. The output uses the same flat [package format](#package-zip-format) that `config package import` consumes, so a package can be exported from one team and imported into another without any conversion. Unlike [`t2tc package export`](./t2tc-commands.md#export-packages) — which produces a multi-package **batch artifact** — this command exports exactly one package on its own. @@ -211,6 +224,23 @@ my-package/ `variables.json` is omitted when the package has no variable assignments. +#### Git Integration for Export + +Instead of writing to the current working directory, you can push the exported package straight to a Git branch with the following **Git options**: + +- `--gitProfile ` – the Git profile to use for the Git operations. If omitted, the default profile is used. +- `--gitBranch ` – the branch in the Git repository the exported package is pushed to. + +The package is pushed in its unzipped [package layout](#package-layout), so `--zip` has no effect when `--gitBranch` is used. + +```bash +content-cli config package export -p --packageKey --gitProfile myGitProfile --gitBranch feature-branch +``` + +```bash +info: Successfully exported package to branch: feature-branch +``` + ## Validate Package Configurations (`config package validate`) > **Renamed.** This command moved from `config validate` to `config package validate`. The old `config validate` still works but prints a deprecation notice; switch to `config package validate`. diff --git a/src/commands/configuration-management/module.ts b/src/commands/configuration-management/module.ts index 4ffffa6..f35af89 100644 --- a/src/commands/configuration-management/module.ts +++ b/src/commands/configuration-management/module.ts @@ -73,17 +73,21 @@ class Module extends IModule { .description("Commands for working with a single package."); packageCommand.command("import") - .description("Import a package from a zip file or directory. Uses the package format, which is not interchangeable with the 't2tc package export' / 't2tc package import' batch archive.") + .description("Import a package from a zip file, directory, or Git branch. Uses the package format, which is not interchangeable with the 't2tc package export' / 't2tc package import' batch archive.") .option("-f, --file ", "Package zip file (relative path)") .option("-d, --directory ", "Package directory (relative path)") .option("--overwrite", "Flag to allow overwriting an existing package with the same key") .option("--json", "Return the response as a JSON file") + .option("--gitProfile ", "Git profile which you want to use for the Git operations") + .option("--gitBranch ", "Git branch from which you want to pull the package and import") .action(this.importSinglePackage); packageCommand.command("export") - .description("Export a single package's staging (draft) version to an unzipped directory (or a single zip with --zip). Uses the package format, which is not interchangeable with the 't2tc package export' / 't2tc package import' batch archive.") + .description("Export a single package's staging (draft) version to an unzipped directory (or a single zip with --zip, or to a Git branch with --gitBranch). Uses the package format, which is not interchangeable with the 't2tc package export' / 't2tc package import' batch archive.") .requiredOption("--packageKey ", "Key of the package to export") .option("--zip", "Export the package as a single .zip file instead of an unzipped directory", false) + .option("--gitProfile ", "Git profile which you want to use for the Git operations") + .option("--gitBranch ", "Git branch in which you want to push the exported package") .action(this.exportSinglePackage); packageCommand.command("validate") @@ -307,11 +311,17 @@ class Module extends IModule { } private async importSinglePackage(context: Context, command: Command, options: OptionValues): Promise { - await new SinglePackageImportService(context).importPackage(options.file, options.directory, options.overwrite, options.json); + if (options.gitProfile && !options.gitBranch) { + throw new Error("Please specify a branch using --gitBranch when using a Git profile."); + } + await new SinglePackageImportService(context).importPackage(options.file, options.directory, options.overwrite, options.json, options.gitBranch); } private async exportSinglePackage(context: Context, command: Command, options: OptionValues): Promise { - await new SinglePackageExportService(context).exportPackage(options.packageKey, options.zip); + if (options.gitProfile && !options.gitBranch) { + throw new Error("Please specify a branch using --gitBranch when using a Git profile."); + } + await new SinglePackageExportService(context).exportPackage(options.packageKey, options.zip, options.gitBranch); } private async diffPackages(context: Context, command: Command, options: OptionValues): Promise { diff --git a/src/commands/configuration-management/single-package-export.service.ts b/src/commands/configuration-management/single-package-export.service.ts index 81e07b0..50d276e 100644 --- a/src/commands/configuration-management/single-package-export.service.ts +++ b/src/commands/configuration-management/single-package-export.service.ts @@ -1,19 +1,28 @@ +import * as fs from "node:fs"; import { Context } from "../../core/command/cli-context"; import { fileService, FileService } from "../../core/utils/file-service"; import { logger } from "../../core/utils/logger"; +import { GitService } from "../../core/git-profile/git/git.service"; import { SinglePackageExportApi } from "./api/single-package-export-api"; export class SinglePackageExportService { private readonly singlePackageExportApi: SinglePackageExportApi; + private readonly gitService: GitService; constructor(context: Context) { this.singlePackageExportApi = new SinglePackageExportApi(context); + this.gitService = new GitService(context); } - public async exportPackage(packageKey: string, zip: boolean): Promise { + public async exportPackage(packageKey: string, zip: boolean, gitBranch: string): Promise { const packageData = await this.singlePackageExportApi.exportPackage(packageKey); + if (gitBranch) { + await this.exportToGitBranch(packageData, gitBranch); + return; + } + if (zip) { const fileName = `${packageKey}.zip`; fileService.writeBufferToFileWithGivenName(packageData, fileName); @@ -24,4 +33,14 @@ export class SinglePackageExportService { fileService.extractZipBufferToDirectory(packageData, packageKey); logger.info(`Successful export. Exported directory: ${packageKey}`); } + + private async exportToGitBranch(packageData: Buffer, gitBranch: string): Promise { + const extractedDirectory = fileService.extractZipBufferToTempDirectory(packageData); + try { + await this.gitService.pushToBranch(extractedDirectory, gitBranch); + logger.info("Successfully exported package to branch: " + gitBranch); + } finally { + fs.rmSync(extractedDirectory, { recursive: true, force: true }); + } + } } diff --git a/src/commands/configuration-management/single-package-import.service.ts b/src/commands/configuration-management/single-package-import.service.ts index fb6f803..2b10eef 100644 --- a/src/commands/configuration-management/single-package-import.service.ts +++ b/src/commands/configuration-management/single-package-import.service.ts @@ -6,6 +6,7 @@ import * as fs from "node:fs"; import { Context } from "../../core/command/cli-context"; import { fileService, FileService } from "../../core/utils/file-service"; import { logger } from "../../core/utils/logger"; +import { GitService } from "../../core/git-profile/git/git.service"; import { SinglePackageImportApi } from "./api/single-package-import-api"; import { SinglePackageImportResult } from "./interfaces/single-package-import.interfaces"; @@ -14,22 +15,44 @@ export class SinglePackageImportService { private static readonly MAX_UNCOMPRESSED_ZIP_SIZE = 4 * 1024 * 1024 * 1024; private readonly singlePackageImportApi: SinglePackageImportApi; + private readonly gitService: GitService; constructor(context: Context) { this.singlePackageImportApi = new SinglePackageImportApi(context); + this.gitService = new GitService(context); } - public async importPackage(file: string, directory: string, overwrite: boolean, jsonResponse: boolean): Promise { - const resolvedSource = this.resolveSource(file, directory); + public async importPackage(file: string, directory: string, overwrite: boolean, jsonResponse: boolean, gitBranch: string): Promise { + if ((file || directory) && gitBranch) { + throw new Error("You cannot use --file or --directory together with --gitBranch. Only one import source can be defined."); + } + if (!file && !directory && !gitBranch) { + throw new Error("You must provide a --file, a --directory, or a --gitBranch option to import a package."); + } + let gitTempDir: string | undefined; try { - const packageZip = new AdmZip(resolvedSource.zipPath); - const formData = this.buildBodyForImport(packageZip, resolvedSource.zipPath); - const result = await this.singlePackageImportApi.importPackage(formData, overwrite); - this.outputResult(result, jsonResponse); + let resolvedSource: { zipPath: string; isTemporary: boolean }; + if (gitBranch) { + gitTempDir = await this.gitService.pullFromBranch(gitBranch); + resolvedSource = { zipPath: fileService.zipDirectoryAsSinglePackage(gitTempDir), isTemporary: true }; + } else { + resolvedSource = this.resolveSource(file, directory); + } + + try { + const packageZip = new AdmZip(resolvedSource.zipPath); + const formData = this.buildBodyForImport(packageZip, resolvedSource.zipPath); + const result = await this.singlePackageImportApi.importPackage(formData, overwrite); + this.outputResult(result, jsonResponse); + } finally { + if (resolvedSource.isTemporary) { + fs.rmSync(resolvedSource.zipPath); + } + } } finally { - if (resolvedSource.isTemporary) { - fs.rmSync(resolvedSource.zipPath); + if (gitTempDir) { + fs.rmSync(gitTempDir, { recursive: true, force: true }); } } } @@ -38,9 +61,6 @@ export class SinglePackageImportService { if (file && directory) { throw new Error("You cannot use both --file and --directory options at the same time. Only one import source can be defined."); } - if (!file && !directory) { - throw new Error("You must provide either a --file or a --directory option to import a package."); - } if (file) { if (fileService.isDirectory(file)) { throw new Error("The --file option accepts only zip files."); diff --git a/src/core/utils/file-service.ts b/src/core/utils/file-service.ts index 5769572..52bb05f 100644 --- a/src/core/utils/file-service.ts +++ b/src/core/utils/file-service.ts @@ -29,6 +29,14 @@ export class FileService { this.restrictFilePermissions(targetPath); } + public extractZipBufferToTempDirectory(data: Buffer): string { + const tempDir = path.join(os.tmpdir(), `content-cli-${uuidv4()}`); + fs.mkdirSync(tempDir, { recursive: true, mode: FileConstants.DEFAULT_FOLDER_PERMISSIONS }); + new AdmZip(data).extractAllTo(tempDir, true, true); + this.restrictFilePermissions(tempDir); + return tempDir; + } + public async readFileToJson(fileName: string): Promise { const fileContent = this.readFile(fileName); diff --git a/tests/commands/configuration-management/module.spec.ts b/tests/commands/configuration-management/module.spec.ts index a708154..a0cc959 100644 --- a/tests/commands/configuration-management/module.spec.ts +++ b/tests/commands/configuration-management/module.spec.ts @@ -620,6 +620,7 @@ describe("Configuration Management Module - Action Validations", () => { "single-package.zip", undefined, undefined, + undefined, undefined ); }); @@ -635,6 +636,7 @@ describe("Configuration Management Module - Action Validations", () => { undefined, "./single-package-dir", undefined, + undefined, undefined ); }); @@ -652,9 +654,40 @@ describe("Configuration Management Module - Action Validations", () => { "single-package.zip", undefined, true, - true + true, + undefined ); }); + + it("should pass the gitBranch option correctly", async () => { + const options: OptionValues = { + gitBranch: "feature-branch", + gitProfile: "myProfile", + }; + + await (module as any).importSinglePackage(testContext, mockCommand, options); + + expect(mockSinglePackageImportService.importPackage).toHaveBeenCalledWith( + undefined, + undefined, + undefined, + undefined, + "feature-branch" + ); + }); + + it("should throw when gitProfile is provided without gitBranch", async () => { + const options: OptionValues = { + file: "single-package.zip", + gitProfile: "myProfile", + }; + + await expect( + (module as any).importSinglePackage(testContext, mockCommand, options) + ).rejects.toThrow("Please specify a branch using --gitBranch when using a Git profile."); + + expect(mockSinglePackageImportService.importPackage).not.toHaveBeenCalled(); + }); }); describe("exportSinglePackage", () => { @@ -667,6 +700,7 @@ describe("Configuration Management Module - Action Validations", () => { expect(mockSinglePackageExportService.exportPackage).toHaveBeenCalledWith( "my-package", + undefined, undefined ); }); @@ -681,9 +715,39 @@ describe("Configuration Management Module - Action Validations", () => { expect(mockSinglePackageExportService.exportPackage).toHaveBeenCalledWith( "my-package", - true + true, + undefined ); }); + + it("should pass the gitBranch option correctly", async () => { + const options: OptionValues = { + packageKey: "my-package", + gitBranch: "feature-branch", + gitProfile: "myProfile", + }; + + await (module as any).exportSinglePackage(testContext, mockCommand, options); + + expect(mockSinglePackageExportService.exportPackage).toHaveBeenCalledWith( + "my-package", + undefined, + "feature-branch" + ); + }); + + it("should throw when gitProfile is provided without gitBranch", async () => { + const options: OptionValues = { + packageKey: "my-package", + gitProfile: "myProfile", + }; + + await expect( + (module as any).exportSinglePackage(testContext, mockCommand, options) + ).rejects.toThrow("Please specify a branch using --gitBranch when using a Git profile."); + + expect(mockSinglePackageExportService.exportPackage).not.toHaveBeenCalled(); + }); }); describe("listVariables validation", () => { diff --git a/tests/commands/configuration-management/single-package-export.spec.ts b/tests/commands/configuration-management/single-package-export.spec.ts index 304c039..91524c2 100644 --- a/tests/commands/configuration-management/single-package-export.spec.ts +++ b/tests/commands/configuration-management/single-package-export.spec.ts @@ -1,10 +1,12 @@ import * as path from "path"; +import * as fs from "node:fs"; import AdmZip = require("adm-zip"); import { mockAxiosGet, mockAxiosGetError, mockedAxiosInstance } from "../../utls/http-requests-mock"; import { SinglePackageExportService } from "../../../src/commands/configuration-management/single-package-export.service"; import { testContext } from "../../utls/test-context"; import { loggingTestTransport, mockWriteFileSync } from "../../jest.setup"; import { FileService } from "../../../src/core/utils/file-service"; +import { GitService } from "../../../src/core/git-profile/git/git.service"; const PACKAGE_KEY = "pkg-1"; const EXPORT_URL = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${PACKAGE_KEY}/export-file`; @@ -37,7 +39,7 @@ describe("Single package export", () => { const extractSpy = jest.spyOn(FileService.prototype, "extractZipBufferToDirectory").mockImplementation(() => undefined); - await new SinglePackageExportService(testContext).exportPackage(PACKAGE_KEY, false); + await new SinglePackageExportService(testContext).exportPackage(PACKAGE_KEY, false, null); expect(mockedAxiosInstance.get).toHaveBeenCalledWith(EXPORT_URL, expect.anything()); expect(extractSpy).toHaveBeenCalledTimes(1); @@ -52,7 +54,7 @@ describe("Single package export", () => { const packageData = buildSinglePackageZip(); mockAxiosGet(EXPORT_URL, packageData); - await new SinglePackageExportService(testContext).exportPackage(PACKAGE_KEY, true); + await new SinglePackageExportService(testContext).exportPackage(PACKAGE_KEY, true, null); expect(mockedAxiosInstance.get).toHaveBeenCalledWith(EXPORT_URL, expect.anything()); @@ -67,9 +69,46 @@ describe("Single package export", () => { mockAxiosGetError(EXPORT_URL, 404, { errorCode: "PACKAGE_NOT_FOUND" }); await expect( - new SinglePackageExportService(testContext).exportPackage(PACKAGE_KEY, false) + new SinglePackageExportService(testContext).exportPackage(PACKAGE_KEY, false, null) ).rejects.toThrow(`Problem exporting package ${PACKAGE_KEY}`); expect(mockWriteFileSync).not.toHaveBeenCalled(); }); + + it("Should push the exported package to a Git branch when --gitBranch is set", async () => { + const packageData = buildSinglePackageZip(); + mockAxiosGet(EXPORT_URL, packageData); + + const extractedDirectory = "/tmp/content-cli-export-temp"; + const extractTempSpy = jest.spyOn(FileService.prototype, "extractZipBufferToTempDirectory").mockReturnValue(extractedDirectory); + const extractDirSpy = jest.spyOn(FileService.prototype, "extractZipBufferToDirectory").mockImplementation(() => undefined); + const pushToBranchSpy = jest.spyOn(GitService.prototype, "pushToBranch").mockResolvedValue(); + + await new SinglePackageExportService(testContext).exportPackage(PACKAGE_KEY, false, "my-branch"); + + expect(mockedAxiosInstance.get).toHaveBeenCalledWith(EXPORT_URL, expect.anything()); + expect(extractTempSpy).toHaveBeenCalledTimes(1); + expect((extractTempSpy.mock.calls[0][0] as Buffer).equals(packageData)).toBe(true); + expect(pushToBranchSpy).toHaveBeenCalledWith(extractedDirectory, "my-branch"); + expect(fs.rmSync).toHaveBeenCalledWith(extractedDirectory, { recursive: true, force: true }); + expect(loggingTestTransport.logMessages[0].message).toContain("Successfully exported package to branch: my-branch"); + expect(extractDirSpy).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("Should push to the Git branch unzipped even when --zip is also set", async () => { + const packageData = buildSinglePackageZip(); + mockAxiosGet(EXPORT_URL, packageData); + + const extractedDirectory = "/tmp/content-cli-export-temp"; + const extractTempSpy = jest.spyOn(FileService.prototype, "extractZipBufferToTempDirectory").mockReturnValue(extractedDirectory); + const pushToBranchSpy = jest.spyOn(GitService.prototype, "pushToBranch").mockResolvedValue(); + + await new SinglePackageExportService(testContext).exportPackage(PACKAGE_KEY, true, "my-branch"); + + expect(extractTempSpy).toHaveBeenCalledTimes(1); + expect(pushToBranchSpy).toHaveBeenCalledWith(extractedDirectory, "my-branch"); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + expect(loggingTestTransport.logMessages[0].message).toContain("Successfully exported package to branch: my-branch"); + }); }); diff --git a/tests/commands/configuration-management/single-package-import.spec.ts b/tests/commands/configuration-management/single-package-import.spec.ts index 5d74973..ce40054 100644 --- a/tests/commands/configuration-management/single-package-import.spec.ts +++ b/tests/commands/configuration-management/single-package-import.spec.ts @@ -19,6 +19,7 @@ import { SinglePackageImportResult } from "../../../src/commands/configuration-m import { testContext } from "../../utls/test-context"; import { loggingTestTransport, mockWriteFileSync } from "../../jest.setup"; import { FileService } from "../../../src/core/utils/file-service"; +import { GitService } from "../../../src/core/git-profile/git/git.service"; const IMPORT_URL = "https://myTeam.celonis.cloud/pacman/api/core/staging/packages/import-file"; @@ -82,7 +83,7 @@ describe("Single package import", () => { const importResponse = buildImportResponse(); mockAxiosPost(IMPORT_URL, importResponse); - await new SinglePackageImportService(testContext).importPackage("./package.zip", null, overwrite, false); + await new SinglePackageImportService(testContext).importPackage("./package.zip", null, overwrite, false, null); expect(mockedAxiosInstance.post).toHaveBeenCalledWith( IMPORT_URL, @@ -105,7 +106,7 @@ describe("Single package import", () => { const importResponse = buildImportResponse(); mockAxiosPost(IMPORT_URL, importResponse); - await new SinglePackageImportService(testContext).importPackage(null, "./package-dir", true, false); + await new SinglePackageImportService(testContext).importPackage(null, "./package-dir", true, false, null); expect(zipDirectorySpy).toHaveBeenCalledWith("./package-dir"); expect(mockedAxiosInstance.post).toHaveBeenCalledWith(IMPORT_URL, expect.anything(), expect.anything()); @@ -120,7 +121,7 @@ describe("Single package import", () => { const importResponse = buildImportResponse(); mockAxiosPost(IMPORT_URL, importResponse); - await new SinglePackageImportService(testContext).importPackage("./package.zip", null, false, true); + await new SinglePackageImportService(testContext).importPackage("./package.zip", null, false, true, null); const expectedFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; expect(mockWriteFileSync).toHaveBeenCalledWith( @@ -137,7 +138,7 @@ describe("Single package import", () => { mockAxiosPost(IMPORT_URL, buildImportResponse()); - await new SinglePackageImportService(testContext).importPackage("./package.zip", null, true, false); + await new SinglePackageImportService(testContext).importPackage("./package.zip", null, true, false, null); expect(mockedPostRequestBodyByUrl.has(IMPORT_URL)).toBe(true); expect(mockedAxiosInstance.post).toHaveBeenCalledWith( @@ -147,23 +148,59 @@ describe("Single package import", () => { ); }); + it("Should import a single package from a Git branch when --gitBranch is set", async () => { + const packageZip = buildSinglePackageZip(); + const pulledDirectory = "mocked-pulled-git-path"; + const temporaryZipPath = "/tmp/content-cli-imports/single_package_test.zip"; + + const pullSpy = jest.spyOn(GitService.prototype, "pullFromBranch").mockResolvedValue(pulledDirectory); + const zipDirectorySpy = jest.spyOn(FileService.prototype, "zipDirectoryAsSinglePackage").mockReturnValue(temporaryZipPath); + mockReadFileSync(packageZip.toBuffer()); + mockCreateReadStream(packageZip.toBuffer()); + + const importResponse = buildImportResponse(); + mockAxiosPost(IMPORT_URL, importResponse); + + await new SinglePackageImportService(testContext).importPackage(null, null, true, false, "my-branch"); + + expect(pullSpy).toHaveBeenCalledWith("my-branch"); + expect(zipDirectorySpy).toHaveBeenCalledWith(pulledDirectory); + expect(mockedAxiosInstance.post).toHaveBeenCalledWith( + IMPORT_URL, + expect.anything(), + expect.objectContaining({ params: { overwrite: true } }) + ); + expect(loggingTestTransport.logMessages[0].message).toContain("Successfully imported package: pkg-1"); + expect(fs.rmSync).toHaveBeenCalledWith(temporaryZipPath); + expect(fs.rmSync).toHaveBeenCalledWith(pulledDirectory, { recursive: true, force: true }); + }); + it("Should throw when both --file and --directory are provided", async () => { await expect( - new SinglePackageImportService(testContext).importPackage("./package.zip", "./package-dir", false, false) + new SinglePackageImportService(testContext).importPackage("./package.zip", "./package-dir", false, false, null) ).rejects.toThrow("You cannot use both --file and --directory options at the same time. Only one import source can be defined."); }); - it("Should throw when neither --file nor --directory is provided", async () => { + it.each(["./package.zip", "./package-dir"])("Should throw when --gitBranch is combined with another source (%s)", async (source: string) => { + const file = source.endsWith(".zip") ? source : null; + const directory = source.endsWith(".zip") ? null : source; + + await expect( + new SinglePackageImportService(testContext).importPackage(file, directory, false, false, "my-branch") + ).rejects.toThrow("You cannot use --file or --directory together with --gitBranch. Only one import source can be defined."); + }); + + it("Should throw when neither --file, --directory, nor --gitBranch is provided", async () => { await expect( - new SinglePackageImportService(testContext).importPackage(null, null, false, false) - ).rejects.toThrow("You must provide either a --file or a --directory option to import a package."); + new SinglePackageImportService(testContext).importPackage(null, null, false, false, null) + ).rejects.toThrow("You must provide a --file, a --directory, or a --gitBranch option to import a package."); }); it("Should throw when the --file option points to a directory", async () => { jest.spyOn(FileService.prototype, "isDirectory").mockReturnValue(true); await expect( - new SinglePackageImportService(testContext).importPackage("./package-dir", null, false, false) + new SinglePackageImportService(testContext).importPackage("./package-dir", null, false, false, null) ).rejects.toThrow("The --file option accepts only zip files."); }); @@ -171,7 +208,7 @@ describe("Single package import", () => { jest.spyOn(FileService.prototype, "isDirectory").mockReturnValue(false); await expect( - new SinglePackageImportService(testContext).importPackage(null, "./package.zip", false, false) + new SinglePackageImportService(testContext).importPackage(null, "./package.zip", false, false, null) ).rejects.toThrow("The --directory option accepts only directories."); }); @@ -188,7 +225,7 @@ describe("Single package import", () => { }); await expect( - new SinglePackageImportService(testContext).importPackage("./package.zip", null, false, false) + new SinglePackageImportService(testContext).importPackage("./package.zip", null, false, false, null) ).rejects.toThrow('Failed to handle zip file "./package.zip": uncompressed size 5.00 GB exceeds the 4 GB limit.'); }); }); diff --git a/tests/core/utils/file.service.spec.ts b/tests/core/utils/file.service.spec.ts index 11c6ffe..fdb7c63 100644 --- a/tests/core/utils/file.service.spec.ts +++ b/tests/core/utils/file.service.spec.ts @@ -137,4 +137,24 @@ describe("FileService", () => { expect(JSON.parse(fs.readFileSync(path.join(targetDir, "package.json"), "utf-8"))).toEqual({ key: "pkg-1" }); }); }); + + describe("extractZipBufferToTempDirectory", () => { + test("Should extract the package zip buffer into a fresh temp directory and return its path", () => { + const zip = new AdmZip(); + zip.addFile("package.json", Buffer.from(JSON.stringify({ key: "pkg-1" }))); + zip.addFile("nodes/node-1.json", Buffer.from(JSON.stringify({ key: "node-1" }))); + const zipBuffer = zip.toBuffer(); + + const extractedDir = fileService.extractZipBufferToTempDirectory(zipBuffer); + + try { + expect(extractedDir.startsWith(os.tmpdir())).toBe(true); + expect(fs.existsSync(path.join(extractedDir, "package.json"))).toBe(true); + expect(fs.existsSync(path.join(extractedDir, "nodes", "node-1.json"))).toBe(true); + expect(JSON.parse(fs.readFileSync(path.join(extractedDir, "package.json"), "utf-8"))).toEqual({ key: "pkg-1" }); + } finally { + fs.rmSync(extractedDir, { recursive: true, force: true }); + } + }); + }); });