From 42f8ecc30c7eb8da0ff277ff6713a04c9162795d Mon Sep 17 00:00:00 2001 From: Rezaan Syed Date: Fri, 28 Aug 2026 13:34:57 -0400 Subject: [PATCH 1/3] Fan out one events module per subscription behind an organization flag or environment opt-in Assisted-By: devx/aa56a38c-289a-416e-8a9a-0de281e4e3e7 --- .changeset/events-subscription-fanout.md | 5 + .../app/src/cli/models/app/loader.test.ts | 211 ++++++++++++++++++ packages/app/src/cli/models/app/loader.ts | 55 ++++- .../models/extensions/extension-instance.ts | 13 ++ .../utilities/developer-platform-client.ts | 4 +- .../app-management-client.test.ts | 75 +++++++ .../app-management-client.ts | 19 +- 7 files changed, 373 insertions(+), 9 deletions(-) create mode 100644 .changeset/events-subscription-fanout.md diff --git a/.changeset/events-subscription-fanout.md b/.changeset/events-subscription-fanout.md new file mode 100644 index 00000000000..8b7ff42a7f6 --- /dev/null +++ b/.changeset/events-subscription-fanout.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Fan out one events module per subscription, gated by an organization flag or SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index 09503ba1461..d6b151c800e 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -2198,6 +2198,217 @@ describe('load', () => { ]) }) + test('fans out one events module per subscription when the fan-out is enabled', async () => { + // Given + const appConfigurationWithEvents = ` + name = "for-testing-events" + client_id = "1234567890" + application_url = "https://example.com/lala" + embedded = true + + [build] + include_config_on_deploy = true + + [webhooks] + api_version = "2024-01" + + [auth] + redirect_urls = [ "https://example.com/api/auth" ] + + [events] + api_version = "2024-01" + + [[events.subscription]] + topic = "orders/create" + actions = ["create"] + handle = "order-notifier" + uri = "https://example.com/events/orders" + + [[events.subscription]] + topic = "products/update" + actions = ["update"] + handle = "product-sync" + uri = "https://example.com/events/products" + ` + await writeConfig(appConfigurationWithEvents) + process.env.SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT = '1' + + try { + // When + const app = await loadTestingApp({remoteFlags: []}) + + // Then + const eventsExtensions = app.allExtensions.filter((ext) => ext.specification.identifier === 'events') + expect(eventsExtensions).toHaveLength(2) + expect(eventsExtensions.map((ext) => ext.configuration)).toEqual([ + { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + actions: ['create'], + handle: 'order-notifier', + uri: 'https://example.com/events/orders', + }, + }, + }, + { + events: { + api_version: '2024-01', + subscription: { + topic: 'products/update', + actions: ['update'], + handle: 'product-sync', + uri: 'https://example.com/events/products', + }, + }, + }, + ]) + expect(eventsExtensions.map((ext) => ext.handle)).toEqual(['order-notifier', 'product-sync']) + expect(eventsExtensions.map((ext) => ext.uid)).toEqual(['order-notifier', 'product-sync']) + } finally { + delete process.env.SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT + } + }) + + test('fans out events modules when the remote flag is enabled without the environment opt-in', async () => { + // Given + const appConfigurationWithEvents = ` + name = "for-testing-events" + client_id = "1234567890" + application_url = "https://example.com/lala" + embedded = true + + [webhooks] + api_version = "2024-01" + + [auth] + redirect_urls = [ "https://example.com/api/auth" ] + + [events] + api_version = "2024-01" + + [[events.subscription]] + topic = "orders/create" + actions = ["create"] + handle = "order-notifier" + uri = "https://example.com/events/orders" + ` + await writeConfig(appConfigurationWithEvents) + + // When + const app = await loadTestingApp({remoteFlags: [Flag.SingleSubscriptionEventsModules]}) + + // Then + const eventsExtensions = app.allExtensions.filter((ext) => ext.specification.identifier === 'events') + expect(eventsExtensions).toHaveLength(1) + expect(eventsExtensions[0]!.configuration).toEqual({ + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + actions: ['create'], + handle: 'order-notifier', + uri: 'https://example.com/events/orders', + }, + }, + }) + expect(eventsExtensions[0]!.handle).toEqual('order-notifier') + }) + + test('loads a single events module with the subscription list when the fan-out is disabled', async () => { + // Given + const appConfigurationWithEvents = ` + name = "for-testing-events" + client_id = "1234567890" + application_url = "https://example.com/lala" + embedded = true + + [webhooks] + api_version = "2024-01" + + [auth] + redirect_urls = [ "https://example.com/api/auth" ] + + [events] + api_version = "2024-01" + + [[events.subscription]] + topic = "orders/create" + actions = ["create"] + handle = "order-notifier" + uri = "https://example.com/events/orders" + ` + await writeConfig(appConfigurationWithEvents) + + // When + const app = await loadTestingApp({remoteFlags: []}) + + // Then + const eventsExtensions = app.allExtensions.filter((ext) => ext.specification.identifier === 'events') + expect(eventsExtensions).toHaveLength(1) + expect(eventsExtensions[0]!.configuration).toMatchObject({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + actions: ['create'], + handle: 'order-notifier', + uri: 'https://example.com/events/orders', + }, + ], + }, + }) + }) + + test('rejects duplicate event subscription handles when the fan-out is enabled', async () => { + // Given + const appConfigurationWithEvents = ` + name = "for-testing-events" + client_id = "1234567890" + application_url = "https://example.com/lala" + embedded = true + + [webhooks] + api_version = "2024-01" + + [auth] + redirect_urls = [ "https://example.com/api/auth" ] + + [events] + api_version = "2024-01" + + [[events.subscription]] + topic = "orders/create" + actions = ["create"] + handle = "order-notifier" + uri = "https://example.com/events/orders" + + [[events.subscription]] + topic = "products/update" + actions = ["update"] + handle = "order-notifier" + uri = "https://example.com/events/products" + ` + await writeConfig(appConfigurationWithEvents) + process.env.SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT = '1' + + try { + // When + const app = await loadTestingApp({remoteFlags: []}) + + // Then + const errorMessages = app.errors + .getErrors() + .map((error) => error.message) + .join('\n') + expect(errorMessages).toContain('Duplicated handle "order-notifier"') + } finally { + delete process.env.SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT + } + }) + test('loads the app with several functions that have valid configurations', async () => { // Given await writeConfig(appConfiguration) diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index 28bac00d9d4..031b04f3885 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -26,6 +26,7 @@ import {ExtensionSpecification, isAppConfigSpecification} from '../extensions/sp import {CreateAppOptions, Flag} from '../../utilities/developer-platform-client.js' import {findConfigFiles} from '../../prompts/config.js' import {WebhookSubscriptionSpecIdentifier} from '../extensions/specifications/app_config_webhook_subscription.js' +import {EventsSpecIdentifier} from '../extensions/specifications/app_config_events.js' import {WebhooksSchema} from '../extensions/specifications/app_config_webhook_schemas/webhooks_schema.js' import {ApplicationURLs, generateApplicationURLs} from '../../services/dev/urls.js' import {Project} from '../project/project.js' @@ -49,6 +50,8 @@ import {AbortError} from '@shopify/cli-kit/node/error' import {outputContent, outputDebug, outputToken, stringifyMessage} from '@shopify/cli-kit/node/output' import {joinWithAnd} from '@shopify/cli-kit/common/string' import {getArrayRejectingUndefined} from '@shopify/cli-kit/common/array' +import {getPathValue} from '@shopify/cli-kit/common/object' +import {isTruthy} from '@shopify/cli-kit/node/context/utilities' import {showNotificationsIfNeeded} from '@shopify/cli-kit/node/notifications-system' import ignore from 'ignore' import type {ActiveConfig} from '../project/active-config.js' @@ -787,11 +790,21 @@ class AppLoader instance) - .map(([instance]) => instance as ExtensionInstance) + return getArrayRejectingUndefined(extensionInstancesWithKeys.flatMap(([instances]) => instances)) + } + + private async createEventSubscriptionInstances( + specification: ExtensionSpecification, + specConfiguration: object, + configPath: string, + directory: string, + ): Promise { + if (specification.identifier !== EventsSpecIdentifier) return undefined + const fanoutEnabled = + this.remoteFlags.includes(Flag.SingleSubscriptionEventsModules) || + isTruthy(process.env.SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT) + if (!fanoutEnabled) return undefined + + const events = getPathValue<{api_version?: string; subscription?: {[key: string]: unknown}[]}>( + specConfiguration, + 'events', + ) + const subscriptions = events?.subscription + if (!Array.isArray(subscriptions) || subscriptions.length === 0) return undefined + + const instances = await Promise.all( + subscriptions.map(async (subscription) => + this.createExtensionInstance( + specification.identifier, + {events: {api_version: events?.api_version, subscription}}, + configPath, + directory, + ), + ), + ) + return getArrayRejectingUndefined(instances) } private async validateConfigurationExtensionInstance( diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index cce10baee56..b8eb2af2a19 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -522,7 +522,17 @@ export class ExtensionInstance -export enum Flag {} +export enum Flag { + SingleSubscriptionEventsModules = 'single_subscription_events_modules', +} const FlagMap: {[key: string]: Flag} = {} diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts index 5c54c82fb6a..2e5b40510ca 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts @@ -9,6 +9,7 @@ import { versionDeepLink, } from './app-management-client.js' import {OrganizationBetaFlagsQuerySchema} from './app-management-client/graphql/organization_beta_flags.js' +import {Flag} from '../developer-platform-client.js' import {OrganizationExpFlagsQuery} from '../../api/graphql/business-platform-organizations/generated/organization_exp_flags.js' import { testUIExtension, @@ -1233,6 +1234,80 @@ describe('deploy', () => { expect(result.appDeploy.userErrors[0]?.details).toHaveLength(0) }) + describe('appFromIdentifiers', () => { + function mockedActiveAppReleaseResponse() { + return { + app: { + id: 'gid://shopify/App/123', + key: 'api-key', + organizationId: 'gid://shopify/Organization/123', + activeRoot: { + grantedShopifyApprovalScopes: [], + clientCredentials: {secrets: [{key: 'secret'}]}, + }, + activeRelease: { + id: 'gid://shopify/Release/1', + version: { + name: 'app-name', + appModules: [], + }, + }, + }, + } + } + + test('includes the single-subscription events flag when the organization exp flag is enabled', async () => { + // Given + const client = AppManagementClient.getInstance() + client.token = () => Promise.resolve('token') + client.businessPlatformToken = () => Promise.resolve('business-platform-token') + vi.mocked(appManagementRequestDoc).mockResolvedValueOnce(mockedActiveAppReleaseResponse()) + const mockedExpFlagsResponse: OrganizationExpFlagsQuery = { + organization: {id: 'gid://organization/Organization/123', enabledFlags: [true]}, + } + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce(mockedExpFlagsResponse) + + // When + const app = await client.appFromIdentifiers('api-key') + + // Then + expect(app?.flags).toEqual([Flag.SingleSubscriptionEventsModules]) + }) + + test('returns no flags when the organization exp flag is disabled', async () => { + // Given + const client = AppManagementClient.getInstance() + client.token = () => Promise.resolve('token') + client.businessPlatformToken = () => Promise.resolve('business-platform-token') + vi.mocked(appManagementRequestDoc).mockResolvedValueOnce(mockedActiveAppReleaseResponse()) + const mockedExpFlagsResponse: OrganizationExpFlagsQuery = { + organization: {id: 'gid://organization/Organization/123', enabledFlags: [false]}, + } + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce(mockedExpFlagsResponse) + + // When + const app = await client.appFromIdentifiers('api-key') + + // Then + expect(app?.flags).toEqual([]) + }) + + test('returns no flags when the exp flag lookup fails', async () => { + // Given + const client = AppManagementClient.getInstance() + client.token = () => Promise.resolve('token') + client.businessPlatformToken = () => Promise.resolve('business-platform-token') + vi.mocked(appManagementRequestDoc).mockResolvedValueOnce(mockedActiveAppReleaseResponse()) + vi.mocked(businessPlatformOrganizationsRequestDoc).mockRejectedValueOnce(new Error('boom')) + + // When + const app = await client.appFromIdentifiers('api-key') + + // Then + expect(app?.flags).toEqual([]) + }) + }) + test('queries for versions list', async () => { // Given const appId = 'gid://shopify/App/123' diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index c611a5dfa17..d549f31f269 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -24,6 +24,7 @@ import { SourceScanUploadUrlSchema, AppVersionIdentifiers, filterDisabledFlags, + Flag, ClientName, AppModuleVersion, CreateAppOptions, @@ -171,6 +172,7 @@ import {webhooksRequestDoc, WebhooksRequestOptions} from '@shopify/cli-kit/node/ import {randomUUID} from 'crypto' const TEMPLATE_JSON_URL = 'https://cdn.shopify.com/static/cli/extensions/templates.json' +const SINGLE_SUBSCRIPTION_EVENTS_MODULES_EXP_FLAG = 'f_single_subscription_events_modules_cli' const commandRunId = randomUUID() type OrgType = NonNullable @@ -354,16 +356,17 @@ export class AppManagementClient implements DeveloperPlatformClient { const {name, appModules} = app.activeRelease.version const appHomeModule = appModules.find((mod) => mod.specification.externalIdentifier === 'app_home') const apiSecretKeys = app.activeRoot.clientCredentials.secrets.map((secret) => ({secret: secret.key})) + const organizationId = String(numberFromGid(app.organizationId)) return { id: app.id, title: name, apiKey: app.key, apiSecretKeys, - organizationId: String(numberFromGid(app.organizationId)), + organizationId, grantedScopes: app.activeRoot.grantedShopifyApprovalScopes, applicationUrl: appHomeModule?.config?.app_url as string | undefined, embedded: appHomeModule?.config?.embedded as boolean | undefined, - flags: [], + flags: await this.remoteFlagsForOrganization(organizationId), developerPlatformClient: this, } } @@ -1082,6 +1085,18 @@ export class AppManagementClient implements DeveloperPlatformClient { return this.appManagementRequest({query: ActiveAppReleaseFromApiKey, variables: {apiKey}}) } + private async remoteFlagsForOrganization(organizationId: string): Promise { + try { + const enabledFlags = await this.organizationExpFlags(organizationId, [ + SINGLE_SUBSCRIPTION_EVENTS_MODULES_EXP_FLAG, + ]) + return enabledFlags[SINGLE_SUBSCRIPTION_EVENTS_MODULES_EXP_FLAG] ? [Flag.SingleSubscriptionEventsModules] : [] + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + return [] + } + } + private async organizationBetaFlags( organizationId: string, allBetaFlags: string[], From 4c0be73701d547046bca0a8a27be5d7a5624c758 Mon Sep 17 00:00:00 2001 From: David Peacock Date: Thu, 24 Sep 2026 21:28:24 -0400 Subject: [PATCH 2/3] Prototype Events identity with specification-owned fan-in Let an opt-in specification reconstruct its complete app-config section from module envelopes while existing scalar transforms keep their order and fallback behavior. Events owns identity and version aggregation, preserves explicit empty lists, and normalizes editing handles before strict fetched-contract validation without duplicating wire identity. This sibling alternative on events-subscription-fanout is not a rollout change. Real TOML, manifest, strict-contract and generic-ordering tests cover the tradeoff; collection ownership requires CLI-team agreement. --- .../app/src/cli/models/app/loader.test.ts | 6 +- .../models/extensions/extension-instance.ts | 8 +- .../cli/models/extensions/specification.ts | 18 +- .../specifications/app_config_events.ts | 51 +- .../transform/app_config_events.test.ts | 577 +++++------------- .../transform/app_config_events.ts | 151 ++--- .../app/configuration-modules.test.ts | 87 +++ .../cli/services/app/configuration-modules.ts | 37 ++ .../app/events-empty-list.integration.test.ts | 129 ++++ .../app/events-module-roundtrip.test.ts | 384 ++++++++++++ .../app/events-parser.integration.test.ts | 91 +++ .../app/events-strict-schema.test-data.ts | 56 ++ .../app/src/cli/services/app/select-app.ts | 16 +- .../context/deploy-identifier-matching.ts | 17 +- 14 files changed, 1095 insertions(+), 533 deletions(-) create mode 100644 packages/app/src/cli/services/app/configuration-modules.test.ts create mode 100644 packages/app/src/cli/services/app/configuration-modules.ts create mode 100644 packages/app/src/cli/services/app/events-empty-list.integration.test.ts create mode 100644 packages/app/src/cli/services/app/events-module-roundtrip.test.ts create mode 100644 packages/app/src/cli/services/app/events-parser.integration.test.ts create mode 100644 packages/app/src/cli/services/app/events-strict-schema.test-data.ts diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index d6b151c800e..54879965e23 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -2242,23 +2242,23 @@ describe('load', () => { expect(eventsExtensions).toHaveLength(2) expect(eventsExtensions.map((ext) => ext.configuration)).toEqual([ { + handle: 'order-notifier', events: { api_version: '2024-01', subscription: { topic: 'orders/create', actions: ['create'], - handle: 'order-notifier', uri: 'https://example.com/events/orders', }, }, }, { + handle: 'product-sync', events: { api_version: '2024-01', subscription: { topic: 'products/update', actions: ['update'], - handle: 'product-sync', uri: 'https://example.com/events/products', }, }, @@ -2303,12 +2303,12 @@ describe('load', () => { const eventsExtensions = app.allExtensions.filter((ext) => ext.specification.identifier === 'events') expect(eventsExtensions).toHaveLength(1) expect(eventsExtensions[0]!.configuration).toEqual({ + handle: 'order-notifier', events: { api_version: '2024-01', subscription: { topic: 'orders/create', actions: ['create'], - handle: 'order-notifier', uri: 'https://example.com/events/orders', }, }, diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index b8eb2af2a19..4d786ec93a1 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -1,5 +1,6 @@ import {BaseConfigType, MAX_EXTENSION_HANDLE_LENGTH, MAX_UID_LENGTH} from './schemas.js' import {FunctionConfigType} from './specifications/function.js' +import {eventSubscriptionHandle} from './specifications/transform/app_config_events.js' import { DevSessionUpdateContext, DevSessionWatchConfig, @@ -524,9 +525,10 @@ export class ExtensionInstance(this.configuration, 'events.subscription') + if (subscription == null || Array.isArray(subscription)) return undefined + const handle = typeof subscription === 'object' ? getPathValue(subscription, 'handle') : undefined + return eventSubscriptionHandle(this.configuration.handle ?? handle) } private buildHandle() { diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 65290be5c1f..59e604c7133 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -30,6 +30,11 @@ export interface CustomTransformationConfig { reverse?: (obj: object, options?: {flags?: Flag[]}) => object } +export interface ConfigurationModule { + readonly handle: string + readonly config: object +} + type ExtensionExperience = 'extension' | 'configuration' export function isAppConfigSpecification(spec: {experience: string}): boolean { @@ -118,6 +123,13 @@ export interface ExtensionSpecification object + /** + * Reconstruct a complete app-config section from this specification's modules, in input order. + * The specification must exclusively own the sections it emits. This replaces scalar readback; + * its result is merged at the first occurrence without reordering other specifications. + */ + aggregateModuleConfigurations?: (modules: ReadonlyArray, options?: {flags?: Flag[]}) => object + uidStrategy: UidStrategy /** @@ -276,6 +288,7 @@ export function createConfigExtensionSpecification ExtensionFeature[] transformConfig: TransformationConfig | CustomTransformationConfig + aggregateModuleConfigurations?: ExtensionSpecification['aggregateModuleConfigurations'] uidStrategy?: UidStrategy getDevSessionUpdateMessages?: (config: TConfiguration, context: DevSessionUpdateContext) => Promise patchWithAppDevURLs?: (config: TConfiguration, urls: ApplicationURLs) => void @@ -288,7 +301,10 @@ export function createConfigExtensionSpecification transformToEventsConfig(content), } const EventsSchema = BaseSchemaWithoutHandle.extend({ events: zod.any().optional(), }) -const appEventsSpec = createConfigExtensionSpecification({ +const baseSpec = createConfigExtensionSpecification({ identifier: EventsSpecIdentifier, schema: EventsSchema, transformConfig: EventsTransformConfig, + aggregateModuleConfigurations: aggregateEventsConfigurations, }) +const appEventsSpec: ExtensionSpecification = { + ...baseSpec, + parseConfigurationObject(configurationObject: object) { + const events = getPathValue>(configurationObject, 'events') + const subscription = events?.subscription + const isObject = subscription !== null && typeof subscription === 'object' && !Array.isArray(subscription) + // The loader parses extension configs again. Rebuild an editing copy for the unchanged local schema. + const editingConfig = isObject + ? { + ...configurationObject, + events: { + ...events, + subscription: {handle: getPathValue(configurationObject, 'handle'), ...subscription}, + }, + } + : configurationObject + const parsed = baseSpec.parseConfigurationObject(editingConfig) + if (parsed.state !== 'ok' || !isObject) return parsed + const parsedSubscription = getPathValue>(parsed.data, 'events.subscription') + if (!parsedSubscription) return parsed + + // Core forbids nested object handles. Its parser already supports first-class module identity. + const {handle, ...fields} = parsedSubscription + return { + ...parsed, + data: { + ...parsed.data, + handle: eventSubscriptionHandle(handle), + events: {...getPathValue(parsed.data, 'events'), subscription: fields}, + }, + } + }, +} + export default appEventsSpec diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts index 7ae2f0e92c9..c1f9e4e254c 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts @@ -1,479 +1,190 @@ -import {transformToEventsConfig, transformFromEventsConfig} from './app_config_events.js' -import {deepMergeObjects} from '@shopify/cli-kit/common/object' +import {aggregateEventsConfigurations, transformFromEventsConfig} from './app_config_events.js' +import {ConfigurationModule} from '../../specification.js' import {describe, expect, test} from 'vitest' -describe('transformFromEventsConfig', () => { - test('returns content as-is when all URIs are absolute', () => { - const content = { - events: { - api_version: '2024-01', - subscription: [{topic: 'orders/create', uri: 'https://example.com', actions: ['create']}], - }, - } - const appConfiguration = {application_url: 'https://tunnel.example.com'} - - const result = transformFromEventsConfig(content, appConfiguration) - - expect(result).toEqual(content) - }) - - test('prepends application_url to relative URIs in subscriptions', () => { - const content = { - events: { - api_version: '2024-01', - subscription: [ - {topic: 'orders/create', uri: '/webhooks/orders', actions: ['create']}, - {topic: 'products/update', uri: 'https://absolute.example.com/webhook', actions: ['update']}, - ], - }, - } - const appConfiguration = {application_url: 'https://tunnel.example.com'} +const PAYLOAD = { + topic: 'products/update', + actions: ['update'], + triggers: ['title'], + uri: 'https://example.com/events', + query: 'query { product { id title } }', + query_filter: 'status:active', +} + +function moduleWith( + subscription: unknown, + apiVersion: unknown = '2026-01', + handle = 'Exact_CASE', +): ConfigurationModule { + return {handle, config: {events: {api_version: apiVersion, subscription}}} +} - const result = transformFromEventsConfig(content, appConfiguration) - - expect(result).toEqual({ - events: { - api_version: '2024-01', - subscription: [ - {topic: 'orders/create', uri: 'https://tunnel.example.com/webhooks/orders', actions: ['create']}, - {topic: 'products/update', uri: 'https://absolute.example.com/webhook', actions: ['update']}, - ], +describe('transformFromEventsConfig', () => { + test.each([undefined, {}, {application_url: 'https://tunnel.example.com/'}])( + 'omits object identity, preserves payload, and leaves local config unchanged (%j)', + (appConfiguration) => { + const content = { + events: {api_version: '2026-01', subscription: {...PAYLOAD, handle: 'Exact_CASE', uri: '/events'}}, + } + const before = structuredClone(content) + const result = transformFromEventsConfig(content, appConfiguration) + expect(result).toEqual({ + events: { + api_version: '2026-01', + subscription: { + ...PAYLOAD, + uri: appConfiguration?.application_url ? 'https://tunnel.example.com/events' : '/events', + }, + }, + }) + expect(content).toEqual(before) + }, + ) + + test('retains list handles and resolves only relative URLs', () => { + const subscriptions = [ + {...PAYLOAD, handle: 'Relative', uri: '/events'}, + {...PAYLOAD, handle: 'Absolute'}, + {...PAYLOAD, handle: 'Pubsub', uri: 'pubsub://project:topic'}, + ] + expect( + transformFromEventsConfig({events: {subscription: subscriptions}}, {application_url: 'https://app.com'}), + ).toEqual({ + events: { + subscription: [{...subscriptions[0], uri: 'https://app.com/events'}, subscriptions[1], subscriptions[2]], }, }) }) - test('returns content as-is when no application_url in config', () => { - const content = { - events: { - api_version: '2024-01', - subscription: [{topic: 'orders/create', uri: '/webhooks/orders', actions: ['create']}], - }, - } - - const result = transformFromEventsConfig(content, {}) - - expect(result).toEqual(content) - }) - - test('returns content as-is when no appConfiguration provided', () => { - const content = { - events: { - api_version: '2024-01', - subscription: [{topic: 'orders/create', uri: '/webhooks/orders', actions: ['create']}], - }, - } - - const result = transformFromEventsConfig(content) - - expect(result).toEqual(content) + test.each([undefined, null, 42, false])('retains a missing/invalid URI for normal validation (%j)', (uri) => { + const subscription = {...PAYLOAD, handle: 'A', uri} + expect( + transformFromEventsConfig({events: {subscription: [subscription]}}, {application_url: 'https://app.com'}), + ).toEqual({events: {subscription: [subscription]}}) }) - test('handles application_url with trailing slash', () => { - const content = { - events: { - api_version: '2024-01', - subscription: [{topic: 'orders/create', uri: '/webhooks/orders', actions: ['create']}], - }, - } - const appConfiguration = {application_url: 'https://tunnel.example.com/'} - - const result = transformFromEventsConfig(content, appConfiguration) + test.each([{}, {events: {}}, {events: {subscription: null}}, {events: {subscription: []}}])( + 'preserves empty content (%j)', + (content) => expect(transformFromEventsConfig(content)).toEqual(content), + ) +}) - expect(result).toEqual({ - events: { - api_version: '2024-01', - subscription: [ - {topic: 'orders/create', uri: 'https://tunnel.example.com/webhooks/orders', actions: ['create']}, - ], - }, +describe('aggregateEventsConfigurations', () => { + test.each([undefined, 'historical-nested-handle'])('outer object handle is authoritative (%j)', (handle) => { + const module = moduleWith({...PAYLOAD, handle, identifier: 'server-owned', api_version: '2026-01'}) + const before = structuredClone(module) + expect(aggregateEventsConfigurations([module])).toEqual({ + events: {api_version: '2026-01', subscription: [{...PAYLOAD, handle: 'Exact_CASE'}]}, }) + expect(module).toEqual(before) }) - test('returns content as-is when subscription array is empty', () => { - const content = { - events: { - api_version: '2024-01', - subscription: [], - }, - } - const appConfiguration = {application_url: 'https://tunnel.example.com'} - - const result = transformFromEventsConfig(content, appConfiguration) - - expect(result).toEqual(content) - }) - - test('returns content as-is when no subscriptions', () => { - const content = { - events: { - api_version: '2024-01', - }, - } - const appConfiguration = {application_url: 'https://tunnel.example.com'} - - const result = transformFromEventsConfig(content, appConfiguration) - - expect(result).toEqual(content) - }) - - test('prepends application_url to a relative URI in a single subscription object', () => { - const content = { - events: { - api_version: '2024-01', - subscription: {topic: 'orders/create', uri: '/webhooks/orders', actions: ['create']}, - }, - } - const appConfiguration = {application_url: 'https://tunnel.example.com'} - - const result = transformFromEventsConfig(content, appConfiguration) - - expect(result).toEqual({ - events: { - api_version: '2024-01', - subscription: {topic: 'orders/create', uri: 'https://tunnel.example.com/webhooks/orders', actions: ['create']}, - }, + test('the literal events is a valid outer identity, not a missing-handle sentinel', () => { + expect(aggregateEventsConfigurations([moduleWith(PAYLOAD, '2026-01', 'events')])).toEqual({ + events: {api_version: '2026-01', subscription: [{...PAYLOAD, handle: 'events'}]}, }) }) - test('returns content as-is when events is undefined', () => { - const content = {} - const appConfiguration = {application_url: 'https://tunnel.example.com'} - - const result = transformFromEventsConfig(content, appConfiguration) - - expect(result).toEqual(content) + test.each(['', ' ', 'contains space', '!bad', 'x'.repeat(51)])('rejects invalid outer identity %j', (handle) => { + expect(() => + aggregateEventsConfigurations([moduleWith({...PAYLOAD, handle: 'not-a-fallback'}, '2026-01', handle)]), + ).toThrow('identity must be a handle') }) -}) -describe('transformToEventsConfig', () => { - test('strips server-managed identifier field from subscriptions while preserving all other fields', () => { - const remoteContent = { + test('retains independent legacy identities and duplicates, including case variants, for validation', () => { + const subscription = {...PAYLOAD, handle: 'First', identifier: 'id'} + const modules = [ + moduleWith([subscription, subscription, {...subscription, handle: 'first'}], '2026-01', 'not-used'), + ] + expect(aggregateEventsConfigurations(modules)).toEqual({ events: { - api_version: '2024-01', + api_version: '2026-01', subscription: [ - { - topic: 'orders/create', - uri: 'https://example.com/webhook', - actions: ['create'], - identifier: 'id-1', - }, - { - topic: 'products/update', - uri: 'https://example.com/webhook', - actions: ['update'], - handle: 'my-subscription', - triggers: ['product_updated'], - query: 'query { id }', - query_filter: 'status:active', - identifier: 'id-2', - }, + {...PAYLOAD, handle: 'First'}, + {...PAYLOAD, handle: 'First'}, + {...PAYLOAD, handle: 'first'}, ], }, - } - - const result = transformToEventsConfig(remoteContent) - - expect(result).toEqual({ - events: { - api_version: '2024-01', - subscription: [ - { - topic: 'orders/create', - uri: 'https://example.com/webhook', - actions: ['create'], - }, - { - topic: 'products/update', - uri: 'https://example.com/webhook', - actions: ['update'], - handle: 'my-subscription', - triggers: ['product_updated'], - query: 'query { id }', - query_filter: 'status:active', - }, - ], - }, - }) - }) - - test('handles missing subscription field', () => { - const remoteContent = { - events: { - api_version: '2024-01', - }, - } - - const result = transformToEventsConfig(remoteContent) - - expect(result).toEqual({ - events: { - api_version: '2024-01', - subscription: undefined, - }, }) }) - test('strips the identifier from a single subscription object and returns it as a one-element array', () => { - const remoteContent = { - events: { - api_version: '2024-01', - subscription: { - topic: 'orders/create', - uri: 'https://example.com/webhook', - actions: ['create'], - handle: 'order-notifier', - identifier: 'id-1', - }, - }, - } - - const result = transformToEventsConfig(remoteContent) - expect(result).toEqual({ - events: { - api_version: '2024-01', - subscription: [ - { - topic: 'orders/create', - uri: 'https://example.com/webhook', - actions: ['create'], - handle: 'order-notifier', - }, + test.each([false, true])('preserves mixed modules and effective versions in either order (reverse=%j)', (reverse) => { + const modules = [ + moduleWith({...PAYLOAD, identifier: 'server-id'}, '2026-04', 'Object'), + moduleWith( + [ + {...PAYLOAD, handle: 'Legacy', api_version: '2026-01'}, + {...PAYLOAD, handle: 'Override', api_version: '2026-07'}, ], - }, + '2026-01', + 'ignored', + ), + moduleWith({...PAYLOAD, api_version: '2026-01'}, '2026-04', 'Explicit'), + ] + const expected = [ + [{...PAYLOAD, handle: 'Object', api_version: '2026-04'}], + [ + {...PAYLOAD, handle: 'Legacy'}, + {...PAYLOAD, handle: 'Override', api_version: '2026-07'}, + ], + [{...PAYLOAD, handle: 'Explicit'}], + ] + expect(aggregateEventsConfigurations(reverse ? [...modules].reverse() : modules)).toEqual({ + events: {api_version: '2026-01', subscription: (reverse ? [...expected].reverse() : expected).flat()}, }) }) - test('merging multiple single-subscription modules accumulates one subscription array', () => { - const moduleOne = { - events: { - api_version: '2024-01', - subscription: { - topic: 'orders/create', - uri: 'https://example.com/a', - actions: ['create'], - handle: 'a', - identifier: 'id-a', - }, - }, - } - const moduleTwo = { - events: { - api_version: '2024-01', - subscription: { - topic: 'products/update', - uri: 'https://example.com/b', - actions: ['update'], - handle: 'b', - identifier: 'id-b', - }, - }, - } - - const merged = deepMergeObjects(transformToEventsConfig(moduleOne), transformToEventsConfig(moduleTwo)) - - expect(merged).toEqual({ - events: { - api_version: '2024-01', - subscription: [ - {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a'}, - {topic: 'products/update', uri: 'https://example.com/b', actions: ['update'], handle: 'b'}, - ], - }, + test.each([undefined, null, []])('later empty subscriptions do not erase earlier entries (%j)', (subscription) => { + expect(aggregateEventsConfigurations([moduleWith(PAYLOAD), moduleWith(subscription)])).toEqual({ + events: {api_version: '2026-01', subscription: [{...PAYLOAD, handle: 'Exact_CASE'}]}, }) }) - test('strips a subscription api_version that matches the events default in the list shape', () => { - const remoteContent = { - events: { - api_version: '2024-01', - subscription: [ - { - topic: 'orders/create', - uri: 'https://example.com/a', - actions: ['create'], - api_version: '2024-01', - identifier: 'id-a', - }, - { - topic: 'products/update', - uri: 'https://example.com/b', - actions: ['update'], - api_version: '2024-01', - identifier: 'id-b', - }, - ], - }, - } - - const result = transformToEventsConfig(remoteContent) - - expect(result).toEqual({ - events: { - api_version: '2024-01', - subscription: [ - {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create']}, - {topic: 'products/update', uri: 'https://example.com/b', actions: ['update']}, - ], - }, + test('empty collections retain a deterministic default and omit absent subscription properties', () => { + expect(aggregateEventsConfigurations([])).toEqual({events: {}}) + expect(aggregateEventsConfigurations([{handle: 'ignored', config: {}}])).toEqual({events: {}}) + const result = aggregateEventsConfigurations([moduleWith(undefined, '2026-04'), moduleWith(null, '2026-01')]) + expect(result).toEqual({events: {api_version: '2026-01'}}) + expect(result.events).not.toHaveProperty('subscription') + expect(aggregateEventsConfigurations([moduleWith([])])).toEqual({ + events: {api_version: '2026-01', subscription: []}, }) }) - test('strips a subscription api_version that matches the events default in the single shape', () => { - const remoteContent = { - events: { - api_version: '2024-01', - subscription: { - topic: 'orders/create', - uri: 'https://example.com/a', - actions: ['create'], - api_version: '2024-01', - identifier: 'id-a', - }, - }, - } - - const result = transformToEventsConfig(remoteContent) + test.each([{}, 'bad', 0, false, [null], [42], [[]], [{}]])( + 'rejects malformed nonempty subscription %j', + (subscription) => { + expect(() => aggregateEventsConfigurations([moduleWith(subscription)])).toThrow('Invalid Events configuration') + expect(() => transformFromEventsConfig(moduleWith(subscription).config)).toThrow('Invalid Events configuration') + }, + ) - expect(result).toEqual({ - events: { - api_version: '2024-01', - subscription: [{topic: 'orders/create', uri: 'https://example.com/a', actions: ['create']}], - }, - }) + test.each(['bad', 12, []])('rejects malformed events table %j', (events) => { + expect(() => aggregateEventsConfigurations([{handle: 'A', config: {events}}])).toThrow( + 'Invalid Events configuration', + ) }) - test('keeps a subscription api_version that overrides the events default', () => { - const remoteContent = { - events: { - api_version: '2024-01', - subscription: { - topic: 'orders/create', - uri: 'https://example.com/a', - actions: ['create'], - api_version: '2025-07', - identifier: 'id-a', - }, - }, - } - - const result = transformToEventsConfig(remoteContent) - - expect(result).toEqual({ - events: { - api_version: '2024-01', - subscription: [ - {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], api_version: '2025-07'}, - ], - }, - }) + test.each([null, '', 42])('rejects invalid module version %j', (version) => { + expect(() => aggregateEventsConfigurations([moduleWith(PAYLOAD, version)])).toThrow('Invalid Events configuration') }) - test('keeps a subscription api_version when the events default is absent', () => { - const remoteContent = { - events: { - subscription: [ - { - topic: 'orders/create', - uri: 'https://example.com/a', - actions: ['create'], - api_version: '2024-01', - identifier: 'id-a', - }, - ], - }, - } - - const result = transformToEventsConfig(remoteContent) - - expect(result).toEqual({ - events: { - api_version: undefined, - subscription: [ - {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], api_version: '2024-01'}, - ], - }, - }) + test('does not borrow a neighboring module default, even for an explicit subscription override', () => { + const missingDefault = {handle: 'B', config: {events: {subscription: {...PAYLOAD, api_version: '2026-01'}}}} + expect(() => aggregateEventsConfigurations([moduleWith(PAYLOAD), missingDefault])).toThrow( + 'without events.api_version', + ) }) - test('merging single-subscription modules keeps only the overriding api_version', () => { - const moduleOne = { - events: { - api_version: '2024-01', - subscription: { - topic: 'orders/create', - uri: 'https://example.com/a', - actions: ['create'], - handle: 'a', - api_version: '2024-01', - identifier: 'id-a', - }, - }, - } - const moduleTwo = { - events: { - api_version: '2024-01', - subscription: { - topic: 'products/update', - uri: 'https://example.com/b', - actions: ['update'], - handle: 'b', - api_version: '2025-07', - identifier: 'id-b', - }, - }, - } - - const merged = deepMergeObjects(transformToEventsConfig(moduleOne), transformToEventsConfig(moduleTwo)) - - expect(merged).toEqual({ - events: { - api_version: '2024-01', - subscription: [ - {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a'}, - { - topic: 'products/update', - uri: 'https://example.com/b', - actions: ['update'], - handle: 'b', - api_version: '2025-07', - }, - ], - }, - }) + test.each([null, '', 42])('rejects invalid subscription version %j', (version) => { + expect(() => aggregateEventsConfigurations([moduleWith({...PAYLOAD, api_version: version})])).toThrow( + 'api_version must be a nonempty string', + ) }) - test('merging a list-shape module with a single-subscription module accumulates all subscriptions', () => { - const listModule = { - events: { - api_version: '2024-01', - subscription: [ - {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a', identifier: 'id-a'}, - ], - }, - } - const singleModule = { - events: { - api_version: '2024-01', - subscription: { - topic: 'products/update', - uri: 'https://example.com/b', - actions: ['update'], - handle: 'b', - identifier: 'id-b', - }, - }, - } - - const merged = deepMergeObjects(transformToEventsConfig(listModule), transformToEventsConfig(singleModule)) - - expect(merged).toEqual({ - events: { - api_version: '2024-01', - subscription: [ - {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a'}, - {topic: 'products/update', uri: 'https://example.com/b', actions: ['update'], handle: 'b'}, - ], - }, - }) + test.each([undefined, null, 42, '', 'with space'])('rejects missing/invalid legacy entry handle %j', (handle) => { + expect(() => aggregateEventsConfigurations([moduleWith([{...PAYLOAD, handle}])])).toThrow( + 'identity must be a handle', + ) }) }) diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts index 7d4187582ac..f7aeab51fa9 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts @@ -1,92 +1,99 @@ import {prependApplicationUrl} from '../validation/url_prepender.js' -import {CurrentAppConfiguration} from '../../../app/app.js' +import {ConfigurationModule, configWithoutFirstClassFields} from '../../specification.js' import {getPathValue} from '@shopify/cli-kit/common/object' +import {AbortError} from '@shopify/cli-kit/node/error' +import {zod} from '@shopify/cli-kit/node/schema' -interface EventSubscription { - uri: string - [key: string]: unknown -} +const SubscriptionShape = zod.record(zod.unknown()).refine((value) => Object.keys(value).length > 0) +const EventsShape = zod + .object({ + api_version: zod.string().min(1).optional(), + subscription: zod.union([SubscriptionShape, zod.array(SubscriptionShape)]).nullish(), + }) + .passthrough() -interface EventsConfig { - events?: { - api_version?: string - subscription?: EventSubscription | EventSubscription[] +export function eventSubscriptionHandle(handle: unknown): string { + if (typeof handle !== 'string' || !/^[a-zA-Z0-9_-]{1,50}$/.test(handle)) { + throw new AbortError( + 'Events subscription identity must be a handle of 1–50 letters, numbers, hyphens or underscores.', + ) } + return handle } -/** - * Transforms the events config from local to remote format. - * Resolves relative URIs (starting with /) by prepending the application_url. - * During dev, application_url is set to the tunnel URL, ensuring events - * are delivered to the correct endpoint. - */ -export function transformFromEventsConfig(content: object, appConfiguration?: object) { - const eventsConfig = content as EventsConfig +/** Resolve relative URIs, removing local editing identity only from outgoing object subscriptions. */ +export function transformFromEventsConfig(content: object, appConfiguration?: object): object { + const config = configWithoutFirstClassFields({...content}) + const events = readEvents(config) + if (!events?.subscription) return config - if (!eventsConfig.events?.subscription) { - return content + const appUrl = appConfiguration && getPathValue(appConfiguration, 'application_url') + const resolve = (subscription: Record) => ({ + ...subscription, + ...(typeof subscription.uri === 'string' ? {uri: prependApplicationUrl(subscription.uri, appUrl)} : {}), + }) + let subscription + if (Array.isArray(events.subscription)) { + subscription = events.subscription.map(resolve) + } else { + const {handle, ...payload} = events.subscription + subscription = resolve(payload) } - let appUrl: string | undefined - if (appConfiguration && 'application_url' in appConfiguration) { - appUrl = (appConfiguration as CurrentAppConfiguration)?.application_url - } + return {...config, events: {...events, subscription}} +} - const subscription = eventsConfig.events.subscription - const resolved = wrapSubscriptions(subscription).map((sub) => ({ - ...sub, - uri: prependApplicationUrl(sub.uri, appUrl), - })) +/** Reconstruct the entire Events section before compacting per-subscription versions against one default. */ +export function aggregateEventsConfigurations(modules: ReadonlyArray) { + const defaults: string[] = [] + const subscriptions: Record[] = [] + let hasSubscriptionList = false + for (const module of modules) { + const events = readEvents(module.config) + if (!events) continue + if (events.api_version !== undefined) defaults.push(events.api_version) + if (events.subscription == null) continue + hasSubscriptionList = true + const isList = Array.isArray(events.subscription) + const entries = Array.isArray(events.subscription) ? events.subscription : [events.subscription] + if (entries.length > 0 && events.api_version === undefined) { + throw new AbortError("Can't reconstruct Events subscriptions without events.api_version on their module.") + } + for (const entry of entries) { + const {identifier, handle, api_version: override, ...payload} = entry + if (override !== undefined && (typeof override !== 'string' || override.length === 0)) { + throw new AbortError('Events subscription api_version must be a nonempty string.') + } + subscriptions.push({ + ...payload, + handle: eventSubscriptionHandle(isList ? handle : module.handle), + api_version: override ?? events.api_version, + }) + } + } + + // Lexical ordering chooses a stable representation, not the newest API release. + const apiVersion = defaults.sort()[0] + const compacted = subscriptions.map(({api_version: version, ...subscription}) => + version === apiVersion ? subscription : {...subscription, api_version: version}, + ) return { - ...eventsConfig, events: { - ...eventsConfig.events, - subscription: Array.isArray(subscription) ? resolved : resolved[0], + ...(apiVersion === undefined ? {} : {api_version: apiVersion}), + ...(hasSubscriptionList ? {subscription: compacted} : {}), }, } } -interface RemoteEventSubscription { - identifier: string - api_version?: string - [key: string]: unknown -} - -/** - * Transforms the events config from remote to local format. - * Strips the server-managed 'identifier' field from subscriptions, and the - * per-subscription 'api_version' when it only echoes the events default. - */ -export function transformToEventsConfig(content: object) { - const eventsConfig = getPathValue(content, 'events') as { - api_version: string - subscription: RemoteEventSubscription | RemoteEventSubscription[] +function readEvents(content: object) { + const events = getPathValue(content, 'events') + if (events == null) return undefined + const parsed = EventsShape.safeParse(events) + if (!parsed.success) { + throw new AbortError( + 'Invalid Events configuration: expected an events table and a subscription object or array of nonempty objects.', + ) } - const apiVersion = getPathValue(eventsConfig, 'api_version') - const subscription = getPathValue(eventsConfig, 'subscription') - - // The server always includes identifier, and materializes the events default - // api_version onto every subscription. Both are derived, so they are stripped - // for the local TOML; an api_version that differs from the default is a real - // override and is kept. Single-subscription modules are normalized to a - // one-element array so that merging multiple modules accumulates a single - // subscription list. - const cleanedSubscriptions = - subscription === undefined - ? undefined - : wrapSubscriptions(subscription).map((sub) => { - const {identifier, api_version: subscriptionApiVersion, ...rest} = sub - const overridesDefault = subscriptionApiVersion !== undefined && subscriptionApiVersion !== apiVersion - return overridesDefault ? {...rest, api_version: subscriptionApiVersion} : rest - }) - - const events = - (apiVersion ?? cleanedSubscriptions) ? {api_version: apiVersion, subscription: cleanedSubscriptions} : {} - - return {events} -} - -function wrapSubscriptions(subscription: T | T[]): T[] { - return Array.isArray(subscription) ? subscription : [subscription] + return parsed.data } diff --git a/packages/app/src/cli/services/app/configuration-modules.test.ts b/packages/app/src/cli/services/app/configuration-modules.test.ts new file mode 100644 index 00000000000..a706a61c97d --- /dev/null +++ b/packages/app/src/cli/services/app/configuration-modules.test.ts @@ -0,0 +1,87 @@ +import {configurationFromModules, ConfigurationModuleEntry} from './configuration-modules.js' +import { + createConfigExtensionSpecification, + createExtensionSpecification, +} from '../../models/extensions/specification.js' +import {BaseSchema} from '../../models/extensions/schemas.js' +import eventsSpec from '../../models/extensions/specifications/app_config_events.js' +import {Flag} from '../../utilities/developer-platform-client.js' +import {describe, expect, test, vi} from 'vitest' + +const FLAGS = [Flag.SingleSubscriptionEventsModules] + +describe('configurationFromModules', () => { + test('collects interleaved opt-in modules once while retaining scalar order and section ownership', () => { + const calls: string[] = [] + const aggregateOne = vi.fn((modules) => { + calls.push('one') + return {one: modules.map((module: {handle: string}) => module.handle)} + }) + const aggregateTwo = vi.fn((modules) => { + calls.push('two') + return {two: modules.map((module: {handle: string}) => module.handle)} + }) + const one = createExtensionSpecification({ + identifier: 'one', + appModuleFeatures: () => [], + aggregateModuleConfigurations: aggregateOne, + }) + const two = createExtensionSpecification({ + identifier: 'two', + appModuleFeatures: () => [], + aggregateModuleConfigurations: aggregateTwo, + }) + const scalar = createExtensionSpecification({identifier: 'scalar', appModuleFeatures: () => []}) + const entries = [ + {specification: scalar, module: {handle: 's1', config: {shared: {value: 'first', list: ['a']}}}}, + {specification: one, module: {handle: 'a', config: {}}}, + {specification: two, module: {handle: 'x', config: {}}}, + {specification: scalar, module: {handle: 's2', config: {shared: {value: 'second', list: ['b']}}}}, + {specification: one, module: {handle: 'b', config: {}}}, + {specification: two, module: {handle: 'y', config: {}}}, + {specification: scalar, module: {handle: 's3', config: {shared: {value: 'last', list: ['c']}}}}, + ] + const result = configurationFromModules(entries, FLAGS, ({module}) => { + calls.push(module.handle) + return module.config + }) + expect(result).toEqual({shared: {value: 'last', list: ['a', 'b', 'c']}, one: ['a', 'b'], two: ['x', 'y']}) + expect(calls).toEqual(['s1', 'one', 'two', 's2', 's3']) + expect(aggregateOne).toHaveBeenCalledExactlyOnceWith([entries[1]?.module, entries[4]?.module], {flags: FLAGS}) + expect(aggregateTwo).toHaveBeenCalledExactlyOnceWith([entries[2]?.module, entries[5]?.module], {flags: FLAGS}) + expect(entries[0]?.module.config).toEqual({shared: {value: 'first', list: ['a']}}) + }) + + test('non-opt-in union still uses Set/reference identity, not structural deduplication', () => { + const specification = createExtensionSpecification({identifier: 'scalar', appModuleFeatures: () => []}) + const sameReference = {id: 1} + const equalValue = {id: 1} + const entries = [ + {specification, module: {handle: 'one', config: {list: [sameReference, 'a']}}}, + {specification, module: {handle: 'two', config: {list: [sameReference, equalValue, 'a', 'b']}}}, + ] + const result = configurationFromModules(entries, [], ({module}) => module.config) + expect(result).toEqual({list: [sameReference, 'a', equalValue, 'b']}) + }) + + test('keeps caller-specific fallbacks when a scalar transform is absent', () => { + const specification = createExtensionSpecification({identifier: 'scalar', appModuleFeatures: () => []}) + const entry = {specification, module: {handle: 'A', config: {remote: true}}, local: {local: true}} + expect(configurationFromModules([entry], [], ({module}) => module.config)).toEqual({remote: true}) + expect(configurationFromModules([entry], [], ({local}) => local)).toEqual({local: true}) + expect(configurationFromModules([], [], () => ({unused: true}))).toEqual({}) + }) + + test('factory does not synthesize a weaker scalar readback for aggregation specifications', () => { + const aggregateModuleConfigurations = vi.fn(() => ({owned: true})) + const specification = createConfigExtensionSpecification({ + identifier: 'synthetic', + schema: BaseSchema, + transformConfig: {forward: (config) => config}, + aggregateModuleConfigurations, + }) + expect(specification.transformRemoteToLocal).toBeUndefined() + expect(eventsSpec.transformRemoteToLocal).toBeUndefined() + expect(specification.aggregateModuleConfigurations).toBe(aggregateModuleConfigurations) + }) +}) diff --git a/packages/app/src/cli/services/app/configuration-modules.ts b/packages/app/src/cli/services/app/configuration-modules.ts new file mode 100644 index 00000000000..f7d5e221e4d --- /dev/null +++ b/packages/app/src/cli/services/app/configuration-modules.ts @@ -0,0 +1,37 @@ +import {ConfigurationModule, ExtensionSpecification} from '../../models/extensions/specification.js' +import {Flag} from '../../utilities/developer-platform-client.js' +import {deepMergeObjects} from '@shopify/cli-kit/common/object' + +export interface ConfigurationModuleEntry { + specification: ExtensionSpecification + module: ConfigurationModule +} + +/** Aggregate opted-in specifications once; preserve scalar transform order and caller-specific fallbacks. */ +export function configurationFromModules( + entries: ReadonlyArray, + flags: Flag[], + transform: (entry: T) => object, +): {[key: string]: unknown} { + const groups = new Map() + for (const {specification, module} of entries) { + if (!specification.aggregateModuleConfigurations) continue + const group = groups.get(specification) ?? [] + group.push(module) + groups.set(specification, group) + } + + let configuration: {[key: string]: unknown} = {} + for (const entry of entries) { + const aggregate = entry.specification.aggregateModuleConfigurations + if (aggregate) { + const group = groups.get(entry.specification) + if (!group) continue + configuration = deepMergeObjects(configuration, aggregate(group, {flags})) + groups.delete(entry.specification) + } else { + configuration = deepMergeObjects(configuration, transform(entry)) + } + } + return configuration +} diff --git a/packages/app/src/cli/services/app/events-empty-list.integration.test.ts b/packages/app/src/cli/services/app/events-empty-list.integration.test.ts new file mode 100644 index 00000000000..766eb7aab31 --- /dev/null +++ b/packages/app/src/cli/services/app/events-empty-list.integration.test.ts @@ -0,0 +1,129 @@ +import {overwriteLocalConfigFileWithRemoteAppConfiguration} from './config/link.js' +import {writeAppConfigurationFile} from './write-app-configuration-file.js' +import {DEFAULT_CONFIG, testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' +import {loadApp} from '../../models/app/loader.js' +import {loadLocalExtensionsSpecifications} from '../../models/extensions/load-specifications.js' +import {RemoteAwareExtensionSpecification} from '../../models/extensions/specification.js' +import {AppModuleVersion, Flag} from '../../utilities/developer-platform-client.js' +import {ensureDeployIdentifiersFromAppVersion} from '../context/deploy-identifier-matching.js' +import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' +import {writeManifestToBundle} from '../bundle.js' +import {inTemporaryDirectory, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {getPathValue} from '@shopify/cli-kit/common/object' +import {afterEach, expect, test, vi} from 'vitest' + +vi.mock('../local-storage.js') +vi.mock('../../prompts/deploy-release.js') +afterEach(() => vi.unstubAllEnvs()) + +test.each([{flags: []}, {flags: [Flag.SingleSubscriptionEventsModules]}])( + 'pulling an explicit empty remote list clears existing subscriptions with flags %j', + async ({flags}) => { + vi.stubEnv('SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT', '') + vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) + await inTemporaryDirectory(async (directory) => { + const specifications: RemoteAwareExtensionSpecification[] = (await loadLocalExtensionsSpecifications()).map( + (spec) => ({ + ...spec, + externalIdentifier: spec.identifier, + loadedRemoteSpecs: true, + }), + ) + const configuration = { + ...DEFAULT_CONFIG, + auth: {redirect_urls: ['https://myapp.com/callback']}, + events: { + api_version: '2026-07', + subscription: [{handle: 'Removed', topic: 'products', actions: ['update'], uri: 'https://myapp.com/events'}], + }, + } + const configPath = joinPath(directory, 'shopify.app.toml') + await writeFile(joinPath(directory, 'package.json'), '{}') + await writeAppConfigurationFile(configuration, configPath) + const app = await loadApp({directory, userProvidedConfigName: undefined, specifications, remoteFlags: flags}) + expect(app.errors.isEmpty()).toBe(true) + const modules: AppModuleVersion[] = await Promise.all( + app.allExtensions + .filter((extension) => extension.type !== 'events') + .map(async (extension) => ({ + registrationId: extension.handle, + registrationTitle: extension.handle, + registrationUuid: `uuid-${extension.handle}`, + type: extension.type, + config: await extension.deployConfig({apiKey: 'api-key', appConfiguration: app.configuration}), + specification: { + identifier: extension.specification.identifier, + name: extension.type, + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + })), + ) + modules.push({ + registrationId: 'events', + registrationTitle: 'events', + registrationUuid: 'uuid-events', + type: 'events', + config: {events: {api_version: '2026-07', subscription: []}}, + specification: { + identifier: 'events', + name: 'Events', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + }) + const activeAppVersion = {appModuleVersions: modules} + const developerPlatformClient = testDeveloperPlatformClient({activeAppVersion: async () => activeAppVersion}) + const remoteApp = testOrganizationApp({apiKey: 'api-key'}) + await overwriteLocalConfigFileWithRemoteAppConfiguration({ + remoteApp, + developerPlatformClient, + specifications, + flags, + configFileName: 'shopify.app.toml', + appDirectory: directory, + localAppOptions: { + state: 'reusable-current-app', + scopes: '', + localAppIdMatchedRemote: true, + existingBuildOptions: undefined, + existingConfig: app.configuration, + appDirectory: directory, + packageManager: 'npm', + }, + }) + const loaded = await loadApp({directory, userProvidedConfigName: undefined, specifications, remoteFlags: flags}) + expect(loaded.errors.isEmpty()).toBe(true) + expect(getPathValue(loaded.configuration, 'events.subscription')).toEqual([]) + await expect(readFile(configPath)).resolves.not.toContain('Removed') + const manifest = await loaded.manifest({}) + expect(manifest.modules.filter((module) => module.type === 'events')).toMatchObject([ + {handle: 'events', uid: 'events', config: {events: {api_version: '2026-07', subscription: []}}}, + ]) + await writeManifestToBundle(manifest, directory) + expect(JSON.parse(await readFile(joinPath(directory, 'manifest.json')))).toEqual( + JSON.parse(JSON.stringify(manifest)), + ) + await ensureDeployIdentifiersFromAppVersion({ + app: loaded, + appId: 'api-key', + appName: loaded.name, + release: false, + developerPlatformClient, + remoteApp, + envIdentifiers: {}, + activeAppVersion, + }) + expect(deployOrReleaseConfirmationPrompt).toHaveBeenLastCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: expect.objectContaining({ + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + }), + }), + ) + }) + }, +) diff --git a/packages/app/src/cli/services/app/events-module-roundtrip.test.ts b/packages/app/src/cli/services/app/events-module-roundtrip.test.ts new file mode 100644 index 00000000000..a53390caa85 --- /dev/null +++ b/packages/app/src/cli/services/app/events-module-roundtrip.test.ts @@ -0,0 +1,384 @@ +import {remoteAppConfigurationExtensionContent} from './select-app.js' +import {writeAppConfigurationFile} from './write-app-configuration-file.js' +import {strictEventsContract} from './events-strict-schema.test-data.js' +import {fetchSpecifications} from '../generate/fetch-extension-specifications.js' +import {RemoteSpecification} from '../../api/graphql/extension_specifications.js' +import {loadApp} from '../../models/app/loader.js' +import {loadLocalExtensionsSpecifications} from '../../models/extensions/load-specifications.js' +import eventsSpec from '../../models/extensions/specifications/app_config_events.js' +import {testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' +import {AppModuleVersion, Flag} from '../../utilities/developer-platform-client.js' +import {AppManagementClient} from '../../utilities/developer-platform-client/app-management-client.js' +import {ensureDeployIdentifiersFromAppVersion} from '../context/deploy-identifier-matching.js' +import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' +import {writeManifestToBundle} from '../bundle.js' +import {appManagementRequestDoc} from '@shopify/cli-kit/node/api/app-management' +import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {inTemporaryDirectory, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {afterEach, describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/api/app-management') +vi.mock('@shopify/cli-kit/node/api/business-platform') +vi.mock('../../prompts/deploy-release.js') +vi.mock('../local-storage.js') +vi.mock('@shopify/cli-kit/node/multiple-installation-warning') + +const BASE = { + client_id: 'test-id', + name: 'Events test', + application_url: 'https://example.com', + embedded: true, + auth: {redirect_urls: ['https://example.com/auth']}, + webhooks: {api_version: '2026-01'}, +} +const PAYLOAD = { + topic: 'products', + actions: ['update'], + triggers: ['title'], + uri: 'https://example.com/events', + query: 'query { product { id title } }', + query_filter: 'status:active', +} +const ENABLED = [Flag.SingleSubscriptionEventsModules] + +function remoteModule(handle: string, subscription: object, apiVersion = '2026-01'): AppModuleVersion { + return { + registrationTitle: handle, + registrationId: handle, + registrationUuid: `uuid-${handle}`, + type: 'events', + specification: { + identifier: 'events', + name: 'Events', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + config: {events: {api_version: apiVersion, subscription}}, + } +} + +async function loadEventsApp(directory: string, eventsConfig: object, remoteFlags: Flag[] = ENABLED) { + await writeAppConfigurationFile({...BASE, ...eventsConfig}, joinPath(directory, 'shopify.app.toml')) + await writeFile(joinPath(directory, 'package.json'), JSON.stringify({name: 'events-test', private: true})) + const remoteSpecs: RemoteSpecification[] = (await loadLocalExtensionsSpecifications()).map((spec) => ({ + identifier: spec.identifier, + externalIdentifier: spec.externalIdentifier, + name: spec.externalName, + externalName: spec.externalName, + experience: spec.experience, + managementExperience: 'cli', + gated: false, + registrationLimit: spec.registrationLimit, + uidStrategy: spec.uidStrategy, + validationSchema: spec.identifier === 'events' ? {jsonSchema: JSON.stringify(strictEventsContract)} : undefined, + })) + const specifications = await fetchSpecifications({ + developerPlatformClient: testDeveloperPlatformClient({specifications: async () => remoteSpecs}), + app: testOrganizationApp(), + }) + return loadApp({ + directory, + userProvidedConfigName: undefined, + specifications, + remoteFlags, + skipPrompts: true, + }) +} + +afterEach(() => vi.unstubAllEnvs()) + +describe('Events module round trip', () => { + test('failed flag lookup still loads and emits the historical list shape', async () => { + vi.stubEnv('SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT', '') + AppManagementClient.resetInstance() + const client = AppManagementClient.getInstance() + client.token = async () => 'token' + client.businessPlatformToken = async () => 'business-platform-token' + vi.mocked(appManagementRequestDoc).mockResolvedValueOnce({ + app: { + id: 'gid://shopify/App/123', + key: 'test-id', + organizationId: 'gid://shopify/Organization/123', + activeRoot: {grantedShopifyApprovalScopes: [], clientCredentials: {secrets: [{key: 'secret'}]}}, + activeRelease: {id: 'gid://shopify/Release/1', version: {name: BASE.name, appModules: []}}, + }, + }) + vi.mocked(businessPlatformOrganizationsRequestDoc).mockRejectedValueOnce(new Error('flag lookup failed')) + const remoteApp = await client.appFromIdentifiers(BASE.client_id) + expect(remoteApp?.flags).toEqual([]) + await inTemporaryDirectory(async (directory) => { + const config = remoteAppConfigurationExtensionContent([remoteModule('One', PAYLOAD)], [eventsSpec], []) + const app = await loadEventsApp(directory, config, remoteApp?.flags) + expect(app.errors.getErrors()).toEqual([]) + const manifest = await app.manifest(undefined) + const events = manifest.modules.filter((module) => module.type === eventsSpec.externalIdentifier) + expect(events).toHaveLength(1) + expect(events[0]?.handle).toBe('events') + expect(events[0]?.config).toHaveProperty('events.subscription', [{...PAYLOAD, handle: 'One'}]) + }) + }) + + test('missing remote object identity fails rather than using nested identity or UID', () => { + const module = remoteModule('known-uid', {...PAYLOAD, handle: 'nested-is-not-a-fallback'}) + Reflect.deleteProperty(module, 'registrationTitle') + expect(() => remoteAppConfigurationExtensionContent([module], [eventsSpec], [])).toThrow( + 'identity must be a handle', + ) + }) + + test.each([false, true])( + 'mixed remote lists/objects preserve every entry through real TOML (fanout=%j)', + async (fanout) => { + vi.stubEnv('SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT', '') + await inTemporaryDirectory(async (directory) => { + const modules = [ + remoteModule( + 'ignored-outer', + [ + {...PAYLOAD, handle: 'Legacy_A'}, + {...PAYLOAD, handle: 'Legacy_B', api_version: '2026-07'}, + ], + '2026-04', + ), + remoteModule('Object', {...PAYLOAD, uri: 'https://example.com/object'}), + ] + const config = remoteAppConfigurationExtensionContent(modules, [eventsSpec], []) + const app = await loadEventsApp(directory, config, fanout ? ENABLED : []) + expect(app.errors.getErrors()).toEqual([]) + const manifest = await app.manifest(undefined) + const events = manifest.modules.filter((module) => module.type === eventsSpec.externalIdentifier) + expect(events).toHaveLength(fanout ? 3 : 1) + const readback = events.map( + (module): AppModuleVersion => ({...remoteModule(module.handle, {}), config: module.config}), + ) + expect(remoteAppConfigurationExtensionContent(readback, [eventsSpec], [])).toEqual(config) + if (fanout) expect(events.map((module) => module.handle)).toEqual(['Legacy_A', 'Legacy_B', 'Object']) + else + expect(events[0]?.config).toHaveProperty('events.subscription', [ + {...PAYLOAD, handle: 'Legacy_A', api_version: '2026-04'}, + {...PAYLOAD, handle: 'Legacy_B', api_version: '2026-07'}, + {...PAYLOAD, handle: 'Object', uri: 'https://example.com/object'}, + ]) + }) + }, + ) + + test('handleless remote objects survive real TOML, loader, deployConfig, bundleConfig and manifest.json', async () => { + vi.stubEnv('SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT', '') + await inTemporaryDirectory(async (directory) => { + const remoteModules = [ + remoteModule('Exact_CASE', {...PAYLOAD, identifier: 'server-owned'}), + remoteModule('events', {...PAYLOAD, uri: 'https://example.com/other'}, '2026-04'), + ] + // Readback must not depend on the current writer rollout flag. + const config = remoteAppConfigurationExtensionContent(remoteModules, [eventsSpec], []) + const app = await loadEventsApp(directory, config) + expect(app.errors.getErrors()).toEqual([]) + const events = app.allExtensions.filter((extension) => extension.type === 'events') + expect(events.map(({handle, uid}) => ({handle, uid}))).toEqual([ + {handle: 'Exact_CASE', uid: 'Exact_CASE'}, + {handle: 'events', uid: 'events'}, + ]) + await expect(readFile(joinPath(directory, 'shopify.app.toml'))).resolves.toContain('handle = "Exact_CASE"') + + const uuids = Object.fromEntries( + events.map((extension) => [extension.localIdentifier, `uuid-${extension.handle}`]), + ) + const configs = await Promise.all( + events.map((extension) => + extension.deployConfig({apiKey: BASE.client_id, appConfiguration: app.configuration}), + ), + ) + expect(configs).toEqual([ + {events: {api_version: '2026-01', subscription: PAYLOAD}}, + { + events: { + api_version: '2026-01', + subscription: {...PAYLOAD, uri: 'https://example.com/other', api_version: '2026-04'}, + }, + }, + ]) + const bundles = await Promise.all( + events.map((extension) => + extension.bundleConfig({ + apiKey: BASE.client_id, + appConfiguration: app.configuration, + appModuleUuids: uuids, + developerPlatformClient: testDeveloperPlatformClient(), + }), + ), + ) + bundles.forEach((bundle, index) => { + expect(bundle).toMatchObject({ + handle: events[index]?.handle, + uid: events[index]?.uid, + uuid: `uuid-${events[index]?.handle}`, + }) + expect(JSON.parse(bundle?.config ?? '{}')).toEqual(configs[index]) + expect(events[index]?.configuration).toHaveProperty('handle', events[index]?.handle) + expect(events[index]?.configuration).not.toHaveProperty('events.subscription.handle') + }) + const manifest = await app.manifest(uuids) + const manifestEvents = manifest.modules.filter((module) => module.type === eventsSpec.externalIdentifier) + expect(manifestEvents).toHaveLength(2) + manifestEvents.forEach((module, index) => { + expect(module).toMatchObject({ + handle: events[index]?.handle, + uid: events[index]?.uid, + uuid: `uuid-${events[index]?.handle}`, + config: configs[index], + }) + expect(module.config).not.toHaveProperty('events.subscription.handle') + }) + const bundleDirectory = joinPath(directory, 'bundle') + await mkdir(bundleDirectory) + await writeManifestToBundle(manifest, bundleDirectory) + const written = JSON.parse(await readFile(joinPath(bundleDirectory, 'manifest.json'))) + expect(written).toEqual(JSON.parse(JSON.stringify(manifest))) + expect(written.modules.filter((module: {type: string}) => module.type === eventsSpec.externalIdentifier)).toEqual( + manifestEvents, + ) + + const readbackModules = manifestEvents.map( + (module): AppModuleVersion => ({...remoteModule(module.handle, {}), config: module.config}), + ) + expect(remoteAppConfigurationExtensionContent(readbackModules, [eventsSpec], [])).toEqual(config) + await writeAppConfigurationFile({...BASE, ...config}, joinPath(directory, 'shopify.app.toml')) + const again = await loadApp({ + directory, + specifications: app.specifications, + userProvidedConfigName: undefined, + remoteFlags: ENABLED, + skipPrompts: true, + }) + await expect(again.manifest(uuids)).resolves.toEqual(manifest) + }) + }) + + test.each([false, true])( + 'no-op deployment retains remote UUID/UID and normalized Events section (reverse=%j)', + async (reverse) => { + vi.stubEnv('SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT', '') + vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) + await inTemporaryDirectory(async (directory) => { + const modules = [ + remoteModule('Exact_CASE', {...PAYLOAD, handle: 'stale-nested', api_version: '2026-04'}, '2026-04'), + remoteModule('Other', {...PAYLOAD, uri: 'https://example.com/other'}, '2026-01'), + ] + const remoteEvents = reverse ? [...modules].reverse() : modules + const app = await loadEventsApp( + directory, + remoteAppConfigurationExtensionContent(remoteEvents, [eventsSpec], []), + ) + expect(app.errors.getErrors()).toEqual([]) + const manifest = await app.manifest(undefined) + const remote = manifest.modules.map((module): AppModuleVersion => { + const event = remoteEvents.find((event) => event.registrationTitle === module.handle) + if (event) return event + const specification = app.specifications.find((spec) => spec.externalIdentifier === module.type) + if (!specification) throw new Error(`Missing specification ${module.type}`) + return { + registrationTitle: module.handle, + registrationId: module.uid, + registrationUuid: `uuid-${module.uid}`, + type: specification.identifier, + config: module.config, + specification: { + identifier: specification.identifier, + name: specification.externalName, + experience: specification.experience, + options: {managementExperience: 'cli'}, + }, + } + }) + const identifiers = await ensureDeployIdentifiersFromAppVersion({ + app, + appId: BASE.client_id, + appName: BASE.name, + release: true, + envIdentifiers: {}, + activeAppVersion: {appModuleVersions: remote}, + remoteApp: testOrganizationApp(), + developerPlatformClient: testDeveloperPlatformClient(), + }) + expect(deployOrReleaseConfirmationPrompt).toHaveBeenLastCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: { + existingFieldNames: expect.arrayContaining(['events']), + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + }, + }), + ) + for (const extension of app.allExtensions.filter((extension) => extension.type === 'events')) { + expect(identifiers.appModuleUuids[extension.localIdentifier]).toBe(`uuid-${extension.handle}`) + expect(identifiers.appModuleRegistrationIds[extension.localIdentifier]).toBe(extension.handle) + } + }) + }, + ) + + test.each([ + {flags: ENABLED, optIn: '', fanout: true}, + {flags: [], optIn: '1', fanout: true}, + {flags: [], optIn: '', fanout: false}, + ])('preserves writer rollout policy (%j)', async ({flags, optIn, fanout}) => { + vi.stubEnv('SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT', optIn) + await inTemporaryDirectory(async (directory) => { + const subscriptions = [ + {...PAYLOAD, handle: 'One'}, + {...PAYLOAD, handle: 'Two', uri: '/relative'}, + ] + const app = await loadEventsApp(directory, {events: {api_version: '2026-01', subscription: subscriptions}}, flags) + expect(app.errors.getErrors()).toEqual([]) + const manifest = await app.manifest(undefined) + const events = manifest.modules.filter((module) => module.type === eventsSpec.externalIdentifier) + expect(events).toHaveLength(fanout ? 2 : 1) + if (fanout) { + expect(events.map((module) => module.handle)).toEqual(['One', 'Two']) + events.forEach((module) => expect(module.config).not.toHaveProperty('events.subscription.handle')) + expect(events[1]?.config).toHaveProperty('events.subscription.uri', 'https://example.com/relative') + } else { + expect(events[0]?.handle).toBe('events') + expect(events[0]?.config).toEqual({ + events: { + api_version: '2026-01', + subscription: [subscriptions[0], {...subscriptions[1], uri: 'https://example.com/relative'}], + }, + }) + } + }) + }) + + test.each([undefined, '', 'has space', 'x'.repeat(51), 42])( + 'invalid local object handle fails instead of falling back to events (%j)', + async (handle) => { + await inTemporaryDirectory(async (directory) => { + await expect( + loadEventsApp(directory, { + events: {api_version: '2026-01', subscription: {...PAYLOAD, ...(handle === undefined ? {} : {handle})}}, + }), + ).rejects.toThrow('identity must be a handle') + }) + }, + ) + + test('case-variant duplicates survive remote fan-in and local fanout for Core to reject, without case folding', async () => { + await inTemporaryDirectory(async (directory) => { + const config = remoteAppConfigurationExtensionContent( + [remoteModule('Case', PAYLOAD), remoteModule('case', PAYLOAD)], + [eventsSpec], + [], + ) + const app = await loadEventsApp(directory, config) + const manifest = await app.manifest(undefined) + expect( + manifest.modules + .filter((module) => module.type === eventsSpec.externalIdentifier) + .map((module) => module.handle), + ).toEqual(['Case', 'case']) + }) + }) +}) diff --git a/packages/app/src/cli/services/app/events-parser.integration.test.ts b/packages/app/src/cli/services/app/events-parser.integration.test.ts new file mode 100644 index 00000000000..33d6f0787dd --- /dev/null +++ b/packages/app/src/cli/services/app/events-parser.integration.test.ts @@ -0,0 +1,91 @@ +import {strictEventsContract} from './events-strict-schema.test-data.js' +import {fetchSpecifications} from '../generate/fetch-extension-specifications.js' +import {DEFAULT_CONFIG, testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' +import {ExtensionInstance} from '../../models/extensions/extension-instance.js' +import {jsonSchemaValidate} from '@shopify/cli-kit/node/json-schema' +import {expect, test} from 'vitest' + +const subscription = {handle: 'Exact_CASE', topic: 'products', actions: ['update'], uri: '/events'} + +async function fetchedEventsSpecification() { + const specifications = await fetchSpecifications({ + app: testOrganizationApp(), + developerPlatformClient: testDeveloperPlatformClient({ + specifications: async () => [ + { + identifier: 'events', + externalIdentifier: 'events', + externalName: 'Events', + name: 'Events', + experience: 'configuration', + managementExperience: 'cli', + gated: false, + registrationLimit: 1000, + uidStrategy: 'single', + validationSchema: {jsonSchema: JSON.stringify(strictEventsContract)}, + }, + ], + }), + }) + const specification = specifications.find((spec) => spec.identifier === 'events') + if (!specification) throw new Error('Missing Events specification') + return specification +} + +test.each([false, true])( + 'fetched parser preserves editing identity through repeat validation and wire output, list=%s', + async (list) => { + const specification = await fetchedEventsSpecification() + const input = {events: {api_version: '2026-07', subscription: list ? [subscription] : subscription}} + const before = structuredClone(input) + const parsed = specification.parseConfigurationObject(input) + expect(parsed.state).toBe('ok') + if (parsed.state !== 'ok') throw new Error('Expected valid Events config') + expect(specification.parseConfigurationObject(parsed.data)).toEqual(parsed) + expect(input).toEqual(before) + const extension = new ExtensionInstance({ + configuration: parsed.data, + configurationPath: '/unused/shopify.app.toml', + directory: '/unused', + specification, + }) + expect(extension.handle).toBe(list ? 'events' : 'Exact_CASE') + expect(extension.uid).toBe(list ? 'events' : 'Exact_CASE') + const wire = await extension.deployConfig({apiKey: 'test-key', appConfiguration: DEFAULT_CONFIG}) + expect(wire).not.toHaveProperty('handle') + expect(wire).not.toHaveProperty('events.subscription.handle') + expect(wire).toHaveProperty( + list ? 'events.subscription.0.handle' : 'events.subscription.topic', + list ? 'Exact_CASE' : 'products', + ) + expect(jsonSchemaValidate(wire ?? {}, strictEventsContract, 'fail').state).toBe('ok') + }, +) + +test.each([{unexpected: true}, {topic: 'not-a-topic'}, {actions: []}, {uri: 123}])( + 'fetched parser still rejects invalid object fields: %j', + async (invalid) => { + const specification = await fetchedEventsSpecification() + const parsed = specification.parseConfigurationObject({ + events: { + api_version: '2026-07', + subscription: {...subscription, ...invalid}, + }, + }) + expect(parsed.state).toBe('error') + }, +) + +test('normalization does not weaken validation on repeated parsing', async () => { + const specification = await fetchedEventsSpecification() + const parsed = specification.parseConfigurationObject({events: {api_version: '2026-07', subscription}}) + if (parsed.state !== 'ok') throw new Error('Expected valid Events config') + const invalid = specification.parseConfigurationObject({ + ...parsed.data, + events: { + api_version: '2026-07', + subscription: {topic: 'products', actions: ['update'], uri: '/events', unexpected: true}, + }, + }) + expect(invalid.state).toBe('error') +}) diff --git a/packages/app/src/cli/services/app/events-strict-schema.test-data.ts b/packages/app/src/cli/services/app/events-strict-schema.test-data.ts new file mode 100644 index 00000000000..8007b0b3399 --- /dev/null +++ b/packages/app/src/cli/services/app/events-strict-schema.test-data.ts @@ -0,0 +1,56 @@ +// Core module.json.erb at 440131c4a4259f3c7d1716f3821fd5e6e1337c7b: +// areas/core/shopify/components/apps/app/services/apps/events/module.json.erb. +// The ERB's runtime topic/action enums use representative values in this fixture. +const subscriptionProperties = { + api_version: {$ref: '#/definitions/ApiVersion'}, + topic: {type: 'string', enum: ['products', 'orders']}, + actions: { + type: 'array', + uniqueItems: true, + minItems: 1, + items: {type: 'string', enum: ['create', 'update', 'delete']}, + }, + triggers: {type: 'array', uniqueItems: true, items: {type: 'string'}}, + uri: {type: 'string'}, + query: {type: 'string'}, + query_filter: {type: 'string'}, + identifier: {type: 'string'}, +} + +export const strictEventsContract = { + type: 'object', + additionalProperties: false, + properties: {events: {$ref: '#/definitions/EventsOptions'}}, + definitions: { + EventsOptions: { + type: 'object', + additionalProperties: false, + properties: { + api_version: {$ref: '#/definitions/ApiVersion'}, + subscription: { + type: ['array', 'object'], + items: {$ref: '#/definitions/Subscription'}, + additionalProperties: false, + required: ['topic', 'actions', 'uri'], + properties: subscriptionProperties, + }, + }, + required: ['api_version', 'subscription'], + }, + ApiVersion: { + type: 'string', + minLength: 1, + title: 'Admin API', + description: 'The Admin API lets you build apps and integrations that extend and enhance the Shopify admin.', + }, + Subscription: { + type: 'object', + additionalProperties: false, + required: ['topic', 'actions', 'uri', 'handle'], + properties: { + ...subscriptionProperties, + handle: {type: 'string', pattern: '^([a-zA-Z0-9-_])*$', minLength: 1, maxLength: 50}, + }, + }, + }, +} diff --git a/packages/app/src/cli/services/app/select-app.ts b/packages/app/src/cli/services/app/select-app.ts index 915e88933b3..62b5aa55031 100644 --- a/packages/app/src/cli/services/app/select-app.ts +++ b/packages/app/src/cli/services/app/select-app.ts @@ -1,8 +1,8 @@ +import {configurationFromModules} from './configuration-modules.js' import {MinimalOrganizationApp} from '../../models/organization.js' import {Flag, AppModuleVersion, DeveloperPlatformClient, AppVersion} from '../../utilities/developer-platform-client.js' import {ExtensionSpecification, isAppConfigSpecification} from '../../models/extensions/specification.js' import {AppConfigurationUsedByCli} from '../../models/extensions/specifications/types/app_config.js' -import {deepMergeObjects} from '@shopify/cli-kit/common/object' function extensionTypeStrategy(specs: ExtensionSpecification[], type?: string) { if (!type) return @@ -58,18 +58,16 @@ export function remoteAppConfigurationExtensionContent( specifications: ExtensionSpecification[], flags: Flag[], ) { - let remoteAppConfig: {[key: string]: unknown} = {} const configSpecifications = specifications.filter(isAppConfigSpecification) - configRegistrations.forEach((module) => { + const entries = configRegistrations.flatMap((module) => { const configSpec = configSpecifications.find( (spec) => spec.identifier === module.specification?.identifier.toLowerCase(), ) - if (!configSpec) return - const config = module.config - if (!config) return - - remoteAppConfig = deepMergeObjects(remoteAppConfig, configSpec.transformRemoteToLocal?.(config, {flags}) ?? config) + if (!configSpec || !module.config) return [] + return [{specification: configSpec, module: {handle: module.registrationTitle, config: module.config}}] }) - return {...remoteAppConfig} + return configurationFromModules(entries, flags, ({specification, module}) => { + return specification.transformRemoteToLocal?.(module.config, {flags}) ?? module.config + }) } diff --git a/packages/app/src/cli/services/context/deploy-identifier-matching.ts b/packages/app/src/cli/services/context/deploy-identifier-matching.ts index 04236f1173d..0e421946332 100644 --- a/packages/app/src/cli/services/context/deploy-identifier-matching.ts +++ b/packages/app/src/cli/services/context/deploy-identifier-matching.ts @@ -7,6 +7,7 @@ import { import {activeAppVersionAfterMigrations} from './deploy-app-version-migrations.js' import {EnsureDeploymentIdsPresenceOptions} from './identifiers.js' import {remoteAppConfigurationExtensionContent} from '../app/select-app.js' +import {configurationFromModules, ConfigurationModuleEntry} from '../app/configuration-modules.js' import {AppInterface} from '../../models/app/app.js' import {DeployIdentifiers, ExtensionUuidsByLocalIdentifier} from '../../models/app/identifiers.js' import {MinimalOrganizationApp} from '../../models/organization.js' @@ -14,7 +15,6 @@ import {ExtensionInstance} from '../../models/extensions/extension-instance.js' import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' import {AppModuleVersion, AppVersion} from '../../utilities/developer-platform-client.js' import {AbortSilentError} from '@shopify/cli-kit/node/error' -import {deepMergeObjects} from '@shopify/cli-kit/common/object' import {slugify} from '@shopify/cli-kit/common/string' type DeployExtensionChangeStatus = 'created' | 'updated' | 'deleted' | 'unchanged' @@ -182,19 +182,20 @@ function buildDeployIdentifiersFromChanges(changes: DeployExtensionChange[]) { } async function localAppConfigurationExtensionContent(app: AppInterface, apiKey: string) { - let appConfig: {[key: string]: unknown} = {} const configExtensions = app.allExtensions.filter((extension) => extension.isAppConfigExtension) - + const entries: (ConfigurationModuleEntry & {configuration: object})[] = [] for (const extension of configExtensions) { // eslint-disable-next-line no-await-in-loop const deployConfig = await extension.deployConfig({apiKey, appConfiguration: app.configuration}) - const localConfig = - extension.specification.transformRemoteToLocal?.(deployConfig ?? {}, {flags: app.remoteFlags}) ?? - extension.configuration - appConfig = deepMergeObjects(appConfig, localConfig) + const specification = extension.specification + const configuration = specification.aggregateModuleConfigurations + ? extension.configuration + : (specification.transformRemoteToLocal?.(deployConfig ?? {}, {flags: app.remoteFlags}) ?? + extension.configuration) + entries.push({specification, module: {handle: extension.handle, config: deployConfig ?? {}}, configuration}) } - return appConfig + return configurationFromModules(entries, app.remoteFlags, ({configuration}) => configuration) } /** Builds prompt metadata for a remote-only module. */ From e05a9463635651996e88a7a92d170f60d6e6784a Mon Sep 17 00:00:00 2001 From: David Peacock Date: Thu, 24 Sep 2026 22:50:23 -0400 Subject: [PATCH 3/3] Reduce review churn in the Events aggregation prototype Restore the original transform-test structure and apply only necessary contract changes, so reviewers can see what behavior actually differs. Consolidate the duplicated parser and empty-list integration scaffolding while retaining strict fetched-schema, real TOML, manifest and no-op checks. Production code and the strict schema fixture are unchanged. Independent coverage review, full app tests, type-check, lint and targeted mutation checks verify that the important regression guards remain effective. --- .../transform/app_config_events.test.ts | 513 +++++++++++++++++- .../app/events-empty-list.integration.test.ts | 129 ----- .../app/events-module-roundtrip.test.ts | 182 ++++++- .../app/events-parser.integration.test.ts | 91 ---- 4 files changed, 648 insertions(+), 267 deletions(-) delete mode 100644 packages/app/src/cli/services/app/events-empty-list.integration.test.ts delete mode 100644 packages/app/src/cli/services/app/events-parser.integration.test.ts diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts index c1f9e4e254c..65e718ff7e3 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts @@ -24,11 +24,11 @@ describe('transformFromEventsConfig', () => { 'omits object identity, preserves payload, and leaves local config unchanged (%j)', (appConfiguration) => { const content = { + handle: 'Exact_CASE', events: {api_version: '2026-01', subscription: {...PAYLOAD, handle: 'Exact_CASE', uri: '/events'}}, } const before = structuredClone(content) - const result = transformFromEventsConfig(content, appConfiguration) - expect(result).toEqual({ + expect(transformFromEventsConfig(content, appConfiguration)).toEqual({ events: { api_version: '2026-01', subscription: { @@ -41,19 +41,9 @@ describe('transformFromEventsConfig', () => { }, ) - test('retains list handles and resolves only relative URLs', () => { - const subscriptions = [ - {...PAYLOAD, handle: 'Relative', uri: '/events'}, - {...PAYLOAD, handle: 'Absolute'}, - {...PAYLOAD, handle: 'Pubsub', uri: 'pubsub://project:topic'}, - ] - expect( - transformFromEventsConfig({events: {subscription: subscriptions}}, {application_url: 'https://app.com'}), - ).toEqual({ - events: { - subscription: [{...subscriptions[0], uri: 'https://app.com/events'}, subscriptions[1], subscriptions[2]], - }, - }) + test('retains list handles and non-HTTP URIs', () => { + const content = {events: {subscription: [{...PAYLOAD, handle: 'Pubsub', uri: 'pubsub://project:topic'}]}} + expect(transformFromEventsConfig(content, {application_url: 'https://app.com'})).toEqual(content) }) test.each([undefined, null, 42, false])('retains a missing/invalid URI for normal validation (%j)', (uri) => { @@ -64,9 +54,150 @@ describe('transformFromEventsConfig', () => { }) test.each([{}, {events: {}}, {events: {subscription: null}}, {events: {subscription: []}}])( - 'preserves empty content (%j)', - (content) => expect(transformFromEventsConfig(content)).toEqual(content), + 'omits first-class identity on empty-content paths (%j)', + (content) => expect(transformFromEventsConfig({...content, handle: 'local-only'})).toEqual(content), ) + + test('returns content as-is when all URIs are absolute', () => { + const content = { + events: { + api_version: '2024-01', + subscription: [{topic: 'orders/create', uri: 'https://example.com', actions: ['create']}], + }, + } + const appConfiguration = {application_url: 'https://tunnel.example.com'} + + const result = transformFromEventsConfig(content, appConfiguration) + + expect(result).toEqual(content) + }) + + test('prepends application_url to relative URIs in subscriptions', () => { + const content = { + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', uri: '/webhooks/orders', actions: ['create']}, + {topic: 'products/update', uri: 'https://absolute.example.com/webhook', actions: ['update']}, + ], + }, + } + const appConfiguration = {application_url: 'https://tunnel.example.com'} + + const result = transformFromEventsConfig(content, appConfiguration) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', uri: 'https://tunnel.example.com/webhooks/orders', actions: ['create']}, + {topic: 'products/update', uri: 'https://absolute.example.com/webhook', actions: ['update']}, + ], + }, + }) + }) + + test('returns content as-is when no application_url in config', () => { + const content = { + events: { + api_version: '2024-01', + subscription: [{topic: 'orders/create', uri: '/webhooks/orders', actions: ['create']}], + }, + } + + const result = transformFromEventsConfig(content, {}) + + expect(result).toEqual(content) + }) + + test('returns content as-is when no appConfiguration provided', () => { + const content = { + events: { + api_version: '2024-01', + subscription: [{topic: 'orders/create', uri: '/webhooks/orders', actions: ['create']}], + }, + } + + const result = transformFromEventsConfig(content) + + expect(result).toEqual(content) + }) + + test('handles application_url with trailing slash', () => { + const content = { + events: { + api_version: '2024-01', + subscription: [{topic: 'orders/create', uri: '/webhooks/orders', actions: ['create']}], + }, + } + const appConfiguration = {application_url: 'https://tunnel.example.com/'} + + const result = transformFromEventsConfig(content, appConfiguration) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', uri: 'https://tunnel.example.com/webhooks/orders', actions: ['create']}, + ], + }, + }) + }) + + test('returns content as-is when subscription array is empty', () => { + const content = { + events: { + api_version: '2024-01', + subscription: [], + }, + } + const appConfiguration = {application_url: 'https://tunnel.example.com'} + + const result = transformFromEventsConfig(content, appConfiguration) + + expect(result).toEqual(content) + }) + + test('returns content as-is when no subscriptions', () => { + const content = { + events: { + api_version: '2024-01', + }, + } + const appConfiguration = {application_url: 'https://tunnel.example.com'} + + const result = transformFromEventsConfig(content, appConfiguration) + + expect(result).toEqual(content) + }) + + test('prepends application_url to a relative URI in a single subscription object', () => { + const content = { + events: { + api_version: '2024-01', + subscription: {topic: 'orders/create', uri: '/webhooks/orders', actions: ['create']}, + }, + } + const appConfiguration = {application_url: 'https://tunnel.example.com'} + + const result = transformFromEventsConfig(content, appConfiguration) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: {topic: 'orders/create', uri: 'https://tunnel.example.com/webhooks/orders', actions: ['create']}, + }, + }) + }) + + test('returns content as-is when events is undefined', () => { + const content = {} + const appConfiguration = {application_url: 'https://tunnel.example.com'} + + const result = transformFromEventsConfig(content, appConfiguration) + + expect(result).toEqual(content) + }) }) describe('aggregateEventsConfigurations', () => { @@ -93,10 +224,11 @@ describe('aggregateEventsConfigurations', () => { test('retains independent legacy identities and duplicates, including case variants, for validation', () => { const subscription = {...PAYLOAD, handle: 'First', identifier: 'id'} - const modules = [ - moduleWith([subscription, subscription, {...subscription, handle: 'first'}], '2026-01', 'not-used'), - ] - expect(aggregateEventsConfigurations(modules)).toEqual({ + expect( + aggregateEventsConfigurations([ + moduleWith([subscription, subscription, {...subscription, handle: 'first'}], '2026-01', 'not-used'), + ]), + ).toEqual({ events: { api_version: '2026-01', subscription: [ @@ -187,4 +319,343 @@ describe('aggregateEventsConfigurations', () => { 'identity must be a handle', ) }) + + test('strips server-managed identifier field from subscriptions while preserving all other fields', () => { + const remoteContent = { + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/webhook', + actions: ['create'], + handle: 'orders', + identifier: 'id-1', + }, + { + topic: 'products/update', + uri: 'https://example.com/webhook', + actions: ['update'], + handle: 'my-subscription', + triggers: ['product_updated'], + query: 'query { id }', + query_filter: 'status:active', + identifier: 'id-2', + }, + ], + }, + } + + const result = aggregateEventsConfigurations([{handle: 'ignored-outer', config: remoteContent}]) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/webhook', + actions: ['create'], + handle: 'orders', + }, + { + topic: 'products/update', + uri: 'https://example.com/webhook', + actions: ['update'], + handle: 'my-subscription', + triggers: ['product_updated'], + query: 'query { id }', + query_filter: 'status:active', + }, + ], + }, + }) + }) + + test('handles missing subscription field', () => { + const remoteContent = { + events: { + api_version: '2024-01', + }, + } + + const result = aggregateEventsConfigurations([{handle: 'events', config: remoteContent}]) + + expect(result).toEqual({events: {api_version: '2024-01'}}) + expect(result.events).not.toHaveProperty('subscription') + }) + test('strips the identifier from a single subscription object and returns it as a one-element array', () => { + const remoteContent = { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/webhook', + actions: ['create'], + handle: 'order-notifier', + identifier: 'id-1', + }, + }, + } + + const result = aggregateEventsConfigurations([{handle: 'order-notifier', config: remoteContent}]) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/webhook', + actions: ['create'], + handle: 'order-notifier', + }, + ], + }, + }) + }) + + test('merging multiple single-subscription modules accumulates one subscription array', () => { + const moduleOne = { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + identifier: 'id-a', + }, + }, + } + const moduleTwo = { + events: { + api_version: '2024-01', + subscription: { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + handle: 'b', + identifier: 'id-b', + }, + }, + } + + const merged = aggregateEventsConfigurations([ + {handle: 'a', config: moduleOne}, + {handle: 'b', config: moduleTwo}, + ]) + + expect(merged).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a'}, + {topic: 'products/update', uri: 'https://example.com/b', actions: ['update'], handle: 'b'}, + ], + }, + }) + }) + + test('strips a subscription api_version that matches the events default in the list shape', () => { + const remoteContent = { + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + api_version: '2024-01', + identifier: 'id-a', + }, + { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + handle: 'b', + api_version: '2024-01', + identifier: 'id-b', + }, + ], + }, + } + + const result = aggregateEventsConfigurations([{handle: 'ignored-outer', config: remoteContent}]) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a'}, + {topic: 'products/update', uri: 'https://example.com/b', actions: ['update'], handle: 'b'}, + ], + }, + }) + }) + + test('strips a subscription api_version that matches the events default in the single shape', () => { + const remoteContent = { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + api_version: '2024-01', + identifier: 'id-a', + }, + }, + } + + const result = aggregateEventsConfigurations([{handle: 'a', config: remoteContent}]) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [{topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a'}], + }, + }) + }) + + test('keeps a subscription api_version that overrides the events default', () => { + const remoteContent = { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + api_version: '2025-07', + identifier: 'id-a', + }, + }, + } + + const result = aggregateEventsConfigurations([{handle: 'a', config: remoteContent}]) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + api_version: '2025-07', + }, + ], + }, + }) + }) + + test('rejects a missing module default even when a subscription overrides it', () => { + const remoteContent = { + events: { + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + api_version: '2024-01', + identifier: 'id-a', + }, + ], + }, + } + + expect(() => aggregateEventsConfigurations([{handle: 'events', config: remoteContent}])).toThrow( + 'without events.api_version', + ) + }) + + test('merging single-subscription modules keeps only the overriding api_version', () => { + const moduleOne = { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + api_version: '2024-01', + identifier: 'id-a', + }, + }, + } + const moduleTwo = { + events: { + api_version: '2024-01', + subscription: { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + handle: 'b', + api_version: '2025-07', + identifier: 'id-b', + }, + }, + } + + const merged = aggregateEventsConfigurations([ + {handle: 'a', config: moduleOne}, + {handle: 'b', config: moduleTwo}, + ]) + + expect(merged).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a'}, + { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + handle: 'b', + api_version: '2025-07', + }, + ], + }, + }) + }) + + test('merging a list-shape module with a single-subscription module accumulates all subscriptions', () => { + const listModule = { + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a', identifier: 'id-a'}, + ], + }, + } + const singleModule = { + events: { + api_version: '2024-01', + subscription: { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + handle: 'b', + identifier: 'id-b', + }, + }, + } + + const merged = aggregateEventsConfigurations([ + {handle: 'ignored-outer', config: listModule}, + {handle: 'b', config: singleModule}, + ]) + + expect(merged).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a'}, + {topic: 'products/update', uri: 'https://example.com/b', actions: ['update'], handle: 'b'}, + ], + }, + }) + }) }) diff --git a/packages/app/src/cli/services/app/events-empty-list.integration.test.ts b/packages/app/src/cli/services/app/events-empty-list.integration.test.ts deleted file mode 100644 index 766eb7aab31..00000000000 --- a/packages/app/src/cli/services/app/events-empty-list.integration.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import {overwriteLocalConfigFileWithRemoteAppConfiguration} from './config/link.js' -import {writeAppConfigurationFile} from './write-app-configuration-file.js' -import {DEFAULT_CONFIG, testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' -import {loadApp} from '../../models/app/loader.js' -import {loadLocalExtensionsSpecifications} from '../../models/extensions/load-specifications.js' -import {RemoteAwareExtensionSpecification} from '../../models/extensions/specification.js' -import {AppModuleVersion, Flag} from '../../utilities/developer-platform-client.js' -import {ensureDeployIdentifiersFromAppVersion} from '../context/deploy-identifier-matching.js' -import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' -import {writeManifestToBundle} from '../bundle.js' -import {inTemporaryDirectory, readFile, writeFile} from '@shopify/cli-kit/node/fs' -import {joinPath} from '@shopify/cli-kit/node/path' -import {getPathValue} from '@shopify/cli-kit/common/object' -import {afterEach, expect, test, vi} from 'vitest' - -vi.mock('../local-storage.js') -vi.mock('../../prompts/deploy-release.js') -afterEach(() => vi.unstubAllEnvs()) - -test.each([{flags: []}, {flags: [Flag.SingleSubscriptionEventsModules]}])( - 'pulling an explicit empty remote list clears existing subscriptions with flags %j', - async ({flags}) => { - vi.stubEnv('SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT', '') - vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) - await inTemporaryDirectory(async (directory) => { - const specifications: RemoteAwareExtensionSpecification[] = (await loadLocalExtensionsSpecifications()).map( - (spec) => ({ - ...spec, - externalIdentifier: spec.identifier, - loadedRemoteSpecs: true, - }), - ) - const configuration = { - ...DEFAULT_CONFIG, - auth: {redirect_urls: ['https://myapp.com/callback']}, - events: { - api_version: '2026-07', - subscription: [{handle: 'Removed', topic: 'products', actions: ['update'], uri: 'https://myapp.com/events'}], - }, - } - const configPath = joinPath(directory, 'shopify.app.toml') - await writeFile(joinPath(directory, 'package.json'), '{}') - await writeAppConfigurationFile(configuration, configPath) - const app = await loadApp({directory, userProvidedConfigName: undefined, specifications, remoteFlags: flags}) - expect(app.errors.isEmpty()).toBe(true) - const modules: AppModuleVersion[] = await Promise.all( - app.allExtensions - .filter((extension) => extension.type !== 'events') - .map(async (extension) => ({ - registrationId: extension.handle, - registrationTitle: extension.handle, - registrationUuid: `uuid-${extension.handle}`, - type: extension.type, - config: await extension.deployConfig({apiKey: 'api-key', appConfiguration: app.configuration}), - specification: { - identifier: extension.specification.identifier, - name: extension.type, - experience: 'configuration', - options: {managementExperience: 'cli'}, - }, - })), - ) - modules.push({ - registrationId: 'events', - registrationTitle: 'events', - registrationUuid: 'uuid-events', - type: 'events', - config: {events: {api_version: '2026-07', subscription: []}}, - specification: { - identifier: 'events', - name: 'Events', - experience: 'configuration', - options: {managementExperience: 'cli'}, - }, - }) - const activeAppVersion = {appModuleVersions: modules} - const developerPlatformClient = testDeveloperPlatformClient({activeAppVersion: async () => activeAppVersion}) - const remoteApp = testOrganizationApp({apiKey: 'api-key'}) - await overwriteLocalConfigFileWithRemoteAppConfiguration({ - remoteApp, - developerPlatformClient, - specifications, - flags, - configFileName: 'shopify.app.toml', - appDirectory: directory, - localAppOptions: { - state: 'reusable-current-app', - scopes: '', - localAppIdMatchedRemote: true, - existingBuildOptions: undefined, - existingConfig: app.configuration, - appDirectory: directory, - packageManager: 'npm', - }, - }) - const loaded = await loadApp({directory, userProvidedConfigName: undefined, specifications, remoteFlags: flags}) - expect(loaded.errors.isEmpty()).toBe(true) - expect(getPathValue(loaded.configuration, 'events.subscription')).toEqual([]) - await expect(readFile(configPath)).resolves.not.toContain('Removed') - const manifest = await loaded.manifest({}) - expect(manifest.modules.filter((module) => module.type === 'events')).toMatchObject([ - {handle: 'events', uid: 'events', config: {events: {api_version: '2026-07', subscription: []}}}, - ]) - await writeManifestToBundle(manifest, directory) - expect(JSON.parse(await readFile(joinPath(directory, 'manifest.json')))).toEqual( - JSON.parse(JSON.stringify(manifest)), - ) - await ensureDeployIdentifiersFromAppVersion({ - app: loaded, - appId: 'api-key', - appName: loaded.name, - release: false, - developerPlatformClient, - remoteApp, - envIdentifiers: {}, - activeAppVersion, - }) - expect(deployOrReleaseConfirmationPrompt).toHaveBeenLastCalledWith( - expect.objectContaining({ - configExtensionIdentifiersBreakdown: expect.objectContaining({ - existingUpdatedFieldNames: [], - newFieldNames: [], - deletedFieldNames: [], - }), - }), - ) - }) - }, -) diff --git a/packages/app/src/cli/services/app/events-module-roundtrip.test.ts b/packages/app/src/cli/services/app/events-module-roundtrip.test.ts index a53390caa85..b2cfdce34b9 100644 --- a/packages/app/src/cli/services/app/events-module-roundtrip.test.ts +++ b/packages/app/src/cli/services/app/events-module-roundtrip.test.ts @@ -1,9 +1,11 @@ import {remoteAppConfigurationExtensionContent} from './select-app.js' import {writeAppConfigurationFile} from './write-app-configuration-file.js' +import {overwriteLocalConfigFileWithRemoteAppConfiguration} from './config/link.js' import {strictEventsContract} from './events-strict-schema.test-data.js' import {fetchSpecifications} from '../generate/fetch-extension-specifications.js' import {RemoteSpecification} from '../../api/graphql/extension_specifications.js' import {loadApp} from '../../models/app/loader.js' +import {AppInterface} from '../../models/app/app.js' import {loadLocalExtensionsSpecifications} from '../../models/extensions/load-specifications.js' import eventsSpec from '../../models/extensions/specifications/app_config_events.js' import {testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' @@ -16,6 +18,7 @@ import {appManagementRequestDoc} from '@shopify/cli-kit/node/api/app-management' import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' import {inTemporaryDirectory, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' +import {jsonSchemaValidate} from '@shopify/cli-kit/node/json-schema' import {afterEach, describe, expect, test, vi} from 'vitest' vi.mock('@shopify/cli-kit/node/api/app-management') @@ -58,9 +61,7 @@ function remoteModule(handle: string, subscription: object, apiVersion = '2026-0 } } -async function loadEventsApp(directory: string, eventsConfig: object, remoteFlags: Flag[] = ENABLED) { - await writeAppConfigurationFile({...BASE, ...eventsConfig}, joinPath(directory, 'shopify.app.toml')) - await writeFile(joinPath(directory, 'package.json'), JSON.stringify({name: 'events-test', private: true})) +async function fetchedSpecifications() { const remoteSpecs: RemoteSpecification[] = (await loadLocalExtensionsSpecifications()).map((spec) => ({ identifier: spec.identifier, externalIdentifier: spec.externalIdentifier, @@ -73,19 +74,45 @@ async function loadEventsApp(directory: string, eventsConfig: object, remoteFlag uidStrategy: spec.uidStrategy, validationSchema: spec.identifier === 'events' ? {jsonSchema: JSON.stringify(strictEventsContract)} : undefined, })) - const specifications = await fetchSpecifications({ + return fetchSpecifications({ developerPlatformClient: testDeveloperPlatformClient({specifications: async () => remoteSpecs}), app: testOrganizationApp(), }) +} + +async function loadEventsApp(directory: string, eventsConfig: object, remoteFlags: Flag[] = ENABLED) { + await writeAppConfigurationFile({...BASE, ...eventsConfig}, joinPath(directory, 'shopify.app.toml')) + await writeFile(joinPath(directory, 'package.json'), JSON.stringify({name: 'events-test', private: true})) return loadApp({ directory, userProvidedConfigName: undefined, - specifications, + specifications: await fetchedSpecifications(), remoteFlags, skipPrompts: true, }) } +async function remoteAppModules(app: AppInterface): Promise { + const manifest = await app.manifest(undefined) + return manifest.modules.map((module) => { + const specification = app.specifications.find((spec) => spec.externalIdentifier === module.type) + if (!specification) throw new Error(`Missing specification ${module.type}`) + return { + registrationTitle: module.handle, + registrationId: module.uid, + registrationUuid: `uuid-${module.uid}`, + type: specification.identifier, + config: module.config, + specification: { + identifier: specification.identifier, + name: specification.externalName, + experience: specification.experience, + options: {managementExperience: 'cli'}, + }, + } + }) +} + afterEach(() => vi.unstubAllEnvs()) describe('Events module round trip', () => { @@ -149,17 +176,22 @@ describe('Events module round trip', () => { const manifest = await app.manifest(undefined) const events = manifest.modules.filter((module) => module.type === eventsSpec.externalIdentifier) expect(events).toHaveLength(fanout ? 3 : 1) + events.forEach((module) => + expect(jsonSchemaValidate(module.config, strictEventsContract, 'fail').state).toBe('ok'), + ) const readback = events.map( (module): AppModuleVersion => ({...remoteModule(module.handle, {}), config: module.config}), ) expect(remoteAppConfigurationExtensionContent(readback, [eventsSpec], [])).toEqual(config) if (fanout) expect(events.map((module) => module.handle)).toEqual(['Legacy_A', 'Legacy_B', 'Object']) - else + else { + expect(events[0]).toMatchObject({handle: 'events', uid: 'events'}) expect(events[0]?.config).toHaveProperty('events.subscription', [ {...PAYLOAD, handle: 'Legacy_A', api_version: '2026-04'}, {...PAYLOAD, handle: 'Legacy_B', api_version: '2026-07'}, {...PAYLOAD, handle: 'Object', uri: 'https://example.com/object'}, ]) + } }) }, ) @@ -229,7 +261,9 @@ describe('Events module round trip', () => { uuid: `uuid-${events[index]?.handle}`, config: configs[index], }) + expect(module.config).not.toHaveProperty('handle') expect(module.config).not.toHaveProperty('events.subscription.handle') + expect(jsonSchemaValidate(module.config, strictEventsContract, 'fail').state).toBe('ok') }) const bundleDirectory = joinPath(directory, 'bundle') await mkdir(bundleDirectory) @@ -272,26 +306,9 @@ describe('Events module round trip', () => { remoteAppConfigurationExtensionContent(remoteEvents, [eventsSpec], []), ) expect(app.errors.getErrors()).toEqual([]) - const manifest = await app.manifest(undefined) - const remote = manifest.modules.map((module): AppModuleVersion => { - const event = remoteEvents.find((event) => event.registrationTitle === module.handle) - if (event) return event - const specification = app.specifications.find((spec) => spec.externalIdentifier === module.type) - if (!specification) throw new Error(`Missing specification ${module.type}`) - return { - registrationTitle: module.handle, - registrationId: module.uid, - registrationUuid: `uuid-${module.uid}`, - type: specification.identifier, - config: module.config, - specification: { - identifier: specification.identifier, - name: specification.externalName, - experience: specification.experience, - options: {managementExperience: 'cli'}, - }, - } - }) + const remote = (await remoteAppModules(app)).map( + (module) => remoteEvents.find((event) => event.registrationTitle === module.registrationTitle) ?? module, + ) const identifiers = await ensureDeployIdentifiersFromAppVersion({ app, appId: BASE.client_id, @@ -365,6 +382,119 @@ describe('Events module round trip', () => { }, ) + test.each([false, true])('pulling an explicit empty list clears existing TOML (fanout=%j)', async (fanout) => { + const flags = fanout ? ENABLED : [] + vi.stubEnv('SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT', '') + vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) + await inTemporaryDirectory(async (directory) => { + const app = await loadEventsApp( + directory, + {events: {api_version: '2026-01', subscription: [{...PAYLOAD, handle: 'Removed'}]}}, + flags, + ) + expect(app.errors.isEmpty()).toBe(true) + const modules = (await remoteAppModules(app)).filter((module) => module.type !== 'events') + modules.push(remoteModule('events', [])) + const activeAppVersion = {appModuleVersions: modules} + const developerPlatformClient = testDeveloperPlatformClient({activeAppVersion: async () => activeAppVersion}) + const remoteApp = testOrganizationApp({apiKey: BASE.client_id}) + await overwriteLocalConfigFileWithRemoteAppConfiguration({ + remoteApp, + developerPlatformClient, + specifications: app.specifications, + flags, + configFileName: 'shopify.app.toml', + appDirectory: directory, + localAppOptions: { + state: 'reusable-current-app', + scopes: '', + localAppIdMatchedRemote: true, + existingBuildOptions: undefined, + existingConfig: app.configuration, + appDirectory: directory, + packageManager: 'npm', + }, + }) + const loaded = await loadApp({ + directory, + userProvidedConfigName: undefined, + specifications: app.specifications, + remoteFlags: flags, + }) + expect(loaded.errors.isEmpty()).toBe(true) + expect(loaded.configuration).toHaveProperty('events.subscription', []) + await expect(readFile(joinPath(directory, 'shopify.app.toml'))).resolves.not.toContain('Removed') + const manifest = await loaded.manifest({}) + const events = manifest.modules.filter((module) => module.type === eventsSpec.externalIdentifier) + expect(events).toMatchObject([ + {handle: 'events', uid: 'events', config: {events: {api_version: '2026-01', subscription: []}}}, + ]) + events.forEach((module) => + expect(jsonSchemaValidate(module.config, strictEventsContract, 'fail').state).toBe('ok'), + ) + await writeManifestToBundle(manifest, directory) + expect(JSON.parse(await readFile(joinPath(directory, 'manifest.json')))).toEqual( + JSON.parse(JSON.stringify(manifest)), + ) + await ensureDeployIdentifiersFromAppVersion({ + app: loaded, + appId: BASE.client_id, + appName: loaded.name, + release: false, + developerPlatformClient, + remoteApp, + envIdentifiers: {}, + activeAppVersion, + }) + expect(deployOrReleaseConfirmationPrompt).toHaveBeenLastCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: expect.objectContaining({ + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + }), + }), + ) + }) + }) + + test.each([false, true])('fetched parser preserves editing input and repeated validation (list=%j)', async (list) => { + const specification = (await fetchedSpecifications()).find((spec) => spec.identifier === 'events') + if (!specification) throw new Error('Missing Events specification') + const subscription = {...PAYLOAD, handle: 'Exact_CASE', uri: '/events'} + const input = {events: {api_version: '2026-01', subscription: list ? [subscription] : subscription}} + const before = structuredClone(input) + const parsed = specification.parseConfigurationObject(input) + expect(parsed.state).toBe('ok') + if (parsed.state !== 'ok') throw new Error('Expected valid Events config') + expect(specification.parseConfigurationObject(parsed.data)).toEqual(parsed) + expect(input).toEqual(before) + if (!list) { + expect(parsed.data).toHaveProperty('handle', 'Exact_CASE') + expect(parsed.data).not.toHaveProperty('events.subscription.handle') + } + const invalid = {...PAYLOAD, unexpected: true, ...(list ? {handle: 'Exact_CASE'} : {})} + expect( + specification.parseConfigurationObject({ + ...parsed.data, + events: {api_version: '2026-01', subscription: list ? [invalid] : invalid}, + }).state, + ).toBe('error') + }) + + test.each([{unexpected: true}, {topic: 'not-a-topic'}, {actions: []}, {uri: 123}])( + 'fetched parser still rejects invalid subscription fields: %j', + async (invalid) => { + const specification = (await fetchedSpecifications()).find((spec) => spec.identifier === 'events') + if (!specification) throw new Error('Missing Events specification') + expect( + specification.parseConfigurationObject({ + events: {api_version: '2026-01', subscription: {...PAYLOAD, handle: 'Exact_CASE', ...invalid}}, + }).state, + ).toBe('error') + }, + ) + test('case-variant duplicates survive remote fan-in and local fanout for Core to reject, without case folding', async () => { await inTemporaryDirectory(async (directory) => { const config = remoteAppConfigurationExtensionContent( diff --git a/packages/app/src/cli/services/app/events-parser.integration.test.ts b/packages/app/src/cli/services/app/events-parser.integration.test.ts deleted file mode 100644 index 33d6f0787dd..00000000000 --- a/packages/app/src/cli/services/app/events-parser.integration.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import {strictEventsContract} from './events-strict-schema.test-data.js' -import {fetchSpecifications} from '../generate/fetch-extension-specifications.js' -import {DEFAULT_CONFIG, testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' -import {ExtensionInstance} from '../../models/extensions/extension-instance.js' -import {jsonSchemaValidate} from '@shopify/cli-kit/node/json-schema' -import {expect, test} from 'vitest' - -const subscription = {handle: 'Exact_CASE', topic: 'products', actions: ['update'], uri: '/events'} - -async function fetchedEventsSpecification() { - const specifications = await fetchSpecifications({ - app: testOrganizationApp(), - developerPlatformClient: testDeveloperPlatformClient({ - specifications: async () => [ - { - identifier: 'events', - externalIdentifier: 'events', - externalName: 'Events', - name: 'Events', - experience: 'configuration', - managementExperience: 'cli', - gated: false, - registrationLimit: 1000, - uidStrategy: 'single', - validationSchema: {jsonSchema: JSON.stringify(strictEventsContract)}, - }, - ], - }), - }) - const specification = specifications.find((spec) => spec.identifier === 'events') - if (!specification) throw new Error('Missing Events specification') - return specification -} - -test.each([false, true])( - 'fetched parser preserves editing identity through repeat validation and wire output, list=%s', - async (list) => { - const specification = await fetchedEventsSpecification() - const input = {events: {api_version: '2026-07', subscription: list ? [subscription] : subscription}} - const before = structuredClone(input) - const parsed = specification.parseConfigurationObject(input) - expect(parsed.state).toBe('ok') - if (parsed.state !== 'ok') throw new Error('Expected valid Events config') - expect(specification.parseConfigurationObject(parsed.data)).toEqual(parsed) - expect(input).toEqual(before) - const extension = new ExtensionInstance({ - configuration: parsed.data, - configurationPath: '/unused/shopify.app.toml', - directory: '/unused', - specification, - }) - expect(extension.handle).toBe(list ? 'events' : 'Exact_CASE') - expect(extension.uid).toBe(list ? 'events' : 'Exact_CASE') - const wire = await extension.deployConfig({apiKey: 'test-key', appConfiguration: DEFAULT_CONFIG}) - expect(wire).not.toHaveProperty('handle') - expect(wire).not.toHaveProperty('events.subscription.handle') - expect(wire).toHaveProperty( - list ? 'events.subscription.0.handle' : 'events.subscription.topic', - list ? 'Exact_CASE' : 'products', - ) - expect(jsonSchemaValidate(wire ?? {}, strictEventsContract, 'fail').state).toBe('ok') - }, -) - -test.each([{unexpected: true}, {topic: 'not-a-topic'}, {actions: []}, {uri: 123}])( - 'fetched parser still rejects invalid object fields: %j', - async (invalid) => { - const specification = await fetchedEventsSpecification() - const parsed = specification.parseConfigurationObject({ - events: { - api_version: '2026-07', - subscription: {...subscription, ...invalid}, - }, - }) - expect(parsed.state).toBe('error') - }, -) - -test('normalization does not weaken validation on repeated parsing', async () => { - const specification = await fetchedEventsSpecification() - const parsed = specification.parseConfigurationObject({events: {api_version: '2026-07', subscription}}) - if (parsed.state !== 'ok') throw new Error('Expected valid Events config') - const invalid = specification.parseConfigurationObject({ - ...parsed.data, - events: { - api_version: '2026-07', - subscription: {topic: 'products', actions: ['update'], uri: '/events', unexpected: true}, - }, - }) - expect(invalid.state).toBe('error') -})