diff --git a/packages/aio-commerce-lib-app/test/fixtures/actions.ts b/packages/aio-commerce-lib-app/test/fixtures/actions.ts new file mode 100644 index 000000000..04b1d4bf6 --- /dev/null +++ b/packages/aio-commerce-lib-app/test/fixtures/actions.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { RuntimeActionParams } from "@adobe/aio-commerce-lib-core/params"; + +type CreateRuntimeActionParamsArgs = RuntimeActionParams & { + method?: RuntimeActionParams["__ow_method"]; + path?: string; + body?: unknown; + query?: string; + headers?: Record; +}; + +/** Builds OpenWhisk-style runtime action params for handler tests. */ +export function createRuntimeActionParams( + args: CreateRuntimeActionParamsArgs = {}, +): RuntimeActionParams { + const { method = "get", path = "/", body, query, headers, ...params } = args; + + return { + ...params, + __ow_method: method, + __ow_path: path, + ...(body === undefined ? {} : { __ow_body: JSON.stringify(body) }), + ...(query ? { __ow_query: query } : {}), + ...(headers ? { __ow_headers: headers } : {}), + }; +} diff --git a/packages/aio-commerce-lib-app/test/unit/actions/app-config.test.ts b/packages/aio-commerce-lib-app/test/unit/actions/app-config.test.ts new file mode 100644 index 000000000..9ccca56bd --- /dev/null +++ b/packages/aio-commerce-lib-app/test/unit/actions/app-config.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { describe, expect, test } from "vitest"; + +import { appConfigRuntimeAction } from "#actions/app-config"; +import { createRuntimeActionParams } from "#test/fixtures/actions"; +import { minimalValidConfig } from "#test/fixtures/config"; + +import type { CommerceAppConfigOutputModel } from "#config/schema/app"; + +describe("appConfigRuntimeAction", () => { + test("returns the validated app config for GET /", async () => { + const handler = appConfigRuntimeAction({ + appConfig: minimalValidConfig, + }); + + const result = await handler(createRuntimeActionParams()); + + expect(result).toEqual({ + type: "success", + statusCode: 200, + body: minimalValidConfig, + }); + }); + + test("returns a 500 error when the app config is invalid", async () => { + const handler = appConfigRuntimeAction({ + appConfig: {} as unknown as CommerceAppConfigOutputModel, + }); + + const result = await handler(createRuntimeActionParams()); + + expect(result).toMatchObject({ + type: "error", + error: { + statusCode: 500, + body: { + message: "Internal server error", + error: "Invalid commerce app config", + }, + }, + }); + }); +}); diff --git a/packages/aio-commerce-lib-app/test/unit/actions/config.test.ts b/packages/aio-commerce-lib-app/test/unit/actions/config.test.ts new file mode 100644 index 000000000..7578abec5 --- /dev/null +++ b/packages/aio-commerce-lib-app/test/unit/actions/config.test.ts @@ -0,0 +1,224 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const { + byScopeIdMock, + getConfigurationMock, + initializeMock, + setConfigurationMock, +} = vi.hoisted(() => ({ + byScopeIdMock: vi.fn((scopeId: string) => ({ scopeId })), + getConfigurationMock: vi.fn(), + initializeMock: vi.fn(), + setConfigurationMock: vi.fn(), +})); + +vi.mock("@adobe/aio-commerce-lib-config", async () => { + const actual = await vi.importActual< + typeof import("@adobe/aio-commerce-lib-config") + >("@adobe/aio-commerce-lib-config"); + + return { + ...actual, + byScopeId: byScopeIdMock, + getConfiguration: getConfigurationMock, + initialize: initializeMock, + setConfiguration: setConfigurationMock, + }; +}); + +import { configRuntimeAction } from "#actions/config"; +import { createRuntimeActionParams } from "#test/fixtures/actions"; + +import type { BusinessConfigSchema } from "@adobe/aio-commerce-lib-config"; + +const configSchema = [ + { + name: "apiKey", + label: "API Key", + description: "Commerce API key", + type: "password", + default: "", + }, + { + name: "mode", + label: "Mode", + description: "App mode", + type: "text", + default: "sandbox", + }, +] satisfies BusinessConfigSchema; + +describe("configRuntimeAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + byScopeIdMock.mockImplementation((scopeId: string) => ({ scopeId })); + }); + + test("masks password values when retrieving configuration", async () => { + getConfigurationMock.mockResolvedValue({ + scopeId: "store-1", + config: [ + { name: "apiKey", value: "super-secret", origin: "scope" }, + { name: "mode", value: "live", origin: "scope" }, + ], + }); + + const handler = configRuntimeAction({ configSchema }); + + const result = await handler( + createRuntimeActionParams({ + query: "scopeId=store-1", + AIO_COMMERCE_CONFIG_ENCRYPTION_KEY: "encryption-key", + }), + ); + + expect(initializeMock).toHaveBeenCalledWith({ schema: configSchema }); + expect(byScopeIdMock).toHaveBeenCalledWith("store-1"); + expect(getConfigurationMock).toHaveBeenCalledWith( + { scopeId: "store-1" }, + { encryptionKey: "encryption-key" }, + ); + expect(result).toEqual({ + type: "success", + statusCode: 200, + body: { + schema: configSchema, + values: { + scopeId: "store-1", + config: [ + { name: "apiKey", value: "*****", origin: "scope" }, + { name: "mode", value: "live", origin: "scope" }, + ], + }, + }, + }); + }); + + test("returns a 400 error when the scope id query parameter is missing", async () => { + const handler = configRuntimeAction({ configSchema }); + + const result = await handler(createRuntimeActionParams()); + + expect(result).toMatchObject({ + type: "error", + error: { + statusCode: 400, + body: { + message: "Invalid query parameters", + }, + }, + }); + }); + + test("filters masked password values before saving with PUT /", async () => { + setConfigurationMock.mockResolvedValue({ + scopeId: "store-1", + config: [ + { name: "apiKey", value: "updated-secret", origin: "scope" }, + { name: "mode", value: "live", origin: "scope" }, + ], + }); + + const handler = configRuntimeAction({ configSchema }); + + const result = await handler( + createRuntimeActionParams({ + method: "put", + body: { + scopeId: "store-1", + config: [ + { name: "apiKey", value: "*****" }, + { name: "mode", value: "live" }, + ], + }, + }), + ); + + expect(initializeMock).toHaveBeenCalledWith({ schema: configSchema }); + expect(setConfigurationMock).toHaveBeenCalledWith( + { + config: [{ name: "mode", value: "live" }], + }, + { scopeId: "store-1" }, + { encryptionKey: undefined }, + ); + expect(result).toEqual({ + type: "success", + statusCode: 200, + headers: { + "Cache-Control": "no-store", + Deprecation: "Wed, 15 Apr 2026 00:00:00 GMT", + }, + body: { + scopeId: "store-1", + config: [ + { name: "apiKey", value: "*****", origin: "scope" }, + { name: "mode", value: "live", origin: "scope" }, + ], + }, + }); + }); + + test("forwards partial updates and null unsets with PATCH /", async () => { + setConfigurationMock.mockResolvedValue({ + scopeId: "store-1", + config: [ + { name: "apiKey", value: "persisted-secret", origin: "scope" }, + { name: "mode", value: "live", origin: "scope" }, + ], + }); + + const handler = configRuntimeAction({ configSchema }); + + const result = await handler( + createRuntimeActionParams({ + method: "patch", + body: { + scopeId: "store-1", + config: [ + { name: "apiKey", value: null }, + { name: "mode", value: "live" }, + ], + }, + AIO_COMMERCE_CONFIG_ENCRYPTION_KEY: "encryption-key", + }), + ); + + expect(setConfigurationMock).toHaveBeenCalledWith( + { + config: [ + { name: "apiKey", value: null }, + { name: "mode", value: "live" }, + ], + }, + { scopeId: "store-1" }, + { encryptionKey: "encryption-key" }, + ); + expect(result).toEqual({ + type: "success", + statusCode: 200, + headers: { + "Cache-Control": "no-store", + }, + body: { + scopeId: "store-1", + config: [ + { name: "apiKey", value: "*****", origin: "scope" }, + { name: "mode", value: "live", origin: "scope" }, + ], + }, + }); + }); +}); diff --git a/packages/aio-commerce-lib-app/test/unit/actions/installation.test.ts b/packages/aio-commerce-lib-app/test/unit/actions/installation.test.ts new file mode 100644 index 000000000..ce3e3938b --- /dev/null +++ b/packages/aio-commerce-lib-app/test/unit/actions/installation.test.ts @@ -0,0 +1,784 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const { + invokeMock, + openwhiskMock, + createCombinedStoreMock, + createInitialInstallationStateMock, + createInitialUninstallationStateMock, + isCompletedStateMock, + isFailedStateMock, + isInProgressStateMock, + isSucceededStateMock, + runInstallationMock, + runUninstallationMock, + runValidationMock, +} = vi.hoisted(() => { + const invokeMock = vi.fn(); + + return { + invokeMock, + openwhiskMock: vi.fn(() => ({ + actions: { + invoke: invokeMock, + }, + })), + createCombinedStoreMock: vi.fn(), + createInitialInstallationStateMock: vi.fn(), + createInitialUninstallationStateMock: vi.fn(), + isCompletedStateMock: vi.fn(), + isFailedStateMock: vi.fn(), + isInProgressStateMock: vi.fn(), + isSucceededStateMock: vi.fn(), + runInstallationMock: vi.fn(), + runUninstallationMock: vi.fn(), + runValidationMock: vi.fn(), + }; +}); + +vi.mock("@aio-commerce-sdk/common-utils/storage", () => ({ + createCombinedStore: createCombinedStoreMock, +})); + +vi.mock("openwhisk", () => ({ + default: openwhiskMock, +})); + +vi.mock("#management/index", () => ({ + createInitialInstallationState: createInitialInstallationStateMock, + createInitialUninstallationState: createInitialUninstallationStateMock, + isCompletedState: isCompletedStateMock, + isFailedState: isFailedStateMock, + isInProgressState: isInProgressStateMock, + isSucceededState: isSucceededStateMock, + runInstallation: runInstallationMock, + runUninstallation: runUninstallationMock, + runValidation: runValidationMock, +})); + +import { installationRuntimeAction } from "#actions/installation"; +import { createRuntimeActionParams } from "#test/fixtures/actions"; +import { minimalValidConfig } from "#test/fixtures/config"; +import { + createMockFailedState, + createMockInProgressState, + createMockInstallationContext, + createMockSucceededState, + createMockValidationResult, + DEFAULT_INSTALLATION_PARAMS, +} from "#test/fixtures/installation"; + +import type { CommerceAppConfigOutputModel } from "#config/schema/app"; +import type { StepFailedEvent } from "#management/installation/workflow/hooks"; +import type { + InProgressInstallationState, + InstallationState, +} from "#management/installation/workflow/types"; + +type StoreOptions = { + cache?: { + keyPrefix?: string; + }; +}; + +type WorkflowHooks = { + onInstallationStart: (state: InstallationState) => Promise; + onInstallationFailure: (state: InstallationState) => Promise; + onInstallationSuccess: (state: InstallationState) => Promise; + onStepStart: ( + event: { stepName: string }, + state: InstallationState, + ) => Promise; + onStepSuccess: ( + event: { stepName: string }, + state: InstallationState, + ) => Promise; + onStepFailure: ( + event: StepFailedEvent, + state: InstallationState, + ) => Promise; +}; + +type WorkflowRunnerArgs = { + initialState: InProgressInstallationState; + hooks: WorkflowHooks; +}; + +function createMockStore(initialValue: InstallationState | null = null) { + let value = initialValue; + + return { + get: vi.fn(async (_key: string) => value), + put: vi.fn(async (_key: string, nextValue: InstallationState) => { + value = nextValue; + }), + delete: vi.fn(async (_key: string) => { + const hasValue = value !== null; + value = null; + return hasValue; + }), + }; +} + +const appData = createMockInstallationContext().appData; +const requestBody = { + appData, + commerceBaseUrl: "https://commerce.example.com", + commerceEnv: "stage", + ioEventsUrl: "https://events.example.com", + ioEventsEnv: "prod", +}; + +describe("installationRuntimeAction", () => { + let installationStore = createMockStore(); + let uninstallationStore = createMockStore(); + + beforeEach(() => { + vi.clearAllMocks(); + + installationStore = createMockStore(); + uninstallationStore = createMockStore(); + + createCombinedStoreMock.mockImplementation( + async (options?: StoreOptions) => { + const prefix = options?.cache?.keyPrefix; + + if (prefix === "installation") { + return installationStore; + } + + if (prefix === "uninstallation") { + return uninstallationStore; + } + + throw new Error(`Unexpected store prefix: ${String(prefix)}`); + }, + ); + + invokeMock.mockResolvedValue({ activationId: "activation-123" }); + + createInitialInstallationStateMock.mockImplementation(() => + createMockInProgressState({ + id: "installation-1", + }), + ); + + createInitialUninstallationStateMock.mockImplementation(() => + createMockInProgressState({ + id: "uninstallation-1", + }), + ); + + isCompletedStateMock.mockImplementation( + (state: InstallationState) => + state.status === "failed" || state.status === "succeeded", + ); + isFailedStateMock.mockImplementation( + (state: InstallationState) => state.status === "failed", + ); + isInProgressStateMock.mockImplementation( + (state: InstallationState) => state.status === "in-progress", + ); + isSucceededStateMock.mockImplementation( + (state: InstallationState) => state.status === "succeeded", + ); + + runInstallationMock.mockImplementation( + async ({ initialState, hooks }: WorkflowRunnerArgs) => { + const inProgressState = createMockInProgressState({ + id: initialState.id, + }); + const succeededState = createMockSucceededState({ + id: initialState.id, + }); + + await hooks.onInstallationStart(inProgressState); + await hooks.onStepStart({ stepName: "validate" }, inProgressState); + await hooks.onStepSuccess({ stepName: "validate" }, succeededState); + await hooks.onInstallationSuccess(succeededState); + + return succeededState; + }, + ); + + runUninstallationMock.mockImplementation( + async ({ initialState, hooks }: WorkflowRunnerArgs) => { + const inProgressState = createMockInProgressState({ + id: initialState.id, + }); + const succeededState = createMockSucceededState({ + id: initialState.id, + }); + + await hooks.onInstallationStart(inProgressState); + await hooks.onStepStart({ stepName: "cleanup" }, inProgressState); + await hooks.onStepSuccess({ stepName: "cleanup" }, succeededState); + await hooks.onInstallationSuccess(succeededState); + + return succeededState; + }, + ); + + runValidationMock.mockResolvedValue(createMockValidationResult()); + }); + + test("returns 204 when there is no installation state", async () => { + const handler = installationRuntimeAction({ + appConfig: minimalValidConfig, + }); + + const result = await handler(createRuntimeActionParams()); + + expect(createCombinedStoreMock).toHaveBeenCalledWith( + expect.objectContaining({ + cache: expect.objectContaining({ keyPrefix: "installation" }), + persistent: expect.objectContaining({ dirPrefix: "installation" }), + }), + ); + expect(result).toEqual({ + type: "success", + statusCode: 204, + }); + }); + + test("returns installation state when one exists", async () => { + const existingState = createMockInProgressState({ id: "installation-1" }); + installationStore = createMockStore(existingState); + + const handler = installationRuntimeAction({ + appConfig: minimalValidConfig, + }); + + const result = await handler(createRuntimeActionParams()); + + expect(result).toEqual({ + type: "success", + statusCode: 200, + body: existingState, + }); + }); + + test("returns 409 when installation is already in progress", async () => { + installationStore = createMockStore(createMockInProgressState()); + + const handler = installationRuntimeAction({ + appConfig: minimalValidConfig, + }); + + const result = await handler( + createRuntimeActionParams({ + method: "post", + body: requestBody, + ...DEFAULT_INSTALLATION_PARAMS, + }), + ); + + expect(result).toEqual({ + type: "error", + error: { + statusCode: 409, + body: { + message: + "Installation is already in-progress. Wait for it to complete.", + }, + }, + }); + }); + + test("returns 409 when installation already succeeded", async () => { + installationStore = createMockStore(createMockSucceededState()); + + const handler = installationRuntimeAction({ + appConfig: minimalValidConfig, + }); + + const result = await handler( + createRuntimeActionParams({ + method: "post", + body: requestBody, + ...DEFAULT_INSTALLATION_PARAMS, + }), + ); + + expect(result).toEqual({ + type: "error", + error: { + statusCode: 409, + body: { + message: "Installation has already completed successfully.", + }, + }, + }); + }); + + test("returns 500 when installation starts without an app config", async () => { + const handler = installationRuntimeAction({ + appConfig: undefined as unknown as CommerceAppConfigOutputModel, + }); + + const result = await handler( + createRuntimeActionParams({ + method: "post", + body: requestBody, + ...DEFAULT_INSTALLATION_PARAMS, + }), + ); + + expect(result).toEqual({ + type: "error", + error: { + statusCode: 500, + body: { + message: + "Could not find or parse the app.commerce.manifest.json file, is it present and valid?", + }, + }, + }); + }); + + test("starts installation asynchronously and returns the initial state", async () => { + const initialState = createMockInProgressState({ id: "installation-1" }); + createInitialInstallationStateMock.mockReturnValue(initialState); + + const handler = installationRuntimeAction({ + appConfig: minimalValidConfig, + }); + + const result = await handler( + createRuntimeActionParams({ + method: "post", + body: requestBody, + ...DEFAULT_INSTALLATION_PARAMS, + }), + ); + + expect(createInitialInstallationStateMock).toHaveBeenCalledWith({ + config: minimalValidConfig, + }); + expect(installationStore.put).toHaveBeenCalledWith("current", initialState); + expect(openwhiskMock).toHaveBeenCalled(); + expect(invokeMock).toHaveBeenCalledWith({ + name: "app-management/installation", + blocking: false, + result: false, + params: expect.objectContaining({ + ...DEFAULT_INSTALLATION_PARAMS, + appData, + AIO_EVENTS_API_BASE_URL: requestBody.ioEventsUrl, + AIO_COMMERCE_AUTH_IMS_ENVIRONMENT: requestBody.ioEventsEnv, + AIO_COMMERCE_API_BASE_URL: requestBody.commerceBaseUrl, + AIO_COMMERCE_API_FLAVOR: requestBody.commerceEnv, + initialState, + appConfig: minimalValidConfig, + __ow_path: "/execution", + __ow_method: "post", + }), + }); + expect(result).toEqual({ + type: "success", + statusCode: 202, + body: { + message: "Installation started", + activationId: "activation-123", + ...initialState, + }, + }); + }); + + test("returns 400 when installation execution is missing the initial state", async () => { + const handler = installationRuntimeAction({ + appConfig: minimalValidConfig, + }); + + const result = await handler( + createRuntimeActionParams({ + method: "post", + path: "/execution", + appData, + ...DEFAULT_INSTALLATION_PARAMS, + }), + ); + + expect(result).toEqual({ + type: "error", + error: { + statusCode: 400, + body: { + message: "initialState is required for execution", + }, + }, + }); + }); + + test("executes installation and stores the final state", async () => { + const initialState = createMockInProgressState({ id: "installation-1" }); + const handler = installationRuntimeAction({ + appConfig: minimalValidConfig, + }); + + const result = await handler( + createRuntimeActionParams({ + method: "post", + path: "/execution", + initialState, + appData, + ...DEFAULT_INSTALLATION_PARAMS, + }), + ); + + expect(runInstallationMock).toHaveBeenCalledWith( + expect.objectContaining({ + config: minimalValidConfig, + initialState, + }), + ); + expect(installationStore.put).toHaveBeenCalledWith( + "current", + expect.objectContaining({ id: "installation-1", status: "succeeded" }), + ); + expect(result).toEqual({ + type: "success", + statusCode: 200, + body: expect.objectContaining({ + id: "installation-1", + status: "succeeded", + }), + }); + }); + + test("returns 500 when the installation workflow fails", async () => { + const initialState = createMockInProgressState({ id: "installation-1" }); + const failedState = createMockFailedState({ id: "installation-1" }); + + runInstallationMock.mockImplementation( + async ({ + initialState: failedInitialState, + hooks, + }: WorkflowRunnerArgs) => { + const inProgressState = createMockInProgressState({ + id: failedInitialState.id, + }); + + await hooks.onInstallationStart(inProgressState); + await hooks.onStepFailure( + { + path: ["installation", "validate"], + stepName: "validate", + isLeaf: true, + error: failedState.error, + }, + failedState, + ); + await hooks.onInstallationFailure(failedState); + + return failedState; + }, + ); + + const handler = installationRuntimeAction({ + appConfig: minimalValidConfig, + }); + + const result = await handler( + createRuntimeActionParams({ + method: "post", + path: "/execution", + initialState, + appData, + ...DEFAULT_INSTALLATION_PARAMS, + }), + ); + + expect(result).toEqual({ + type: "error", + error: { + statusCode: 500, + body: { + message: "Installation failed", + error: failedState.error, + state: failedState, + }, + }, + }); + }); + + test("returns 500 when validation runs without an app config", async () => { + const handler = installationRuntimeAction({ + appConfig: undefined as unknown as CommerceAppConfigOutputModel, + }); + + const result = await handler( + createRuntimeActionParams({ + method: "post", + path: "/validation", + body: requestBody, + ...DEFAULT_INSTALLATION_PARAMS, + }), + ); + + expect(result).toEqual({ + type: "error", + error: { + statusCode: 500, + body: { + message: + "Could not find or parse the app.commerce.manifest.json file, is it present and valid?", + }, + }, + }); + }); + + test("returns the validation result for POST /validation", async () => { + const validationResult = createMockValidationResult({ valid: false }); + runValidationMock.mockResolvedValue(validationResult); + + const handler = installationRuntimeAction({ + appConfig: minimalValidConfig, + }); + + const result = await handler( + createRuntimeActionParams({ + method: "post", + path: "/validation", + body: requestBody, + ...DEFAULT_INSTALLATION_PARAMS, + }), + ); + + expect(runValidationMock).toHaveBeenCalledWith({ + validationContext: { + appData, + params: expect.objectContaining({ + ...DEFAULT_INSTALLATION_PARAMS, + AIO_EVENTS_API_BASE_URL: requestBody.ioEventsUrl, + AIO_COMMERCE_AUTH_IMS_ENVIRONMENT: requestBody.ioEventsEnv, + AIO_COMMERCE_API_BASE_URL: requestBody.commerceBaseUrl, + AIO_COMMERCE_API_FLAVOR: requestBody.commerceEnv, + }), + logger: expect.anything(), + }, + config: minimalValidConfig, + }); + expect(result).toEqual({ + type: "success", + statusCode: 200, + body: validationResult, + }); + }); + + test("returns uninstallation state when one exists", async () => { + const existingState = createMockInProgressState({ id: "uninstallation-1" }); + uninstallationStore = createMockStore(existingState); + + const handler = installationRuntimeAction({ + appConfig: minimalValidConfig, + }); + + const result = await handler( + createRuntimeActionParams({ + path: "/uninstallation", + }), + ); + + expect(result).toEqual({ + type: "success", + statusCode: 200, + body: existingState, + }); + }); + + test("returns 409 when uninstallation is already in progress", async () => { + uninstallationStore = createMockStore(createMockInProgressState()); + + const handler = installationRuntimeAction({ + appConfig: minimalValidConfig, + }); + + const result = await handler( + createRuntimeActionParams({ + method: "post", + path: "/uninstallation", + body: requestBody, + ...DEFAULT_INSTALLATION_PARAMS, + }), + ); + + expect(result).toEqual({ + type: "error", + error: { + statusCode: 409, + body: { + message: + "Uninstallation is already in progress. Wait for it to complete.", + }, + }, + }); + }); + + test("starts uninstallation asynchronously and returns the initial state", async () => { + const initialState = createMockInProgressState({ id: "uninstallation-1" }); + createInitialUninstallationStateMock.mockReturnValue(initialState); + + const handler = installationRuntimeAction({ + appConfig: minimalValidConfig, + }); + + const result = await handler( + createRuntimeActionParams({ + method: "post", + path: "/uninstallation", + body: requestBody, + ...DEFAULT_INSTALLATION_PARAMS, + }), + ); + + expect(uninstallationStore.put).toHaveBeenCalledWith( + "current", + initialState, + ); + expect(invokeMock).toHaveBeenCalledWith({ + name: "app-management/installation", + blocking: false, + result: false, + params: expect.objectContaining({ + initialState, + appConfig: minimalValidConfig, + __ow_path: "/uninstallation/execution", + __ow_method: "post", + }), + }); + expect(result).toEqual({ + type: "success", + statusCode: 202, + body: { + message: "Uninstallation started", + activationId: "activation-123", + ...initialState, + }, + }); + }); + + test("executes uninstallation and clears the installation state after success", async () => { + const initialState = createMockInProgressState({ id: "uninstallation-1" }); + installationStore = createMockStore( + createMockSucceededState({ id: "installation-1" }), + ); + + const handler = installationRuntimeAction({ + appConfig: minimalValidConfig, + }); + + const result = await handler( + createRuntimeActionParams({ + method: "post", + path: "/uninstallation/execution", + initialState, + appData, + ...DEFAULT_INSTALLATION_PARAMS, + }), + ); + + expect(runUninstallationMock).toHaveBeenCalledWith( + expect.objectContaining({ + config: minimalValidConfig, + initialState, + }), + ); + expect(installationStore.delete).toHaveBeenCalledWith("current"); + expect(result).toEqual({ + type: "success", + statusCode: 200, + body: expect.objectContaining({ + id: "uninstallation-1", + status: "succeeded", + }), + }); + }); + + test("returns 500 when the uninstallation workflow fails", async () => { + const initialState = createMockInProgressState({ id: "uninstallation-1" }); + const failedState = createMockFailedState({ id: "uninstallation-1" }); + + runUninstallationMock.mockImplementation( + async ({ + initialState: failedInitialState, + hooks, + }: WorkflowRunnerArgs) => { + const inProgressState = createMockInProgressState({ + id: failedInitialState.id, + }); + + await hooks.onInstallationStart(inProgressState); + await hooks.onStepFailure( + { + path: ["uninstallation", "cleanup"], + stepName: "cleanup", + isLeaf: true, + error: failedState.error, + }, + failedState, + ); + await hooks.onInstallationFailure(failedState); + + return failedState; + }, + ); + + const handler = installationRuntimeAction({ + appConfig: minimalValidConfig, + }); + + const result = await handler( + createRuntimeActionParams({ + method: "post", + path: "/uninstallation/execution", + initialState, + appData, + ...DEFAULT_INSTALLATION_PARAMS, + }), + ); + + expect(result).toEqual({ + type: "error", + error: { + statusCode: 500, + body: { + message: "Uninstallation failed", + error: failedState.error, + state: failedState, + }, + }, + }); + }); + + test("clears uninstallation state with DELETE /uninstallation", async () => { + const handler = installationRuntimeAction({ + appConfig: minimalValidConfig, + }); + + const result = await handler( + createRuntimeActionParams({ + method: "delete", + path: "/uninstallation", + }), + ); + + expect(uninstallationStore.delete).toHaveBeenCalledWith("current"); + expect(result).toEqual({ + type: "success", + statusCode: 204, + }); + }); +}); diff --git a/packages/aio-commerce-lib-app/test/unit/actions/scope-tree.test.ts b/packages/aio-commerce-lib-app/test/unit/actions/scope-tree.test.ts new file mode 100644 index 000000000..4f342e19e --- /dev/null +++ b/packages/aio-commerce-lib-app/test/unit/actions/scope-tree.test.ts @@ -0,0 +1,239 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const { + resolveCommerceHttpClientParamsMock, + getScopeTreeMock, + setCustomScopeTreeMock, + syncCommerceScopesMock, + unsyncCommerceScopesMock, +} = vi.hoisted(() => ({ + resolveCommerceHttpClientParamsMock: vi.fn(), + getScopeTreeMock: vi.fn(), + setCustomScopeTreeMock: vi.fn(), + syncCommerceScopesMock: vi.fn(), + unsyncCommerceScopesMock: vi.fn(), +})); + +vi.mock("@adobe/aio-commerce-lib-api", () => ({ + resolveCommerceHttpClientParams: resolveCommerceHttpClientParamsMock, +})); + +vi.mock("@adobe/aio-commerce-lib-config", () => ({ + getScopeTree: getScopeTreeMock, + setCustomScopeTree: setCustomScopeTreeMock, + syncCommerceScopes: syncCommerceScopesMock, + unsyncCommerceScopes: unsyncCommerceScopesMock, +})); + +import { scopeTreeRuntimeAction } from "#actions/scope-tree"; +import { createRuntimeActionParams } from "#test/fixtures/actions"; + +const scopeTree = [{ id: "default", name: "Default Website" }]; + +describe("scopeTreeRuntimeAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("returns a 200 response when the scope tree is fresh", async () => { + getScopeTreeMock.mockResolvedValue({ + isCachedData: false, + scopeTree, + }); + + const result = await scopeTreeRuntimeAction(createRuntimeActionParams()); + + expect(result).toEqual({ + type: "success", + statusCode: 200, + body: { scopes: scopeTree }, + }); + }); + + test("returns a 203 response and cache header when the scope tree is cached", async () => { + getScopeTreeMock.mockResolvedValue({ + isCachedData: true, + scopeTree, + }); + + const result = await scopeTreeRuntimeAction(createRuntimeActionParams()); + + expect(result).toEqual({ + type: "success", + statusCode: 203, + headers: { "x-cache": "hit" }, + body: { scopes: scopeTree }, + }); + }); + + test("stores a custom scope tree with PUT /", async () => { + setCustomScopeTreeMock.mockResolvedValue({ synced: true }); + + const result = await scopeTreeRuntimeAction( + createRuntimeActionParams({ + method: "put", + body: { scopes: scopeTree }, + }), + ); + + expect(setCustomScopeTreeMock).toHaveBeenCalledWith({ scopes: scopeTree }); + expect(result).toEqual({ + type: "success", + statusCode: 200, + headers: { "Cache-Control": "no-store" }, + body: { result: { synced: true } }, + }); + }); + + test("syncs commerce scopes with resolved API client params", async () => { + const commerceConfig = { token: "resolved-config" }; + resolveCommerceHttpClientParamsMock.mockReturnValue(commerceConfig); + syncCommerceScopesMock.mockResolvedValue({ + synced: true, + scopeTree, + }); + + const result = await scopeTreeRuntimeAction( + createRuntimeActionParams({ + method: "post", + path: "/commerce", + body: { + commerceBaseUrl: "https://commerce.example.com", + commerceEnv: "stage", + }, + AIO_COMMERCE_AUTH_IMS_TOKEN: "ims-token", + }), + ); + + expect(resolveCommerceHttpClientParamsMock).toHaveBeenCalledWith( + { + AIO_COMMERCE_AUTH_IMS_TOKEN: "ims-token", + AIO_COMMERCE_API_BASE_URL: "https://commerce.example.com", + AIO_COMMERCE_API_FLAVOR: "stage", + __ow_method: "post", + __ow_path: "/commerce", + __ow_body: JSON.stringify({ + commerceBaseUrl: "https://commerce.example.com", + commerceEnv: "stage", + }), + }, + { tryForwardAuthProvider: true }, + ); + expect(syncCommerceScopesMock).toHaveBeenCalledWith(commerceConfig); + expect(result).toEqual({ + type: "success", + statusCode: 200, + body: { + scopes: scopeTree, + synced: true, + }, + }); + }); + + test("returns a 203 response when synced commerce scopes come from cache", async () => { + const commerceConfig = { token: "resolved-config" }; + resolveCommerceHttpClientParamsMock.mockReturnValue(commerceConfig); + syncCommerceScopesMock.mockResolvedValue({ + synced: false, + scopeTree, + }); + + const result = await scopeTreeRuntimeAction( + createRuntimeActionParams({ + method: "post", + path: "/commerce", + body: { + commerceBaseUrl: "https://commerce.example.com", + commerceEnv: "stage", + }, + }), + ); + + expect(result).toEqual({ + type: "success", + statusCode: 203, + headers: { "x-cache": "hit" }, + body: { + scopes: scopeTree, + synced: false, + }, + }); + }); + + test("returns a 500 response when syncing commerce scopes fails", async () => { + const commerceError = { message: "Boom" }; + + resolveCommerceHttpClientParamsMock.mockReturnValue({ token: "resolved" }); + syncCommerceScopesMock.mockResolvedValue({ + error: commerceError, + }); + + const result = await scopeTreeRuntimeAction( + createRuntimeActionParams({ + method: "post", + path: "/commerce", + body: { + commerceBaseUrl: "https://commerce.example.com", + commerceEnv: "stage", + }, + }), + ); + + expect(result).toEqual({ + type: "error", + error: { + statusCode: 500, + body: { + message: "An internal server error occurred", + error: commerceError, + }, + }, + }); + }); + + test("returns a success message when commerce scopes are unsynced", async () => { + unsyncCommerceScopesMock.mockResolvedValue({ unsynced: true }); + + const result = await scopeTreeRuntimeAction( + createRuntimeActionParams({ + method: "delete", + path: "/commerce", + }), + ); + + expect(result).toEqual({ + type: "success", + statusCode: 200, + body: { message: "Commerce scopes unsynced successfully" }, + }); + }); + + test("returns a no-op message when there are no commerce scopes to unsync", async () => { + unsyncCommerceScopesMock.mockResolvedValue({ unsynced: false }); + + const result = await scopeTreeRuntimeAction( + createRuntimeActionParams({ + method: "delete", + path: "/commerce", + }), + ); + + expect(result).toEqual({ + type: "success", + statusCode: 200, + body: { message: "No commerce scopes to unsync" }, + }); + }); +});