diff --git a/docs/user-guide/branch-commands.md b/docs/user-guide/branch-commands.md index 33907a7d..51775604 100644 --- a/docs/user-guide/branch-commands.md +++ b/docs/user-guide/branch-commands.md @@ -1,6 +1,6 @@ # Branch Commands (beta) -The `config branch` command group lets you author and merge branches from the CLI, and optionally mirror a branch to a Git branch one-to-one. +The `config branch` command group lets you author and merge branches from the CLI, and optionally mirror a branch to a Git branch one-to-one. The related [`config pointer`](#select-a-branch-as-live-config-pointer) group selects which branch a package's consumers read. ## Concepts @@ -143,6 +143,69 @@ Worked example: the preview reports that for node `node-1`, the source set `/tit } ``` +## Select a branch as LIVE (`config pointer`) + +The `config pointer` group decides which branch of a package its consumers read. Selecting a branch as +LIVE is how you release a branch without merging it back into main. + +This group needs the `pacman.live-branch-pointer` feature to be active for the team. + +### Concepts + +- **LIVE selection** — a named pointer on a main package. While it is set, a consumer that references + the main package resolves to the selected branch instead. Consumers keep referencing the plain + ``, so moving the selection to a new branch needs no change on their side. +- **No selection** — the default. Consumers read the main package. + +Both commands take `--packageKey` as the **main** package key. Passing a `@` +value is rejected before any request is sent; name the branch with `--branchKey` instead. + +### Set the LIVE selection + +```bash +content-cli config pointer set --packageKey --branchKey +``` + +`--json` writes the raw `PackagePointerTransport` payload to a file in the working directory. + +There is no dry-run flag. `set` always changes the selection when it succeeds, so validate the branch +first with `config package validate --packageKey @`. + +`main` cannot be selected. To return consumers to the main package, merge the branch into main with +[`config branch merge apply`](#preview-and-apply-merges) — there is no CLI command that clears a +selection, because leaving consumers pointed at nothing is not a state the CLI will produce. + +### Read the LIVE selection + +```bash +content-cli config pointer get --packageKey +content-cli config pointer get --packageKey --json +``` + +When no branch is selected, the command reports that and exits successfully. With `--json` it writes +`null`. + +### Responses worth recognizing + +| Response | Meaning | What to do | +|---|---|---| +| `409` with `package-pointer-blocking-problems` | The branch has problems that block a release. | Run `config package validate --packageKey @`, fix what it reports, then set the selection again. There is no override flag. | +| `403` | Ambiguous. Either the profile may not edit the package, **or** `pacman.live-branch-pointer` is inactive for the team. The response body is empty and does not distinguish the two. | Confirm the feature is active before asking for permissions. | +| `400` | `--packageKey` was not a main key, or the composed branch key does not name a branch. | Check both keys. `config branch list --packageKey ` shows the valid branch keys. | + +### Where this sits in a release + +```bash +content-cli config package validate --packageKey my-package +content-cli config versions create --packageKey my-package --versionBumpOption PATCH +content-cli config branch create --packageKey my-package --branchKey release-branch --sourceVersion 1.4.0 +content-cli config package validate --packageKey my-package@release-branch +content-cli config pointer set --packageKey my-package --branchKey release-branch +``` + +Run the branch's pipelines and transformations, and refresh any cached perspectives, before the last +step. The problems those steps clear are the same ones the `409` reports. + ## Branch export / import `config branch export` and `config branch import` move a branch's contents in and out of the package. They behave like `config package export` / `config package import`, with one difference: they always rewrite `package.json#key`, so a branch's exported content lines up with the main package's. diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index 5fb464b7..10fb9ba2 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -6,7 +6,7 @@ Content CLI organizes its commands into groups by area. Each group covers a spec |---|-------------------------------------------------------------------------------| | [Studio Commands](./studio-commands.md) | Pull and push packages, assets, spaces, and widgets to and from Studio | | [Config Commands](./config-commands.md) | List, batch export, and import all packages and their configurations | -| [Branch Commands](./branch-commands.md) | Create, list, merge, and delete package branches, and mirror them to Git | +| [Branch Commands](./branch-commands.md) | Create, list, merge, and delete package branches, select which branch is LIVE, and mirror them to Git | | [Deployment Commands](./deployment-commands.md) | Create deployments, list history, check active deployments, and manage targets | | [Asset Registry Commands](./asset-registry-commands.md) | Discover registered asset types and their service descriptors | | [Data Pool Commands](./data-pool-commands.md) | Export and import Data Pools with their dependencies | diff --git a/src/commands/configuration-management/module.ts b/src/commands/configuration-management/module.ts index 90691247..bc284eb9 100644 --- a/src/commands/configuration-management/module.ts +++ b/src/commands/configuration-management/module.ts @@ -19,6 +19,7 @@ import { SinglePackageImportService } from "./single-package-import.service"; import { SinglePackageExportService } from "./single-package-export.service"; import { BranchCommandService } from "./branch/branch.command.service"; import { BranchExportImportCommandService } from "./branch/branch-export-import.command.service"; +import { PointerCommandService } from "./pointer/pointer.command.service"; import { BranchUtils } from "../../core/utils/branches"; class Module extends IModule { @@ -105,6 +106,22 @@ class Module extends IModule { .option("--json", "Write response to a JSON file", false) .action(this.importBranch); + const pointerCommand = configCommand.command("pointer").beta() + .description("Select which branch of a package consumers read (the LIVE selection)"); + + pointerCommand.command("set").beta() + .description("Select a branch as LIVE, so consumers of the main package read that branch") + .requiredOption("--packageKey ", "Main package key (no '@')") + .requiredOption("--branchKey ", "Branch key to select as LIVE") + .option("--json", "Write response to a JSON file", false) + .action(this.setPackagePointer); + + pointerCommand.command("get").beta() + .description("Show which branch is currently LIVE for a main package") + .requiredOption("--packageKey ", "Main package key (no '@')") + .option("--json", "Write response to a JSON file", false) + .action(this.getPackagePointer); + configCommand.command("list") .description("[Deprecated] Use 't2tc package list' instead. List packages in the target team.") .deprecationNotice("'config list' is deprecated and will be removed in a future release. Use 't2tc package list' instead.") @@ -339,6 +356,14 @@ class Module extends IModule { await new BranchCommandService(context).deleteBranch(options.packageKey, options.branchKey); } + private async setPackagePointer(context: Context, command: Command, options: OptionValues): Promise { + await new PointerCommandService(context).setLive(options.packageKey, options.branchKey, !!options.json); + } + + private async getPackagePointer(context: Context, command: Command, options: OptionValues): Promise { + await new PointerCommandService(context).getLive(options.packageKey, !!options.json); + } + private async previewBranchMerge(context: Context, command: Command, options: OptionValues): Promise { await new BranchCommandService(context).mergePreview(options.packageKey, options.sourceKey, options.sourceVersion, !!options.json); } diff --git a/src/commands/configuration-management/pointer/api/pointer.api.ts b/src/commands/configuration-management/pointer/api/pointer.api.ts new file mode 100644 index 00000000..a45230fa --- /dev/null +++ b/src/commands/configuration-management/pointer/api/pointer.api.ts @@ -0,0 +1,117 @@ +import { HttpClient } from "../../../../core/http/http-client"; +import { Context } from "../../../../core/command/cli-context"; +import { FatalError } from "../../../../core/utils/logger"; +import { + ConflictErrorTransport, + PackagePointerTransport, + SetPackagePointerTransport, +} from "../interfaces/pointer.interfaces"; + +export const BLOCKING_PROBLEMS_ERROR_CODE = "package-pointer-blocking-problems"; + +const STATUS_NO_CONTENT = 204; +const STATUS_FIRST_ERROR = 400; +const STATUS_FORBIDDEN = 403; +const STATUS_NOT_FOUND = 404; +const STATUS_CONFLICT = 409; + +const AMBIGUOUS_FORBIDDEN_MESSAGE = + "The package pointer API answered 403 with an empty body. Two causes produce exactly this response and it " + + "does not distinguish them: the profile may not edit the package, or the 'pacman.live-branch-pointer' " + + "feature is inactive for the team. Confirm the feature is active before requesting permissions."; + +export interface PointerLookup { + pointer: PackagePointerTransport | null; + detail?: string; +} + +export class PointerApi { + private readonly httpClient: () => HttpClient; + + constructor(context: Context) { + this.httpClient = () => context.httpClient; + } + + public async setPointer( + packageKey: string, + transport: SetPackagePointerTransport, + ): Promise { + const { status, data } = await this.httpClient().putStatusAndData( + PointerApi.pointerUrl(packageKey), + transport, + ); + + if (status >= STATUS_FIRST_ERROR) { + PointerApi.fail(status, data, transport.branchPackageKey); + } + + return PointerApi.hasNoPayload(status, data) ? null : (data as PackagePointerTransport); + } + + public async getPointer(packageKey: string): Promise { + const { status, data } = await this.httpClient().getStatusAndData(PointerApi.pointerUrl(packageKey)); + + if (status === STATUS_NOT_FOUND) { + return { pointer: null, detail: PointerApi.messageOf(data) }; + } + + if (status >= STATUS_FIRST_ERROR) { + PointerApi.fail(status, data, packageKey); + } + + if (PointerApi.hasNoPayload(status, data)) { + return { pointer: null }; + } + + return { pointer: data as PackagePointerTransport }; + } + + private static pointerUrl(packageKey: string): string { + return `/pacman/api/core/pointers/packages/${encodeURIComponent(packageKey)}`; + } + + private static hasNoPayload(status: number, data: unknown): boolean { + return status === STATUS_NO_CONTENT || data === undefined || data === null || data === ""; + } + + private static fail(status: number, data: unknown, subjectKey: string): never { + if (status === STATUS_FORBIDDEN) { + throw new FatalError(AMBIGUOUS_FORBIDDEN_MESSAGE); + } + + if (status === STATUS_CONFLICT) { + throw new FatalError(PointerApi.conflictMessage(data, subjectKey)); + } + + throw new FatalError(`Package pointer request failed with status ${status}: ${PointerApi.describe(data)}`); + } + + private static conflictMessage(data: unknown, subjectKey: string): string { + const body = (data ?? {}) as ConflictErrorTransport; + const errorCode = body.details?.[0]?.errorCode; + const reason = body.message ?? PointerApi.describe(data); + const head = errorCode ? `${reason} (errorCode: ${errorCode})` : reason; + + if (errorCode !== BLOCKING_PROBLEMS_ERROR_CODE) { + return head; + } + + return ( + `${head}\nThe branch has blocking problems, so it cannot be selected as LIVE. ` + + `Resolve them and retry. To list them, run: ` + + `content-cli config package validate --packageKey ${subjectKey}` + ); + } + + private static messageOf(data: unknown): string | undefined { + const message = (data as { message?: unknown })?.message; + return typeof message === "string" ? message : undefined; + } + + private static describe(data: unknown): string { + if (data === undefined || data === null || data === "") { + return "no response body"; + } + return typeof data === "string" ? data : JSON.stringify(data); + } +} diff --git a/src/commands/configuration-management/pointer/interfaces/pointer.interfaces.ts b/src/commands/configuration-management/pointer/interfaces/pointer.interfaces.ts new file mode 100644 index 00000000..17ed2d6b --- /dev/null +++ b/src/commands/configuration-management/pointer/interfaces/pointer.interfaces.ts @@ -0,0 +1,20 @@ +export interface PackagePointerTransport { + packageKey: string; + pointerName: string; + branchPackageKey: string; + updatedAt?: string; + updatedBy?: string; +} + +export interface SetPackagePointerTransport { + branchPackageKey: string; +} + +export interface ConflictErrorDetailsTransport { + errorCode?: string; +} + +export interface ConflictErrorTransport { + message?: string; + details?: ConflictErrorDetailsTransport[]; +} diff --git a/src/commands/configuration-management/pointer/pointer.command.service.ts b/src/commands/configuration-management/pointer/pointer.command.service.ts new file mode 100644 index 00000000..70c6445a --- /dev/null +++ b/src/commands/configuration-management/pointer/pointer.command.service.ts @@ -0,0 +1,99 @@ +import { v4 as uuidv4 } from "uuid"; +import { Context } from "../../../core/command/cli-context"; +import { FileService } from "../../../core/utils/file-service"; +import { CuiFileService } from "../../../core/utils/cui-file-service"; +import { logger } from "../../../core/utils/logger"; +import { BranchUtils } from "../../../core/utils/branches"; +import { PointerApi } from "./api/pointer.api"; +import { PackagePointerTransport, SetPackagePointerTransport } from "./interfaces/pointer.interfaces"; + +export class PointerCommandService { + private readonly pointerApi: PointerApi; + private readonly cuiFileService: CuiFileService; + + constructor(context: Context) { + this.pointerApi = new PointerApi(context); + this.cuiFileService = new CuiFileService(context); + } + + public async setLive( + packageKey: string, + branchKey: string, + jsonResponse: boolean, + ): Promise { + PointerCommandService.requireMainPackageKey(packageKey); + + if (branchKey === BranchUtils.MAIN_BRANCH_KEY) { + throw new Error( + `'${BranchUtils.MAIN_BRANCH_KEY}' cannot be selected as LIVE. Select one of the package's branches.`, + ); + } + + const branchPackageKey = BranchUtils.constructBranchKey(packageKey, branchKey); + const transport: SetPackagePointerTransport = { branchPackageKey }; + const result = await this.pointerApi.setPointer(packageKey, transport); + + if (jsonResponse) { + await this.writeJson(result); + } else if (result) { + PointerCommandService.printPointer(result); + } else { + logger.info(`${branchPackageKey} is now LIVE for ${packageKey}.`); + } + + return result; + } + + public async getLive(packageKey: string, jsonResponse: boolean): Promise { + PointerCommandService.requireMainPackageKey(packageKey); + + const { pointer, detail } = await this.pointerApi.getPointer(packageKey); + + if (jsonResponse) { + await this.writeJson(pointer); + return pointer; + } + + if (pointer) { + PointerCommandService.printPointer(pointer); + return pointer; + } + + logger.info(`No LIVE selection for ${packageKey}. Consumers read the main package.`); + if (detail) { + logger.info(detail); + } + + return null; + } + + private static requireMainPackageKey(packageKey: string): void { + if (BranchUtils.isBranchPackageKey(packageKey)) { + throw new Error( + `--packageKey must be the main package key, without '@'. Received '${packageKey}'. ` + + `Pass the branch through --branchKey instead.`, + ); + } + } + + private static printPointer(pointer: PackagePointerTransport): void { + logger.info(`Package Key: ${pointer.packageKey}`); + logger.info(`Pointer Name: ${pointer.pointerName}`); + logger.info(`Branch Package Key: ${pointer.branchPackageKey}`); + if (pointer.updatedBy) { + logger.info(`Updated By: ${pointer.updatedBy}`); + } + if (pointer.updatedAt) { + logger.info(`Updated At: ${pointer.updatedAt}`); + } + } + + private async writeJson(payload: unknown): Promise { + const filename = `${uuidv4()}.json`; + const writtenFilename = await this.cuiFileService.writeToFileWithGivenName( + JSON.stringify(payload, null, 2), + filename, + ); + logger.info(FileService.fileDownloadedMessage + writtenFilename); + } +} diff --git a/src/core/http/http-client.ts b/src/core/http/http-client.ts index a66b62a2..ea193789 100644 --- a/src/core/http/http-client.ts +++ b/src/core/http/http-client.ts @@ -52,6 +52,23 @@ export class HttpClient { }); } + public async putStatusAndData(url: string, body: object): Promise<{ status: number; data: any }> { + const fullUrl = this.resolveUrl(url); + logger.debug(`HttpClient - PUT ${fullUrl}`); + return this.axios.put(fullUrl, JSON.stringify(body), { + headers: this.buildHeaders("application/json;charset=utf-8"), + validateStatus: () => true, + }).then(response => { + logger.debug(`Response ${response.status}`); + return { status: response.status, data: response.data }; + }).catch(err => { + if (err.response) { + return { status: err.response.status, data: err.response.data }; + } + throw new FatalError(err); + }); + } + public async getFile(url: string): Promise { return new Promise((resolve, reject) => { this.axios.get(this.resolveUrl(url), { diff --git a/tests/commands/configuration-management/pointer/pointer-get.spec.ts b/tests/commands/configuration-management/pointer/pointer-get.spec.ts new file mode 100644 index 00000000..1061430e --- /dev/null +++ b/tests/commands/configuration-management/pointer/pointer-get.spec.ts @@ -0,0 +1,74 @@ +import { mockAxiosGet, mockAxiosGetError, mockAxiosGetWithStatus } from "../../../utls/http-requests-mock"; +import { PointerCommandService } from "../../../../src/commands/configuration-management/pointer/pointer.command.service"; +import { testContext } from "../../../utls/test-context"; +import { loggingTestTransport } from "../../../jest.setup"; +import { getJsonFromDownloadedFile } from "../../../utls/fs-utils"; +import { PackagePointerTransport } from "../../../../src/commands/configuration-management/pointer/interfaces/pointer.interfaces"; + +describe("pointer get", () => { + const packageKey = "my-package"; + const branchPackageKey = `${packageKey}@release-branch`; + const apiUrl = `https://myTeam.celonis.cloud/pacman/api/core/pointers/packages/${packageKey}`; + + const pointer: PackagePointerTransport = { + packageKey, + pointerName: "LIVE", + branchPackageKey, + updatedBy: "someone", + updatedAt: "2026-08-28T10:00:00Z", + }; + + const logged = (fragment: string): boolean => + loggingTestTransport.logMessages.some(message => message.message.includes(fragment)); + + it("prints the LIVE branch", async () => { + mockAxiosGet(apiUrl, pointer); + + const result = await new PointerCommandService(testContext).getLive(packageKey, false); + + expect(result).toEqual(pointer); + expect(logged(`Branch Package Key: ${branchPackageKey}`)).toBe(true); + }); + + it("reports no selection instead of an empty object when the backend answers 404", async () => { + const backendMessage = `Package pointer 'LIVE' not found for package '${packageKey}'`; + mockAxiosGetError(apiUrl, 404, { message: backendMessage }); + + const result = await new PointerCommandService(testContext).getLive(packageKey, false); + + expect(result).toBeNull(); + expect(logged(`No LIVE selection for ${packageKey}.`)).toBe(true); + expect(logged(backendMessage)).toBe(true); + }); + + it("reports no selection when the backend answers 204", async () => { + mockAxiosGetWithStatus(apiUrl, 204, ""); + + const result = await new PointerCommandService(testContext).getLive(packageKey, false); + + expect(result).toBeNull(); + expect(logged(`No LIVE selection for ${packageKey}.`)).toBe(true); + }); + + it("writes the pointer to a JSON file when jsonResponse=true", async () => { + mockAxiosGet(apiUrl, pointer); + + await new PointerCommandService(testContext).getLive(packageKey, true); + + expect(getJsonFromDownloadedFile()).toEqual(pointer); + }); + + it("names both causes of an empty 403 without choosing between them", async () => { + mockAxiosGetError(apiUrl, 403, ""); + + await expect(new PointerCommandService(testContext).getLive(packageKey, false)).rejects.toThrow( + /pacman\.live-branch-pointer/ + ); + }); + + it("rejects a branch package key in --packageKey before calling the backend", async () => { + await expect(new PointerCommandService(testContext).getLive(branchPackageKey, false)).rejects.toThrow( + /--packageKey must be the main package key/ + ); + }); +}); diff --git a/tests/commands/configuration-management/pointer/pointer-set.spec.ts b/tests/commands/configuration-management/pointer/pointer-set.spec.ts new file mode 100644 index 00000000..e1de055c --- /dev/null +++ b/tests/commands/configuration-management/pointer/pointer-set.spec.ts @@ -0,0 +1,109 @@ +import { mockAxiosPut, mockAxiosPutError, mockedPostRequestBodyByUrl } from "../../../utls/http-requests-mock"; +import { PointerCommandService } from "../../../../src/commands/configuration-management/pointer/pointer.command.service"; +import { testContext } from "../../../utls/test-context"; +import { loggingTestTransport } from "../../../jest.setup"; +import { getJsonFromDownloadedFile } from "../../../utls/fs-utils"; +import { + PackagePointerTransport, + SetPackagePointerTransport, +} from "../../../../src/commands/configuration-management/pointer/interfaces/pointer.interfaces"; + +describe("pointer set", () => { + const packageKey = "my-package"; + const branchKey = "release-branch"; + const branchPackageKey = `${packageKey}@${branchKey}`; + const apiUrl = `https://myTeam.celonis.cloud/pacman/api/core/pointers/packages/${packageKey}`; + + const pointer: PackagePointerTransport = { + packageKey, + pointerName: "LIVE", + branchPackageKey, + updatedBy: "someone", + updatedAt: "2026-08-28T10:00:00Z", + }; + + const logged = (fragment: string): boolean => + loggingTestTransport.logMessages.some(message => message.message.includes(fragment)); + + it("selects the branch as LIVE and prints the resulting pointer", async () => { + mockAxiosPut(apiUrl, pointer); + + await new PointerCommandService(testContext).setLive(packageKey, branchKey, false); + + const requestBody: SetPackagePointerTransport = JSON.parse(mockedPostRequestBodyByUrl.get(apiUrl) as string); + expect(requestBody).toEqual({ branchPackageKey }); + expect(logged(`Branch Package Key: ${branchPackageKey}`)).toBe(true); + }); + + it("sends no validate query parameter, which the backend would ignore while still releasing", async () => { + mockAxiosPut(apiUrl, pointer); + + await new PointerCommandService(testContext).setLive(packageKey, branchKey, false); + + expect([...mockedPostRequestBodyByUrl.keys()]).toEqual([apiUrl]); + }); + + it("writes the pointer to a JSON file when jsonResponse=true", async () => { + mockAxiosPut(apiUrl, pointer); + + await new PointerCommandService(testContext).setLive(packageKey, branchKey, true); + + expect(getJsonFromDownloadedFile()).toEqual(pointer); + }); + + it("confirms the selection when the backend answers without a body", async () => { + mockAxiosPut(apiUrl, undefined); + + await new PointerCommandService(testContext).setLive(packageKey, branchKey, false); + + expect(logged(`${branchPackageKey} is now LIVE for ${packageKey}.`)).toBe(true); + }); + + it("reports blocking problems and the validate follow-up on 409", async () => { + mockAxiosPutError(apiUrl, 409, { + message: "The branch has 2 blocking problems.", + details: [{ errorCode: "package-pointer-blocking-problems" }], + }); + + await expect(new PointerCommandService(testContext).setLive(packageKey, branchKey, false)).rejects.toThrow( + /package-pointer-blocking-problems/ + ); + await expect(new PointerCommandService(testContext).setLive(packageKey, branchKey, false)).rejects.toThrow( + new RegExp(`config package validate --packageKey ${branchPackageKey}`) + ); + }); + + it("passes a conflict that is not about blocking problems through unchanged", async () => { + mockAxiosPutError(apiUrl, 409, { message: "Something else conflicted.", details: [{ errorCode: "other" }] }); + + await expect(new PointerCommandService(testContext).setLive(packageKey, branchKey, false)).rejects.toThrow( + /Something else conflicted/ + ); + await expect(new PointerCommandService(testContext).setLive(packageKey, branchKey, false)).rejects.not.toThrow( + /config package validate/ + ); + }); + + it("names both causes of an empty 403 without choosing between them", async () => { + mockAxiosPutError(apiUrl, 403, ""); + + await expect(new PointerCommandService(testContext).setLive(packageKey, branchKey, false)).rejects.toThrow( + /pacman\.live-branch-pointer/ + ); + await expect(new PointerCommandService(testContext).setLive(packageKey, branchKey, false)).rejects.toThrow( + /may not edit the package/ + ); + }); + + it("rejects a branch package key in --packageKey before calling the backend", async () => { + await expect(new PointerCommandService(testContext).setLive(branchPackageKey, branchKey, false)).rejects.toThrow( + /--packageKey must be the main package key/ + ); + }); + + it("rejects selecting main as LIVE", async () => { + await expect(new PointerCommandService(testContext).setLive(packageKey, "main", false)).rejects.toThrow( + /cannot be selected as LIVE/ + ); + }); +}); diff --git a/tests/integration/commands/configuration-management.spec.ts b/tests/integration/commands/configuration-management.spec.ts index c5a48ec7..3ba41f58 100644 --- a/tests/integration/commands/configuration-management.spec.ts +++ b/tests/integration/commands/configuration-management.spec.ts @@ -10,6 +10,7 @@ import { SinglePackageImportService } from "../../../src/commands/configuration- import { SinglePackageExportService } from "../../../src/commands/configuration-management/single-package-export.service"; import { BranchCommandService } from "../../../src/commands/configuration-management/branch/branch.command.service"; import { BranchExportImportCommandService } from "../../../src/commands/configuration-management/branch/branch-export-import.command.service"; +import { PointerCommandService } from "../../../src/commands/configuration-management/pointer/pointer.command.service"; import { CliRunResult, runCli as runCliProcess } from "../../utls/cli-runner"; jest.mock("../../../src/commands/configuration-management/config-command.service"); @@ -23,6 +24,7 @@ jest.mock("../../../src/commands/configuration-management/single-package-import. jest.mock("../../../src/commands/configuration-management/single-package-export.service"); jest.mock("../../../src/commands/configuration-management/branch/branch.command.service"); jest.mock("../../../src/commands/configuration-management/branch/branch-export-import.command.service"); +jest.mock("../../../src/commands/configuration-management/pointer/pointer.command.service"); describe("configuration-management command integration", () => { let mockConfigCommandService: jest.Mocked; @@ -36,6 +38,7 @@ describe("configuration-management command integration", () => { let mockSinglePackageExportService: jest.Mocked; let mockBranchCommandService: jest.Mocked; let mockBranchExportImportCommandService: jest.Mocked; + let mockPointerCommandService: jest.Mocked; beforeEach(() => { mockConfigCommandService = { @@ -90,6 +93,11 @@ describe("configuration-management command integration", () => { importBranch: jest.fn().mockResolvedValue(undefined), } as any; + mockPointerCommandService = { + setLive: jest.fn().mockResolvedValue(undefined), + getLive: jest.fn().mockResolvedValue(undefined), + } as any; + (ConfigCommandService as jest.MockedClass).mockImplementation(() => mockConfigCommandService); (StagingPackageService as jest.MockedClass).mockImplementation(() => mockStagingPackageService); (MetadataService as jest.MockedClass).mockImplementation(() => mockMetadataService); @@ -101,6 +109,7 @@ describe("configuration-management command integration", () => { (SinglePackageExportService as jest.MockedClass).mockImplementation(() => mockSinglePackageExportService); (BranchCommandService as jest.MockedClass).mockImplementation(() => mockBranchCommandService); (BranchExportImportCommandService as jest.MockedClass).mockImplementation(() => mockBranchExportImportCommandService); + (PointerCommandService as jest.MockedClass).mockImplementation(() => mockPointerCommandService); }); let lastResult: CliRunResult; @@ -317,6 +326,49 @@ describe("configuration-management command integration", () => { }); }); + describe("config pointer set (setLive)", () => { + it("forwards the main package key and branch key", async () => { + const result = await runCli(["config", "pointer", "set", "--packageKey", "myPackage", "--branchKey", "release-branch"]); + + expect(result.exitCode).toBe(0); + expect(mockPointerCommandService.setLive).toHaveBeenCalledWith("myPackage", "release-branch", false); + }); + + it("forwards --json", async () => { + const result = await runCli([ + "config", "pointer", "set", + "--packageKey", "myPackage", + "--branchKey", "release-branch", + "--json", + ]); + + expect(result.exitCode).toBe(0); + expect(mockPointerCommandService.setLive).toHaveBeenCalledWith("myPackage", "release-branch", true); + }); + + it("requires --branchKey, so no partial selection is attempted", async () => { + await runCli(["config", "pointer", "set", "--packageKey", "myPackage"]); + + expect(mockPointerCommandService.setLive).not.toHaveBeenCalled(); + }); + }); + + describe("config pointer get (getLive)", () => { + it("forwards the main package key", async () => { + const result = await runCli(["config", "pointer", "get", "--packageKey", "myPackage"]); + + expect(result.exitCode).toBe(0); + expect(mockPointerCommandService.getLive).toHaveBeenCalledWith("myPackage", false); + }); + + it("forwards --json", async () => { + const result = await runCli(["config", "pointer", "get", "--packageKey", "myPackage", "--json"]); + + expect(result.exitCode).toBe(0); + expect(mockPointerCommandService.getLive).toHaveBeenCalledWith("myPackage", true); + }); + }); + describe("config branch merge apply (mergeApply)", () => { it("forwards --newVersion, which the root program's --version would otherwise shadow", async () => { const result = await runCli([ diff --git a/tests/utls/http-requests-mock.ts b/tests/utls/http-requests-mock.ts index 50734e5b..073e8dca 100644 --- a/tests/utls/http-requests-mock.ts +++ b/tests/utls/http-requests-mock.ts @@ -13,6 +13,7 @@ const mockedPostResponseByUrl = new Map(); const mockedPostErrorByUrl = new Map(); const mockedPostRequestBodyByUrl = new Map(); const mockedPutErrorByUrl = new Map(); +const mockedPutStatusByUrl = new Map(); const mockedDeleteResponseByUrl = new Map(); const mockAxios = () : void => { @@ -72,7 +73,10 @@ const mockAxios = () : void => { return Promise.reject({ response: { status, data: errorData } }); } if (mockedPostResponseByUrl.has(requestUrl)) { - const response = { data: mockedPostResponseByUrl.get(requestUrl) }; + const response = { + data: mockedPostResponseByUrl.get(requestUrl), + status: mockedPutStatusByUrl.get(requestUrl) ?? 200, + }; mockedPostRequestBodyByUrl.set(requestUrl, data); return Promise.resolve(response); @@ -110,6 +114,13 @@ const mockAxiosPostError = (url: string, status: number, data: any) => { const mockAxiosPut = (url: string, responseData: any) => { mockedPostResponseByUrl.set(url, responseData); + mockedPutStatusByUrl.delete(url); + mockedPutErrorByUrl.delete(url); +}; + +const mockAxiosPutWithStatus = (url: string, status: number, responseData: any) => { + mockedPostResponseByUrl.set(url, responseData); + mockedPutStatusByUrl.set(url, status); mockedPutErrorByUrl.delete(url); }; @@ -137,6 +148,7 @@ afterEach(() => { mockedPostErrorByUrl.clear(); mockedPostRequestBodyByUrl.clear(); mockedPutErrorByUrl.clear(); + mockedPutStatusByUrl.clear(); mockedDeleteResponseByUrl.clear(); }) @@ -149,6 +161,7 @@ export { mockAxiosPost, mockAxiosPostError, mockAxiosPut, + mockAxiosPutWithStatus, mockAxiosPutError, mockAxiosDelete, mockedPostRequestBodyByUrl