diff --git a/.changeset/commerce-update-event-subscription.md b/.changeset/commerce-update-event-subscription.md new file mode 100644 index 000000000..a2408a025 --- /dev/null +++ b/.changeset/commerce-update-event-subscription.md @@ -0,0 +1,5 @@ +--- +"@adobe/aio-commerce-lib-events": minor +--- + +Add `updateEventSubscription` to the Commerce Events API client, wrapping the `PUT eventing/eventSubscribe/:name` endpoint to update an existing subscription's fields and rules in place. diff --git a/.changeset/eventing-subscription-config-updates.md b/.changeset/eventing-subscription-config-updates.md new file mode 100644 index 000000000..c1f7537e5 --- /dev/null +++ b/.changeset/eventing-subscription-config-updates.md @@ -0,0 +1,5 @@ +--- +"@adobe/aio-commerce-lib-app": minor +--- + +Reconcile Commerce event subscription configuration changes during an app upgrade. When an event that exists in both the installed baseline and the target config changes its fields, rules, priority or HIPAA flag, the upgrade now updates the live subscription instead of leaving it stale. diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts b/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts index 4be9365c9..65e864549 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts @@ -28,12 +28,14 @@ import { getNamespacedEvent, getRegistrationDescription, getRegistrationName, + getSubscriptionChangeKind, groupEventsByRuntimeActions, } from "./utils"; import type { EventProviderType } from "@adobe/aio-commerce-lib-events/io-events"; import type { AppEvent, + CommerceEvent, CommerceEventsConfig, ExternalEventsConfig, } from "#config/schema/eventing"; @@ -226,6 +228,15 @@ async function reconcileProviderSubResources( baselineMetadata, context, ); + + await reconcileChangedSubscriptions( + providerData.id, + target.events, + baseline.events, + targetMetadata, + baselineMetadata, + context, + ); } await removeDroppedMetadata( @@ -540,6 +551,80 @@ async function removeDroppedSubscriptions( } } +/** + * Reconciles configuration changes on Commerce subscriptions present on both the baseline and + * target. Additive/same-key changes are applied in place via the Commerce merge-update endpoint; + * orphaning changes (field/rule removal, rename, rule operator/field change) are applied by + * unsubscribe + resubscribe. + */ +async function reconcileChangedSubscriptions( + providerId: string, + targetEvents: AppEvent[], + baselineEvents: AppEvent[], + targetMetadata: ApplicationMetadata, + baselineMetadata: ApplicationMetadata, + context: EventsExecutionContext, +): Promise { + const { commerceEventsClient, logger } = context; + const baselineByName = new Map( + baselineEvents.map((event) => [ + getNamespacedEvent(baselineMetadata, event.name), + event as CommerceEvent, + ]), + ); + + for (const event of targetEvents as CommerceEvent[]) { + const name = getNamespacedEvent(targetMetadata, event.name); + const baselineEvent = baselineByName.get(name); + if (!baselineEvent) { + // Added event — created by the idempotent install pass. + continue; + } + + const changeMode = getSubscriptionChangeKind(baselineEvent, event); + if (changeMode === "none") { + continue; + } + + const subscription = { + fields: event.fields, + hipaa_audit_required: event.hipaa_audit_required, + name, + parent: event.name, + priority: event.priority, + provider_id: providerId, + rules: event.rules, + }; + + try { + if (changeMode === "in-place") { + // biome-ignore lint/performance/noAwaitInLoops: subscriptions are updated sequentially to avoid a Commerce rate-limit burst + await commerceEventsClient.updateEventSubscription(subscription); + logger.info(`Updated Commerce event subscription "${name}" in place.`); + } else { + // The merge-update endpoint cannot remove or re-key fields/rules, so re-subscribe. The + // Commerce unsubscribe/subscribe cascade churns the event's I/O metadata; the registration + // re-links by event code and is left untouched. + await commerceEventsClient.deleteEventSubscription({ name }); + await commerceEventsClient.createEventSubscription({ + ...subscription, + destination: event.destination, + force: event.force, + }); + logger.info(`Recreated Commerce event subscription "${name}".`); + } + } catch (error) { + const message = await unwrapHttpError(error); + // Unlike the best-effort removals, a failure here fails the upgrade step: a silently stale + // subscription would diverge from the applied config. + throw new Error( + `Failed to update Commerce event subscription "${name}": ${message}`, + { cause: error }, + ); + } + } +} + /** Finds a deployed registration by its current or legacy name. */ function findDeployedRegistration( providerData: IoEventProviderWithMetadata, diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/branch.ts b/packages/aio-commerce-lib-app/source/management/domains/events/branch.ts index 774e8a543..091a8583d 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/branch.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/branch.ts @@ -34,6 +34,11 @@ export const eventingStep = defineBranchStep({ "Removes the I/O Events and Commerce events configured by the application", label: "Eventing", }, + upgrade: { + description: + "Reconciles the I/O Events and Commerce events configured by the application", + label: "Eventing", + }, }, name: "eventing", }); diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/context.ts b/packages/aio-commerce-lib-app/source/management/domains/events/context.ts index d8bcc1bed..8bf956a1d 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/context.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/context.ts @@ -23,6 +23,7 @@ import { getAllEventProviders as getAllEventProvidersCommerce, getAllEventSubscriptions, updateEventingConfiguration, + updateEventSubscription, } from "@adobe/aio-commerce-lib-events/commerce"; import { createCustomAdobeIoEventsApiClient, @@ -65,6 +66,7 @@ function createCommerceEventsApiClient(params: RuntimeActionParams) { getAllEventProviders: getAllEventProvidersCommerce, getAllEventSubscriptions, updateEventingConfiguration, + updateEventSubscription, }); } diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts b/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts index 1e8481241..b02144e38 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts @@ -21,6 +21,7 @@ import { eventCodeOf, getNamespacedEvent, getProviderKey, + getSubscriptionChangeKind, groupEventsByRuntimeActions, } from "./utils"; @@ -28,6 +29,7 @@ import type { CommerceEnv } from "@adobe/aio-commerce-lib-core/commerce"; import type { EventProviderType } from "@adobe/aio-commerce-lib-events/io-events"; import type { AppEvent, + CommerceEvent, CommerceEventsConfig, EventProvider, ExternalEventsConfig, @@ -388,6 +390,36 @@ class LeafPlanBuilder { `Remove Commerce subscription: ${name}`, ); } + + // Subscriptions present on both sides: reconcile in-place config changes (fields, rules, + // priority, hipaa) that the add/remove partition above never examines. + const baselineByName = new Map( + baseline.events.map((event) => [ + getNamespacedEvent(baselineMetadata, event.name), + event as CommerceEvent, + ]), + ); + + for (const event of target.events as CommerceEvent[]) { + const name = getNamespacedEvent(targetMetadata, event.name); + const baselineEvent = baselineByName.get(name); + if (!baselineEvent) { + continue; + } + + const changeMode = getSubscriptionChangeKind(baselineEvent, event); + if (changeMode === "none") { + continue; + } + + this.update( + { name, providerKey: key, resourceType: "subscription" }, + { changeMode, name, providerKey: key, resourceType: "subscription" }, + changeMode === "in-place" + ? `Update Commerce subscription in place: ${name}` + : `Recreate Commerce subscription: ${name}`, + ); + } } } @@ -467,8 +499,8 @@ function planEventingLeaf( /** * Plans the Commerce eventing changes between the installed baseline and the target config. Pure: * diffs provider/metadata/registration/subscription resources into add/remove/update operations - * without any I/O. In-place provider/metadata/subscription changes have no update API and are left - * as-is; registration event-set changes are emitted as `update` (applied via a full-replace PUT). + * without any I/O. In-place provider/metadata changes have no update API and are left as-is; + * registration event-set changes and Commerce subscription config changes are emitted as `update`. * * @param input - The planning input (baseline config + snapshot, target config, path). * @param context - The side-effect-free execution context (used to resolve the install environment). diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/types.ts b/packages/aio-commerce-lib-app/source/management/domains/events/types.ts index 090b1f8be..5f60d250b 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/types.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/types.ts @@ -194,6 +194,13 @@ export type EventingOperationValue = resourceType: "subscription"; providerKey: string; name: string; + + /** + * How a subscription config change is applied, present only on `update` operations: + * `in-place` via the Commerce merge-update endpoint, or `recreate` (unsubscribe + + * resubscribe) when the change drops or re-keys a field/rule. + */ + changeMode?: "in-place" | "recreate"; }; /** diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts b/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts index c175c0502..7fe4c8661 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts @@ -15,6 +15,7 @@ import { getSystemConfigByKey, setSystemConfigByKey, } from "@adobe/aio-commerce-lib-config"; +import stringify from "safe-stable-stringify"; import type { CommerceEventProvider, @@ -28,7 +29,11 @@ import type { IoEventRegistration, } from "@adobe/aio-commerce-lib-events/io-events"; import type { ApplicationMetadata } from "#config/index"; -import type { AppEvent, EventProvider } from "#config/schema/eventing"; +import type { + AppEvent, + CommerceEvent, + EventProvider, +} from "#config/schema/eventing"; import type { EventsExecutionContext } from "./context"; import type { AppEventWithoutRuntimeActions, StoredEventsData } from "./types"; @@ -246,6 +251,80 @@ export function diffByKey( }; } +/** How a persisting Commerce subscription's configuration changed between baseline and target. */ +export type SubscriptionChangeKind = "none" | "in-place" | "recreate"; + +/** Order-independent, default-normalized view of the subscription attributes we reconcile. */ +function canonicalSubscriptionConfig(event: CommerceEvent) { + const fields = event.fields + .map((field) => ({ name: field.name, source: field.source ?? null })) + .sort((a, b) => a.name.localeCompare(b.name)); + + const rules = (event.rules ?? []) + .map((rule) => ({ + field: rule.field, + operator: rule.operator, + value: rule.value, + })) + .sort((a, b) => + `${a.field}:${a.operator}`.localeCompare(`${b.field}:${b.operator}`), + ); + + // `destination` is omitted: it is internal routing, not developer-facing config. + return { + fields, + hipaa_audit_required: event.hipaa_audit_required ?? false, + priority: event.priority ?? false, + rules, + }; +} + +/** The Commerce merge-update keys for an event's fields (by name) and rules (by `field:operator`). */ +function subscriptionMergeKeys(event: CommerceEvent): { + fields: Set; + rules: Set; +} { + return { + fields: new Set(event.fields.map((field) => field.name)), + rules: new Set( + (event.rules ?? []).map((rule) => `${rule.field}:${rule.operator}`), + ), + }; +} + +/** + * Classifies how a persisting Commerce event subscription's configuration changed: + * + * - `none` — identical after normalization (field/rule reordering and omitted-optional defaults + * do not count as a change). + * - `in-place` — only additive or same-key changes (new field/rule, changed field source, changed + * rule value, toggled `priority`/`hipaa_audit_required`), which the Commerce merge-update endpoint + * can express. + * - `recreate` — a field or rule identity was dropped (removed field/rule, renamed field, changed + * rule operator/field). Merge cannot remove entries, so these require re-subscribing the event. + */ +export function getSubscriptionChangeKind( + baseline: CommerceEvent, + target: CommerceEvent, +): SubscriptionChangeKind { + if ( + stringify(canonicalSubscriptionConfig(baseline)) === + stringify(canonicalSubscriptionConfig(target)) + ) { + return "none"; + } + + const baselineKeys = subscriptionMergeKeys(baseline); + const targetKeys = subscriptionMergeKeys(target); + + const droppedKey = !( + baselineKeys.fields.isSubsetOf(targetKeys.fields) && + baselineKeys.rules.isSubsetOf(targetKeys.rules) + ); + + return droppedKey ? "recreate" : "in-place"; +} + /** Maps a provider's metadata type to its human-readable label ("Commerce" or "External"). */ function getProviderTypeLabel(provider: IoEventProvider) { return PROVIDER_TYPE_TO_LABEL[ diff --git a/packages/aio-commerce-lib-app/test/fixtures/eventing.ts b/packages/aio-commerce-lib-app/test/fixtures/eventing.ts index 73be0a9dd..6955c284a 100644 --- a/packages/aio-commerce-lib-app/test/fixtures/eventing.ts +++ b/packages/aio-commerce-lib-app/test/fixtures/eventing.ts @@ -1,5 +1,10 @@ import { vi } from "vitest"; +import { + COMMERCE_PROVIDER_TYPE, + generateInstanceId, +} from "#management/domains/events/utils"; + import { configWithCommerceEventing } from "./config"; import { createMockInstallationContext } from "./installation"; @@ -9,6 +14,7 @@ import type { UpdateEventingConfigurationParams, } from "@adobe/aio-commerce-lib-events/commerce"; import type { + EventProviderType, IoEventMetadata, IoEventMetadataManyResponse, IoEventProvider, @@ -215,6 +221,11 @@ export function createMockCommerceEventsClient( getAllEventProviders: vi.fn(overrides?.getAllEventProviders), getAllEventSubscriptions: vi.fn(overrides?.getAllEventSubscriptions), updateEventingConfiguration: vi.fn(overrides?.updateEventingConfiguration), + updateEventSubscription: vi + .fn() + .mockImplementation( + overrides?.updateEventSubscription ?? (() => Promise.resolve()), + ), }; } @@ -252,6 +263,75 @@ export function createMockIoEventsClient( }; } +/** Workspace id the mock installation context deploys under; deployed-provider instance ids must match it. */ +export const TEST_WORKSPACE_ID = "test-workspace-id"; + +/** Client id the mock installation context runs as; deployed registrations must carry it to be found. */ +export const TEST_CLIENT_ID = "test-client-id"; + +/** + * Builds a live I/O Events provider entry whose `instance_id` matches the given config provider under + * the shared test metadata, so `resolveDeployedProvider` finds it during an apply. Carries an empty + * `_embedded.eventmetadata` so it also parses as a provider HAL model in the list endpoints. + * + * @param options - The config provider to mirror, the deployed provider id, and its provider type. + */ +export function createMockDeployedIoProvider(options: { + provider: { label: string; key?: string }; + id: string; + type?: EventProviderType; +}) { + const { provider, id, type = COMMERCE_PROVIDER_TYPE } = options; + return { + ...createMockIoEventProvider({ + id, + instance_id: generateInstanceId( + configWithCommerceEventing.metadata, + provider as EventProvider, + TEST_WORKSPACE_ID, + ), + label: provider.label, + provider_metadata: type, + }), + _embedded: { eventmetadata: [] }, + }; +} + +/** Builds a deployed I/O Events registration carrying the mock context's client id. */ +export function createMockDeployedRegistration( + name: string, + registrationId: string, +) { + return createMockIoEventRegistration({ + client_id: TEST_CLIENT_ID, + name, + registration_id: registrationId, + }); +} + +/** A mock io-events client whose list endpoints return the given (defaulted-empty) HAL payloads. */ +export function createMockIoEventsListClient(options?: { + providers?: unknown[]; + registrations?: unknown[]; + updateRegistration?: (...args: unknown[]) => unknown; + createRegistration?: (...args: unknown[]) => unknown; + deleteRegistration?: (...args: unknown[]) => unknown; + deleteEventMetadataForProvider?: (...args: unknown[]) => unknown; +}) { + return { + createRegistration: options?.createRegistration, + deleteEventMetadataForProvider: options?.deleteEventMetadataForProvider, + deleteRegistration: options?.deleteRegistration, + getAllEventProviders: () => + Promise.resolve({ _embedded: { providers: options?.providers ?? [] } }), + getAllRegistrations: () => + Promise.resolve({ + _embedded: { registrations: options?.registrations ?? [] }, + }), + updateRegistration: options?.updateRegistration ?? vi.fn(), + }; +} + /** Options for creating a mock {@link EventsExecutionContext}. */ export type MockEventingInstallationContextOptions = Omit< Partial, diff --git a/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts b/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts index a57a6c1af..8004e063c 100644 --- a/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts @@ -28,7 +28,6 @@ import { import { COMMERCE_PROVIDER_TYPE, EXTERNAL_PROVIDER_TYPE, - generateInstanceId, getIoEventCode, getNamespacedEvent, getRegistrationName, @@ -36,11 +35,12 @@ import { import { configWithCommerceEventing } from "#test/fixtures/config"; import { createMockCommerceEventsConfig as commerceConfig, + createMockDeployedIoProvider, + createMockDeployedRegistration, createMockEventingInstallationContext, - createMockIoEventProvider, - createMockIoEventRegistration, createMockAppEvent as event, createMockExternalEventsConfig as externalConfig, + createMockIoEventsListClient as ioEventsClient, } from "#test/fixtures/eventing"; import type { @@ -69,49 +69,6 @@ function httpError(status: number) { ); } -/** - * Builds a live I/O Events provider entry whose `instance_id` matches the given config - * provider, so `resolveDeployedProvider` can find it during reconciliation. - */ -function liveProvider(prov: { label: string; key?: string }, id: string) { - return { - ...createMockIoEventProvider({ - id, - instance_id: generateInstanceId( - metadata, - prov as EventProvider, - "test-workspace-id", - ), - label: prov.label, - provider_metadata: "dx_commerce_events", - }), - _embedded: { eventmetadata: [] }, - }; -} - -/** A mock io-events client whose list endpoints return the given (defaulted-empty) HAL payloads. */ -function ioEventsClient(options?: { - providers?: unknown[]; - registrations?: unknown[]; - updateRegistration?: (...args: unknown[]) => unknown; - createRegistration?: (...args: unknown[]) => unknown; - deleteRegistration?: (...args: unknown[]) => unknown; - deleteEventMetadataForProvider?: (...args: unknown[]) => unknown; -}) { - return { - createRegistration: options?.createRegistration, - deleteEventMetadataForProvider: options?.deleteEventMetadataForProvider, - deleteRegistration: options?.deleteRegistration, - getAllEventProviders: () => - Promise.resolve({ _embedded: { providers: options?.providers ?? [] } }), - getAllRegistrations: () => - Promise.resolve({ - _embedded: { registrations: options?.registrations ?? [] }, - }), - updateRegistration: options?.updateRegistration ?? vi.fn(), - }; -} - async function planCommerce( baseline: CommerceEventsConfig, target: CommerceEventsConfig, @@ -155,7 +112,12 @@ describe("applyCommerceEvents", () => { .mockResolvedValue([]); const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ - providers: [liveProvider({ label: "P1" }, "prov-1")], + providers: [ + createMockDeployedIoProvider({ + id: "prov-1", + provider: { label: "P1" }, + }), + ], }) as never, }); @@ -201,7 +163,12 @@ describe("applyCommerceEvents", () => { .mockResolvedValue(undefined); const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ - providers: [liveProvider({ label: "P1" }, "prov-1")], + providers: [ + createMockDeployedIoProvider({ + id: "prov-1", + provider: { label: "P1" }, + }), + ], }) as never, }); @@ -232,31 +199,18 @@ describe("applyCommerceEvents", () => { key: "k1", label: "P1", }; - const instanceId = generateInstanceId( - metadata, + const providerData = createMockDeployedIoProvider({ + id: "prov-1", provider, - "test-workspace-id", - ); - const providerData = { - ...createMockIoEventProvider({ - id: "prov-1", - instance_id: instanceId, - label: "P1", - provider_metadata: "dx_commerce_events", - }), - }; + }); const registrationName = getRegistrationName(providerData, "pkg/a"); const updateRegistration = vi.fn().mockResolvedValue(undefined); const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ - providers: [{ ...providerData, _embedded: { eventmetadata: [] } }], + providers: [providerData], registrations: [ - createMockIoEventRegistration({ - client_id: "test-client-id", - name: registrationName, - registration_id: "reg-1", - }), + createMockDeployedRegistration(registrationName, "reg-1"), ], updateRegistration, }) as never, @@ -290,29 +244,18 @@ describe("applyCommerceEvents", () => { key: "k1", label: "P1", }; - const instanceId = generateInstanceId( - metadata, - provider, - "test-workspace-id", - ); - const providerData = createMockIoEventProvider({ + const providerData = createMockDeployedIoProvider({ id: "prov-1", - instance_id: instanceId, - label: "P1", - provider_metadata: "dx_commerce_events", + provider, }); const registrationName = getRegistrationName(providerData, "pkg/a"); const updateRegistration = vi.fn().mockResolvedValue(undefined); const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ - providers: [{ ...providerData, _embedded: { eventmetadata: [] } }], + providers: [providerData], registrations: [ - createMockIoEventRegistration({ - client_id: "test-client-id", - name: registrationName, - registration_id: "reg-1", - }), + createMockDeployedRegistration(registrationName, "reg-1"), ], updateRegistration, }) as never, @@ -368,28 +311,17 @@ describe("applyCommerceEvents", () => { key: "k1", label: "P1", }; - const instanceId = generateInstanceId( - metadata, - provider, - "test-workspace-id", - ); - const providerData = createMockIoEventProvider({ + const providerData = createMockDeployedIoProvider({ id: "prov-1", - instance_id: instanceId, - label: "P1", - provider_metadata: "dx_commerce_events", + provider, }); const droppedRegistrationName = getRegistrationName(providerData, "pkg/b"); const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ - providers: [{ ...providerData, _embedded: { eventmetadata: [] } }], + providers: [providerData], registrations: [ - createMockIoEventRegistration({ - client_id: "test-client-id", - name: droppedRegistrationName, - registration_id: "reg-b", - }), + createMockDeployedRegistration(droppedRegistrationName, "reg-b"), ], }) as never, params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, @@ -433,25 +365,10 @@ describe("applyCommerceEvents", () => { }; function providerData() { - return createMockIoEventProvider({ - id: "prov-1", - instance_id: generateInstanceId( - metadata, - provider, - "test-workspace-id", - ), - label: "P1", - provider_metadata: "dx_commerce_events", - }); + return createMockDeployedIoProvider({ id: "prov-1", provider }); } - function registration(name: string, registrationId: string) { - return createMockIoEventRegistration({ - client_id: "test-client-id", - name, - registration_id: registrationId, - }); - } + const registration = createMockDeployedRegistration; test("fails the apply when updating a registration's event set errors", async () => { vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); @@ -459,7 +376,7 @@ describe("applyCommerceEvents", () => { const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ - providers: [{ ...data, _embedded: { eventmetadata: [] } }], + providers: [data], registrations: [ registration(getRegistrationName(data, "pkg/a"), "reg-1"), ], @@ -489,7 +406,7 @@ describe("applyCommerceEvents", () => { const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ createRegistration, - providers: [{ ...data, _embedded: { eventmetadata: [] } }], + providers: [data], registrations: [], }) as never, params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, @@ -524,7 +441,7 @@ describe("applyCommerceEvents", () => { const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ createRegistration: () => Promise.reject(httpError(500)), - providers: [{ ...data, _embedded: { eventmetadata: [] } }], + providers: [data], registrations: [], }) as never, params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, @@ -549,7 +466,7 @@ describe("applyCommerceEvents", () => { const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ deleteRegistration: () => Promise.reject(httpError(500)), - providers: [{ ...data, _embedded: { eventmetadata: [] } }], + providers: [data], registrations: [ registration(getRegistrationName(data, "pkg/b"), "reg-b"), ], @@ -579,7 +496,7 @@ describe("applyCommerceEvents", () => { deleteEventSubscription: () => Promise.reject(httpError(500)), }, ioEventsClient: ioEventsClient({ - providers: [{ ...data, _embedded: { eventmetadata: [] } }], + providers: [data], registrations: [ registration(getRegistrationName(data, "pkg/a"), "reg-1"), ], @@ -609,7 +526,7 @@ describe("applyCommerceEvents", () => { deleteEventSubscription: () => Promise.reject(httpError(404)), }, ioEventsClient: ioEventsClient({ - providers: [{ ...data, _embedded: { eventmetadata: [] } }], + providers: [data], registrations: [ registration(getRegistrationName(data, "pkg/a"), "reg-1"), ], @@ -642,7 +559,7 @@ describe("applyCommerceEvents", () => { const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ deleteEventMetadataForProvider: () => Promise.reject(httpError(404)), - providers: [{ ...data, _embedded: { eventmetadata: [] } }], + providers: [data], registrations: [ registration(getRegistrationName(data, "pkg/a"), "reg-1"), ], @@ -675,7 +592,7 @@ describe("applyCommerceEvents", () => { const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ deleteEventMetadataForProvider: () => Promise.reject(httpError(500)), - providers: [{ ...data, _embedded: { eventmetadata: [] } }], + providers: [data], registrations: [ registration(getRegistrationName(data, "pkg/a"), "reg-1"), ], @@ -707,7 +624,7 @@ describe("applyCommerceEvents", () => { const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ - providers: [{ ...data, _embedded: { eventmetadata: [] } }], + providers: [data], registrations: [ registration(getRegistrationName(data, "pkg/a"), "reg-1"), ], @@ -761,6 +678,242 @@ describe("applyCommerceEvents", () => { ).rejects.toThrow("Could not resolve deployed provider"); }); }); + + /** Builds a context whose deployed provider resolves to `prov-1` for a persisting provider. */ + function persistingProviderContext( + provider: EventProvider, + overrides?: { + commerceEventsClient?: Record; + }, + ) { + const providerData = createMockDeployedIoProvider({ + id: "prov-1", + provider, + }); + const registrationName = getRegistrationName(providerData, "pkg/a"); + + return createMockEventingInstallationContext({ + commerceEventsClient: overrides?.commerceEventsClient as never, + ioEventsClient: ioEventsClient({ + providers: [providerData], + registrations: [ + createMockDeployedRegistration(registrationName, "reg-1"), + ], + }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + } + + test("updates a persisting subscription in place for an additive config change", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const provider: EventProvider = { + description: "P1", + key: "k1", + label: "P1", + }; + const context = persistingProviderContext(provider); + + const plan = await planCommerce( + commerceConfig([ + { + events: [{ ...event("a", ["pkg/a"]), fields: [{ name: "field_a" }] }], + provider, + }, + ]), + commerceConfig([ + { + events: [ + { + ...event("a", ["pkg/a"]), + fields: [{ name: "field_a" }, { name: "field_b" }], + }, + ], + provider, + }, + ]), + ); + + await applyCommerceEvents(plan, context as ApplyContext); + + const name = getNamespacedEvent(metadata, "a"); + expect( + context.commerceEventsClient.updateEventSubscription, + ).toHaveBeenCalledTimes(1); + expect( + context.commerceEventsClient.updateEventSubscription, + ).toHaveBeenCalledWith( + expect.objectContaining({ + fields: [{ name: "field_a" }, { name: "field_b" }], + name, + provider_id: "prov-1", + }), + ); + expect( + context.commerceEventsClient.deleteEventSubscription, + ).not.toHaveBeenCalled(); + }); + + test("updates a persisting subscription in place when a scalar is disabled (true -> false)", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const provider: EventProvider = { + description: "P1", + key: "k1", + label: "P1", + }; + const context = persistingProviderContext(provider); + + // Only the scalars change (fields/rules are identical), so the change is mergeable and must + // reach Commerce as an in-place update carrying the `false` values — never a recreate. + const plan = await planCommerce( + commerceConfig([ + { + events: [ + { + ...event("a", ["pkg/a"]), + fields: [{ name: "field_a" }], + hipaa_audit_required: true, + priority: true, + }, + ], + provider, + }, + ]), + commerceConfig([ + { + events: [ + { + ...event("a", ["pkg/a"]), + fields: [{ name: "field_a" }], + hipaa_audit_required: false, + priority: false, + }, + ], + provider, + }, + ]), + ); + + await applyCommerceEvents(plan, context as ApplyContext); + + const name = getNamespacedEvent(metadata, "a"); + expect( + context.commerceEventsClient.updateEventSubscription, + ).toHaveBeenCalledTimes(1); + expect( + context.commerceEventsClient.updateEventSubscription, + ).toHaveBeenCalledWith( + expect.objectContaining({ + hipaa_audit_required: false, + name, + priority: false, + provider_id: "prov-1", + }), + ); + expect( + context.commerceEventsClient.deleteEventSubscription, + ).not.toHaveBeenCalled(); + expect( + context.commerceEventsClient.createEventSubscription, + ).not.toHaveBeenCalled(); + }); + + test("recreates a persisting subscription (unsubscribe then resubscribe) for an orphaning change", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const provider: EventProvider = { + description: "P1", + key: "k1", + label: "P1", + }; + const context = persistingProviderContext(provider); + + const plan = await planCommerce( + commerceConfig([ + { + events: [ + { + ...event("a", ["pkg/a"]), + fields: [{ name: "field_a" }, { name: "field_b" }], + }, + ], + provider, + }, + ]), + commerceConfig([ + { + events: [{ ...event("a", ["pkg/a"]), fields: [{ name: "field_a" }] }], + provider, + }, + ]), + ); + + await applyCommerceEvents(plan, context as ApplyContext); + + const name = getNamespacedEvent(metadata, "a"); + expect( + context.commerceEventsClient.deleteEventSubscription, + ).toHaveBeenCalledWith({ name }); + expect( + context.commerceEventsClient.createEventSubscription, + ).toHaveBeenCalledWith( + expect.objectContaining({ + fields: [{ name: "field_a" }], + name, + provider_id: "prov-1", + }), + ); + expect( + context.commerceEventsClient.updateEventSubscription, + ).not.toHaveBeenCalled(); + + const firstCallOrder = (fn: unknown) => { + const { mock } = fn as { mock: { invocationCallOrder: number[] } }; + return mock.invocationCallOrder[0]; + }; + expect( + firstCallOrder(context.commerceEventsClient.deleteEventSubscription), + ).toBeLessThan( + firstCallOrder(context.commerceEventsClient.createEventSubscription), + ); + }); + + test("fails the apply when a subscription config update cannot be applied", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const provider: EventProvider = { + description: "P1", + key: "k1", + label: "P1", + }; + const context = persistingProviderContext(provider, { + commerceEventsClient: { + updateEventSubscription: () => + Promise.reject(new Error("update failed")), + }, + }); + + const plan = await planCommerce( + commerceConfig([ + { + events: [{ ...event("a", ["pkg/a"]), fields: [{ name: "field_a" }] }], + provider, + }, + ]), + commerceConfig([ + { + events: [ + { + ...event("a", ["pkg/a"]), + fields: [{ name: "field_a" }, { name: "field_b" }], + }, + ], + provider, + }, + ]), + ); + + await expect( + applyCommerceEvents(plan, context as ApplyContext), + ).rejects.toThrow(); + }); }); describe("applyExternalEvents", () => { @@ -825,7 +978,12 @@ describe("applyExternalEvents", () => { .mockResolvedValue(undefined); const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ - providers: [liveProvider({ label: "EP1" }, "prov-ext-1")], + providers: [ + createMockDeployedIoProvider({ + id: "prov-ext-1", + provider: { label: "EP1" }, + }), + ], }) as never, }); @@ -856,28 +1014,18 @@ describe("applyExternalEvents", () => { key: "k1", label: "EP1", }; - const instanceId = generateInstanceId( - metadata, - provider, - "test-workspace-id", - ); - const providerData = createMockIoEventProvider({ + const providerData = createMockDeployedIoProvider({ id: "prov-ext", - instance_id: instanceId, - label: "EP1", - provider_metadata: EXTERNAL_PROVIDER_TYPE, + provider, + type: EXTERNAL_PROVIDER_TYPE, }); const droppedRegistrationName = getRegistrationName(providerData, "pkg/b"); const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ - providers: [{ ...providerData, _embedded: { eventmetadata: [] } }], + providers: [providerData], registrations: [ - createMockIoEventRegistration({ - client_id: "test-client-id", - name: droppedRegistrationName, - registration_id: "reg-ext-b", - }), + createMockDeployedRegistration(droppedRegistrationName, "reg-ext-b"), ], }) as never, params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, @@ -921,29 +1069,19 @@ describe("applyExternalEvents", () => { key: "k1", label: "EP1", }; - const instanceId = generateInstanceId( - metadata, - provider, - "test-workspace-id", - ); - const providerData = createMockIoEventProvider({ + const providerData = createMockDeployedIoProvider({ id: "prov-ext", - instance_id: instanceId, - label: "EP1", - provider_metadata: EXTERNAL_PROVIDER_TYPE, + provider, + type: EXTERNAL_PROVIDER_TYPE, }); const registrationName = getRegistrationName(providerData, "pkg/a"); const updateRegistration = vi.fn().mockResolvedValue(undefined); const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ - providers: [{ ...providerData, _embedded: { eventmetadata: [] } }], + providers: [providerData], registrations: [ - createMockIoEventRegistration({ - client_id: "test-client-id", - name: registrationName, - registration_id: "reg-ext-a", - }), + createMockDeployedRegistration(registrationName, "reg-ext-a"), ], updateRegistration, }) as never, diff --git a/packages/aio-commerce-lib-app/test/unit/management/domains/events/branch.test.ts b/packages/aio-commerce-lib-app/test/unit/management/domains/events/branch.test.ts index ecf5c66a2..646ccce16 100644 --- a/packages/aio-commerce-lib-app/test/unit/management/domains/events/branch.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/management/domains/events/branch.test.ts @@ -51,6 +51,11 @@ describe("events installation module", () => { "Removes the I/O Events and Commerce events configured by the application", label: "Eventing", }, + upgrade: { + description: + "Reconciles the I/O Events and Commerce events configured by the application", + label: "Eventing", + }, }); }); diff --git a/packages/aio-commerce-lib-app/test/unit/management/domains/events/plan.test.ts b/packages/aio-commerce-lib-app/test/unit/management/domains/events/plan.test.ts index 30e80972b..c6316c547 100644 --- a/packages/aio-commerce-lib-app/test/unit/management/domains/events/plan.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/management/domains/events/plan.test.ts @@ -16,6 +16,8 @@ import { planCommerceEvents, planExternalEvents, } from "#management/domains/events/plan"; +import { getNamespacedEvent } from "#management/domains/events/utils"; +import { configWithCommerceEventing } from "#test/fixtures/config"; import { createMockCommerceEventsConfig as commerceConfig, createMockAppEvent as event, @@ -36,6 +38,8 @@ import type { EventingSnapshotData, } from "#management/domains/events/types"; +const { metadata } = configWithCommerceEventing; + const context = { params: { AIO_COMMERCE_API_FLAVOR: "paas" }, } as unknown as ValidationExecutionContext; @@ -115,6 +119,83 @@ describe("planCommerceEvents", () => { expect(plan.targetProviders.map((p) => p.key)).toEqual(["P1"]); }); + test("an additive subscription config change emits an in-place subscription update", async () => { + const baseline = commerceConfig([ + { + events: [{ ...event("a", ["pkg/a"]), fields: [{ name: "field_a" }] }], + provider: { label: "P1" }, + }, + ]); + const target = commerceConfig([ + { + events: [ + { + ...event("a", ["pkg/a"]), + fields: [{ name: "field_a" }, { name: "field_b" }], + }, + ], + provider: { label: "P1" }, + }, + ]); + + const plan = await planCommerce(commerceInput(baseline, target)); + + expect(pick(plan, "update", "subscription")).toEqual([ + { + changeMode: "in-place", + name: getNamespacedEvent(metadata, "a"), + providerKey: "P1", + resourceType: "subscription", + }, + ]); + expect(pick(plan, "add", "subscription")).toHaveLength(0); + expect(pick(plan, "remove", "subscription")).toHaveLength(0); + }); + + test("an orphaning subscription config change emits a recreate subscription update", async () => { + const baseline = commerceConfig([ + { + events: [ + { + ...event("a", ["pkg/a"]), + fields: [{ name: "field_a" }, { name: "field_b" }], + }, + ], + provider: { label: "P1" }, + }, + ]); + const target = commerceConfig([ + { + events: [{ ...event("a", ["pkg/a"]), fields: [{ name: "field_a" }] }], + provider: { label: "P1" }, + }, + ]); + + const plan = await planCommerce(commerceInput(baseline, target)); + + expect(pick(plan, "update", "subscription")).toEqual([ + { + changeMode: "recreate", + name: getNamespacedEvent(metadata, "a"), + providerKey: "P1", + resourceType: "subscription", + }, + ]); + }); + + test("an unchanged subscription emits no subscription update", async () => { + const config = commerceConfig([ + { + events: [{ ...event("a", ["pkg/a"]), fields: [{ name: "field_a" }] }], + provider: { label: "P1" }, + }, + ]); + + const plan = await planCommerce(commerceInput(config, config)); + + expect(pick(plan, "update", "subscription")).toHaveLength(0); + }); + test("added provider emits provider + metadata + registration + subscription adds", async () => { const baseline = commerceConfig([ { events: [event("a", ["pkg/a"])], provider: { label: "P1" } }, diff --git a/packages/aio-commerce-lib-app/test/unit/management/domains/events/utils.test.ts b/packages/aio-commerce-lib-app/test/unit/management/domains/events/utils.test.ts index af6c978f1..d8f3b5f91 100644 --- a/packages/aio-commerce-lib-app/test/unit/management/domains/events/utils.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/management/domains/events/utils.test.ts @@ -21,6 +21,7 @@ import { getIoEventCode, getIoEventsExistingData, getNamespacedEvent, + getSubscriptionChangeKind, makeWorkspaceConfig, sanitizeEventingIdentifier, } from "#management/domains/events/utils"; @@ -43,6 +44,7 @@ import type { IoEventProviderManyResponse, IoEventRegistrationManyResponse, } from "@adobe/aio-commerce-lib-events/io-events"; +import type { CommerceEvent } from "#config/schema/eventing"; const TEST_WORKSPACE_ID = "4567890123456789"; const TEST_NAMESPACE = "test-namespace"; @@ -649,3 +651,151 @@ describe("removeStoredEventProviders", () => { ); }); }); + +describe("getSubscriptionChangeKind", () => { + function commerceEvent( + overrides: Partial = {}, + ): CommerceEvent { + return { + description: "An event", + fields: [{ name: "field_a" }], + label: "Event", + name: "observer.order_placed", + runtimeActions: ["my-package/my-action"], + ...overrides, + } as CommerceEvent; + } + + test("returns 'none' for identical config", () => { + expect(getSubscriptionChangeKind(commerceEvent(), commerceEvent())).toBe( + "none", + ); + }); + + test("returns 'none' when fields are only reordered", () => { + const baseline = commerceEvent({ + fields: [{ name: "field_a" }, { name: "field_b" }], + }); + const target = commerceEvent({ + fields: [{ name: "field_b" }, { name: "field_a" }], + }); + expect(getSubscriptionChangeKind(baseline, target)).toBe("none"); + }); + + test("returns 'none' when rules are only reordered", () => { + const baseline = commerceEvent({ + rules: [ + { field: "a", operator: "equal", value: "1" }, + { field: "b", operator: "equal", value: "2" }, + ], + }); + const target = commerceEvent({ + rules: [ + { field: "b", operator: "equal", value: "2" }, + { field: "a", operator: "equal", value: "1" }, + ], + }); + expect(getSubscriptionChangeKind(baseline, target)).toBe("none"); + }); + + test("treats an omitted optional flag as its default (no change)", () => { + const baseline = commerceEvent(); + const target = commerceEvent({ priority: false }); + expect(getSubscriptionChangeKind(baseline, target)).toBe("none"); + }); + + test("returns 'in-place' when a field is added", () => { + const target = commerceEvent({ + fields: [{ name: "field_a" }, { name: "field_b" }], + }); + expect(getSubscriptionChangeKind(commerceEvent(), target)).toBe("in-place"); + }); + + test("returns 'in-place' when a field's source changes (same name)", () => { + const baseline = commerceEvent({ fields: [{ name: "field_a" }] }); + const target = commerceEvent({ + fields: [{ name: "field_a", source: "extension_attributes.foo" }], + }); + expect(getSubscriptionChangeKind(baseline, target)).toBe("in-place"); + }); + + test("returns 'in-place' when a rule is added", () => { + const target = commerceEvent({ + rules: [{ field: "state", operator: "equal", value: "new" }], + }); + expect(getSubscriptionChangeKind(commerceEvent(), target)).toBe("in-place"); + }); + + test("returns 'in-place' when a rule value changes (same field:operator)", () => { + const baseline = commerceEvent({ + rules: [{ field: "state", operator: "equal", value: "old" }], + }); + const target = commerceEvent({ + rules: [{ field: "state", operator: "equal", value: "new" }], + }); + expect(getSubscriptionChangeKind(baseline, target)).toBe("in-place"); + }); + + test("returns 'in-place' when priority is toggled", () => { + const target = commerceEvent({ priority: true }); + expect(getSubscriptionChangeKind(commerceEvent(), target)).toBe("in-place"); + }); + + test("returns 'in-place' when hipaa_audit_required is toggled", () => { + const target = commerceEvent({ hipaa_audit_required: true }); + expect(getSubscriptionChangeKind(commerceEvent(), target)).toBe("in-place"); + }); + + // Disabling a scalar (true -> false) drops no field/rule key, so it classifies as `in-place` + // like the enabling direction above. This case is called out separately because the in-place + // path relies on the Commerce merge endpoint applying a `false` scalar; if it does not, a + // disable would not take effect (see reconcileChangedSubscriptions). + test("returns 'in-place' when priority is disabled (true -> false)", () => { + const baseline = commerceEvent({ priority: true }); + const target = commerceEvent({ priority: false }); + expect(getSubscriptionChangeKind(baseline, target)).toBe("in-place"); + }); + + test("returns 'in-place' when hipaa_audit_required is disabled (true -> false)", () => { + const baseline = commerceEvent({ hipaa_audit_required: true }); + const target = commerceEvent({ hipaa_audit_required: false }); + expect(getSubscriptionChangeKind(baseline, target)).toBe("in-place"); + }); + + test("returns 'recreate' when a field is removed", () => { + const baseline = commerceEvent({ + fields: [{ name: "field_a" }, { name: "field_b" }], + }); + const target = commerceEvent({ fields: [{ name: "field_a" }] }); + expect(getSubscriptionChangeKind(baseline, target)).toBe("recreate"); + }); + + test("returns 'recreate' when a field is renamed", () => { + const baseline = commerceEvent({ fields: [{ name: "field_a" }] }); + const target = commerceEvent({ fields: [{ name: "field_b" }] }); + expect(getSubscriptionChangeKind(baseline, target)).toBe("recreate"); + }); + + test("returns 'recreate' when a rule is removed", () => { + const baseline = commerceEvent({ + rules: [ + { field: "a", operator: "equal", value: "1" }, + { field: "b", operator: "equal", value: "2" }, + ], + }); + const target = commerceEvent({ + rules: [{ field: "a", operator: "equal", value: "1" }], + }); + expect(getSubscriptionChangeKind(baseline, target)).toBe("recreate"); + }); + + test("returns 'recreate' when a rule operator changes for the same field", () => { + const baseline = commerceEvent({ + rules: [{ field: "state", operator: "equal", value: "1" }], + }); + const target = commerceEvent({ + rules: [{ field: "state", operator: "greaterThan", value: "1" }], + }); + expect(getSubscriptionChangeKind(baseline, target)).toBe("recreate"); + }); +}); diff --git a/packages/aio-commerce-lib-events/docs/usage.md b/packages/aio-commerce-lib-events/docs/usage.md index 6cf8332b0..4e01a9e20 100644 --- a/packages/aio-commerce-lib-events/docs/usage.md +++ b/packages/aio-commerce-lib-events/docs/usage.md @@ -92,6 +92,15 @@ const subscription = await commerceEventsClient.createEventSubscription({ providerId: "my-provider-id", fields: [{ name: "order_id" }, { name: "customer_email" }], }); + +// Update an existing subscription. The Commerce endpoint merges the provided +// fields and rules into the existing ones (keyed by field name and +// `field:operator`); it cannot remove entries, so removals require re-subscribing. +await commerceEventsClient.updateEventSubscription({ + name: "my-subscription", + provider_id: "my-provider-id", + fields: [{ name: "order_id" }, { name: "grand_total" }], +}); ``` #### Updating Eventing Configuration diff --git a/packages/aio-commerce-lib-events/source/commerce/api/event-subscriptions/endpoints.ts b/packages/aio-commerce-lib-events/source/commerce/api/event-subscriptions/endpoints.ts index d212cc1c3..614be7683 100644 --- a/packages/aio-commerce-lib-events/source/commerce/api/event-subscriptions/endpoints.ts +++ b/packages/aio-commerce-lib-events/source/commerce/api/event-subscriptions/endpoints.ts @@ -15,6 +15,7 @@ import { parseOrThrow } from "@aio-commerce-sdk/common-utils/valibot"; import { EventSubscriptionCreateParamsSchema, EventSubscriptionDeleteParamsSchema, + EventSubscriptionUpdateParamsSchema, } from "./schema"; import type { AdobeCommerceHttpClient } from "@adobe/aio-commerce-lib-api"; @@ -23,6 +24,7 @@ import type { HTTPError, Options } from "ky"; import type { EventSubscriptionCreateParams, EventSubscriptionDeleteParams, + EventSubscriptionUpdateParams, } from "./schema"; import type { CommerceEventSubscriptionManyResponse } from "./types"; @@ -79,6 +81,45 @@ export async function createEventSubscription( }); } +/** + * Updates an existing event subscription in the Commerce instance bound to the given {@link AdobeCommerceHttpClient}. + * + * The Commerce update endpoint merges the provided `fields` and `rules` into the + * existing subscription (keyed by field name and `field:operator` respectively); + * it cannot remove entries. Callers reconciling toward a desired end state must + * account for that (removals require re-subscribing the event). + * + * @see https://developer.adobe.com/commerce/extensibility/events/api/#update-an-event-subscription + * + * @param httpClient - The {@link AdobeCommerceHttpClient} to use to make the request. + * @param params - The parameters to update the event subscription with. + * @param fetchOptions - The {@link Options} to use to make the request. + * + * @throws A {@link CommerceSdkValidationError} If the parameters are in the wrong format. + * @throws An {@link HTTPError} If the status code is not 2XX. + */ +export async function updateEventSubscription( + httpClient: AdobeCommerceHttpClient, + params: EventSubscriptionUpdateParams, + fetchOptions?: Options, +): Promise { + const validatedParams = parseOrThrow( + EventSubscriptionUpdateParamsSchema, + params, + ); + + const { name, ...event } = validatedParams; + return httpClient + .put(`eventing/eventSubscribe/${name}`, { + ...fetchOptions, + json: { event }, + }) + .json() + .then((_res) => { + // The response mirrors the subscribe endpoint (`[]`); coerce to `void`. + }); +} + /** * Unsubscribes from an event in the Commerce instance bound to the given {@link AdobeCommerceHttpClient}. * @see https://developer.adobe.com/commerce/extensibility/events/api/#unsubscribe-from-events diff --git a/packages/aio-commerce-lib-events/source/commerce/api/event-subscriptions/schema.ts b/packages/aio-commerce-lib-events/source/commerce/api/event-subscriptions/schema.ts index ea1c389af..d14206fc6 100644 --- a/packages/aio-commerce-lib-events/source/commerce/api/event-subscriptions/schema.ts +++ b/packages/aio-commerce-lib-events/source/commerce/api/event-subscriptions/schema.ts @@ -90,6 +90,19 @@ export const EventSubscriptionCreateParamsSchema = v.object({ rules: v.optional(rulesSchema("rules")), }); +export const EventSubscriptionUpdateParamsSchema = v.object({ + destination: v.optional(stringValueSchema("destination")), + fields: fieldsSchema("fields"), + + hipaa_audit_required: v.optional(booleanValueSchema("hipaa_audit_required")), + name: stringValueSchema("name"), + parent: v.optional(stringValueSchema("parent")), + priority: v.optional(booleanValueSchema("priority")), + + provider_id: v.optional(stringValueSchema("provider_id")), + rules: v.optional(rulesSchema("rules")), +}); + export const EventSubscriptionDeleteParamsSchema = v.object({ name: stringValueSchema("name"), }); @@ -102,6 +115,14 @@ export type EventSubscriptionCreateParams = v.InferInput< typeof EventSubscriptionCreateParamsSchema >; +/** + * The schema of the parameters received by the PUT `eventing/eventSubscribe/:name` Commerce API endpoint. + * @see https://developer.adobe.com/commerce/extensibility/events/api/#update-an-event-subscription + */ +export type EventSubscriptionUpdateParams = v.InferInput< + typeof EventSubscriptionUpdateParamsSchema +>; + /** * The schema of the parameters received by the POST `eventing/eventUnsubscribe/:name` Commerce API endpoint. * @see https://developer.adobe.com/commerce/extensibility/events/api/#unsubscribe-from-events diff --git a/packages/aio-commerce-lib-events/test/fixtures/commerce-events-api-payloads.ts b/packages/aio-commerce-lib-events/test/fixtures/commerce-events-api-payloads.ts index 745ea2ac3..e1fe8cfcf 100644 --- a/packages/aio-commerce-lib-events/test/fixtures/commerce-events-api-payloads.ts +++ b/packages/aio-commerce-lib-events/test/fixtures/commerce-events-api-payloads.ts @@ -90,6 +90,23 @@ export const COMMERCE_EVENTS_API_PAYLOADS = [ { hasInputValidation: true, + invoke(client: CommerceEventsApiClient, fetchOptions?: Options) { + return client.updateEventSubscription( + { + fields: [{ name: "field-1" }], + name: "subscription-1", + provider_id: "provider-1", + }, + fetchOptions, + ); + }, + method: "PUT", + name: "updateEventSubscription", + pathname: "eventing/eventSubscribe/subscription-1", + }, + { + hasInputValidation: true, + invoke(client: CommerceEventsApiClient, fetchOptions?: Options) { return client.updateEventingConfiguration( { diff --git a/packages/aio-commerce-lib-events/test/unit/commerce.test.ts b/packages/aio-commerce-lib-events/test/unit/commerce.test.ts index 9a0d54da1..439efe90e 100644 --- a/packages/aio-commerce-lib-events/test/unit/commerce.test.ts +++ b/packages/aio-commerce-lib-events/test/unit/commerce.test.ts @@ -35,6 +35,7 @@ describe("Commerce Events API", () => { expect(client).toHaveProperty("deleteEventProvider"); expect(client).toHaveProperty("getAllEventSubscriptions"); expect(client).toHaveProperty("createEventSubscription"); + expect(client).toHaveProperty("updateEventSubscription"); expect(client).toHaveProperty("deleteEventSubscription"); expect(client).toHaveProperty("updateEventingConfiguration"); });