diff --git a/packages/app/src/cli/models/app/identifiers.test.ts b/packages/app/src/cli/models/app/identifiers.test.ts index e0615febd4c..106c325f834 100644 --- a/packages/app/src/cli/models/app/identifiers.test.ts +++ b/packages/app/src/cli/models/app/identifiers.test.ts @@ -203,8 +203,7 @@ describe('getAppIdentifiers', () => { }) // Then - expect(got.app).toEqual('FOO') - expect((got.extensions ?? {})['test-ui-extension']).toEqual('BAR') + expect(got['test-ui-extension']).toEqual('BAR') }) }) @@ -229,8 +228,7 @@ describe('getAppIdentifiers', () => { ) // Then - expect(got.app).toEqual('FOO') - expect((got.extensions ?? {})['test-ui-extension']).toEqual('BAR') + expect(got['test-ui-extension']).toEqual('BAR') }) }) }) diff --git a/packages/app/src/cli/models/app/identifiers.ts b/packages/app/src/cli/models/app/identifiers.ts index d0caebcf0f8..44c8334385e 100644 --- a/packages/app/src/cli/models/app/identifiers.ts +++ b/packages/app/src/cli/models/app/identifiers.ts @@ -34,6 +34,11 @@ export interface ExtensionUuidsByLocalIdentifier { [localIdentifier: string]: string } +export interface DeployIdentifiers { + appModuleUuids: ExtensionUuidsByLocalIdentifier + appModuleRegistrationIds: ExtensionUuidsByLocalIdentifier +} + type UuidOnlyIdentifiers = Omit type UpdateAppIdentifiersCommand = 'dev' | 'deploy' | 'release' | 'import-extensions' interface UpdateAppIdentifiersOptions { @@ -95,7 +100,7 @@ interface GetAppIdentifiersOptions { export function getAppIdentifiers( {app}: GetAppIdentifiersOptions, systemEnvironment = process.env, -): Partial { +): ExtensionUuidsByLocalIdentifier { const envVariables = { ...app.dotenv?.variables, ...(systemEnvironment as {[variable: string]: string}), @@ -108,8 +113,5 @@ export function getAppIdentifiers( } app.allExtensions.forEach(processExtension) - return { - app: envVariables[app.idEnvironmentVariableName], - extensions: extensionsIdentifiers, - } + return extensionsIdentifiers } diff --git a/packages/app/src/cli/services/context.test.ts b/packages/app/src/cli/services/context.test.ts index dd8bf8e6488..c75371302b0 100644 --- a/packages/app/src/cli/services/context.test.ts +++ b/packages/app/src/cli/services/context.test.ts @@ -134,7 +134,7 @@ beforeAll(async () => { }) beforeEach(async () => { - vi.mocked(getAppIdentifiers).mockReturnValue({app: undefined}) + vi.mocked(getAppIdentifiers).mockReturnValue({}) vi.mocked(selectOrganizationPrompt).mockResolvedValue(ORG1) vi.mocked(selectOrCreateApp).mockResolvedValue(APP1) vi.mocked(selectStore).mockResolvedValue(STORE1) diff --git a/packages/app/src/cli/services/context/breakdown-extensions.ts b/packages/app/src/cli/services/context/breakdown-extensions.ts index 090cb94a3f7..fb97cd52a18 100644 --- a/packages/app/src/cli/services/context/breakdown-extensions.ts +++ b/packages/app/src/cli/services/context/breakdown-extensions.ts @@ -443,7 +443,7 @@ export function configExtensionsIdentifiersReleaseBreakdown({ return buildConfigExtensionIdentifiersBreakdown(versionConfig, activeConfig) } -function buildConfigExtensionIdentifiersBreakdown( +export function buildConfigExtensionIdentifiersBreakdown( localConfig: {[key: string]: unknown}, remoteConfig: {[key: string]: unknown}, ): ConfigExtensionIdentifiersBreakdown | undefined { diff --git a/packages/app/src/cli/services/context/deploy-app-version-migrations.ts b/packages/app/src/cli/services/context/deploy-app-version-migrations.ts new file mode 100644 index 00000000000..9e862120cd4 --- /dev/null +++ b/packages/app/src/cli/services/context/deploy-app-version-migrations.ts @@ -0,0 +1,100 @@ +import {EnsureDeploymentIdsPresenceOptions, RemoteSource} from './identifiers.js' +import {extensionMigrationPrompt} from './prompts.js' +import {migrateFlowExtensions} from '../dev/migrate-flow-extension.js' +import {migrateExtensionsToUIExtension} from '../dev/migrate-to-ui-extension.js' +import { + AdminLinkModulesMap, + FlowModulesMap, + getModulesToMigrate, + MarketingModulesMap, + migrateAppModules, + UIModulesMap, +} from '../dev/migrate-app-module.js' +import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' +import {PartnersClient} from '../../utilities/developer-platform-client/partners-client.js' +import {AbortSilentError} from '@shopify/cli-kit/node/error' + +/** Returns a fresh active app version when migrations changed remote modules. */ +export async function activeAppVersionAfterMigrations(options: EnsureDeploymentIdsPresenceOptions) { + const didMigrateExtensions = await migrateExtensionsIfNeeded(options) + return didMigrateExtensions + ? options.developerPlatformClient.activeAppVersion(options.remoteApp) + : options.activeAppVersion +} + +/** Runs supported legacy extension migrations before deploy classification. */ +async function migrateExtensionsIfNeeded(options: EnsureDeploymentIdsPresenceOptions) { + const {app, appId, developerPlatformClient, envIdentifiers, remoteApp} = options + const registrations = await developerPlatformClient.appExtensionRegistrations(remoteApp, options.activeAppVersion) + const localExtensions = app.allExtensions + const identifiers = envIdentifiers + const remoteExtensions = registrations.app.extensionRegistrations + const dashboardExtensions = registrations.app.dashboardManagedExtensionRegistrations + const migrationClient = PartnersClient.getInstance() + + let didMigrate = false + let migratedRemoteExtensions = remoteExtensions + + const uiExtensionsToMigrate = getModulesToMigrate( + localExtensions, + migratedRemoteExtensions, + identifiers, + UIModulesMap, + ) + if (uiExtensionsToMigrate.length > 0) { + const confirmedMigration = await extensionMigrationPrompt(uiExtensionsToMigrate) + if (!confirmedMigration) throw new AbortSilentError() + migratedRemoteExtensions = await migrateExtensionsToUIExtension({ + extensionsToMigrate: uiExtensionsToMigrate, + appId, + remoteExtensions: migratedRemoteExtensions, + migrationClient, + }) + didMigrate = true + } + + const dashboardMigrations = [ + {typesMap: FlowModulesMap, migrate: migrateFlowExtensions}, + migrationGroup(MarketingModulesMap, 'marketing_activity'), + migrationGroup(AdminLinkModulesMap, 'admin_link'), + ] + + for (const migration of dashboardMigrations) { + const extensionsToMigrate = getModulesToMigrate( + localExtensions, + dashboardExtensions, + identifiers, + migration.typesMap, + ) + if (extensionsToMigrate.length === 0) continue + + // eslint-disable-next-line no-await-in-loop + const confirmedMigration = await extensionMigrationPrompt(extensionsToMigrate, false) + if (!confirmedMigration) throw new AbortSilentError() + + // eslint-disable-next-line no-await-in-loop + const newRemoteExtensions = await migration.migrate({ + extensionsToMigrate, + appId, + remoteExtensions: dashboardExtensions, + migrationClient, + }) + migratedRemoteExtensions = migratedRemoteExtensions.concat(newRemoteExtensions) + didMigrate = true + } + + return didMigrate +} + +/** Adapts app-module migrations to the shared dashboard migration loop. */ +function migrationGroup(typesMap: {[key: string]: string[]}, type: string) { + return { + typesMap, + migrate: (options: { + extensionsToMigrate: Parameters[0]['extensionsToMigrate'] + appId: string + remoteExtensions: RemoteSource[] + migrationClient: DeveloperPlatformClient + }) => migrateAppModules({...options, type}), + } +} diff --git a/packages/app/src/cli/services/context/deploy-app-version.test.ts b/packages/app/src/cli/services/context/deploy-app-version.test.ts new file mode 100644 index 00000000000..f7b60e72920 --- /dev/null +++ b/packages/app/src/cli/services/context/deploy-app-version.test.ts @@ -0,0 +1,722 @@ +/* eslint-disable @shopify/prefer-module-scope-constants */ +import {classifyDeployExtensionChanges, ensureDeployIdentifiersFromAppVersion} from './deploy-app-version.js' +import {extensionMigrationPrompt} from './prompts.js' +import {EnsureDeploymentIdsPresenceOptions} from './identifiers.js' +import {AppInterface} from '../../models/app/app.js' +import { + testApp, + testAppConfigExtensions, + testDeveloperPlatformClient, + testOrganizationApp, + testSingleWebhookSubscriptionExtension, + testUIExtension, +} from '../../models/app/app.test-data.js' +import {OrganizationApp} from '../../models/organization.js' +import {ExtensionInstance} from '../../models/extensions/extension-instance.js' +import {BaseConfigType} from '../../models/extensions/schemas.js' +import {createConfigExtensionSpecification} from '../../models/extensions/specification.js' +import {AppModuleVersion, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' +import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' +import {migrateExtensionsToUIExtension} from '../dev/migrate-to-ui-extension.js' +import {beforeAll, beforeEach, describe, expect, test, vi} from 'vitest' +import {AbortSilentError} from '@shopify/cli-kit/node/error' +import {zod} from '@shopify/cli-kit/node/schema' + +vi.mock('../../prompts/deploy-release.js') +vi.mock('./prompts.js') +vi.mock('../dev/migrate-to-ui-extension.js') + +const REMOTE_APP: OrganizationApp = testOrganizationApp({ + id: 'app-id', + apiKey: 'api-key', + organizationId: 'org-id', +}) + +const EMPTY_EXTENSION_BREAKDOWN = {onlyRemote: [], toCreate: [], toUpdate: [], unchanged: []} + +let EXTENSION_A: ExtensionInstance +let EXTENSION_B: ExtensionInstance +let EXTENSION_TO_MIGRATE: ExtensionInstance +let CONFIG_EXTENSION: ExtensionInstance +let WEBHOOK_SUBSCRIPTION_EXTENSION: ExtensionInstance +let APP: AppInterface +let REMOTE_EXTENSION_A: AppModuleVersion +let REMOTE_EXTENSION_DELETED: AppModuleVersion +let REMOTE_CONFIG_EXTENSION: AppModuleVersion + +// A UI extension carrying the capability defaults shared across every fixture here. +function buildUiExtension(options: {directory: string; name: string; type: string; uid: string}) { + return testUIExtension({ + directory: options.directory, + configuration: { + name: options.name, + type: options.type, + metafields: [], + capabilities: { + network_access: false, + block_progress: false, + api_access: false, + collect_buyer_consent: { + sms_marketing: false, + customer_privacy: false, + }, + iframe: { + sources: [], + }, + }, + }, + entrySourceFilePath: '', + devUUID: 'devUUID', + uid: options.uid, + }) +} + +// A remote configuration webhook subscription module, identified by its topic/URI. +function remoteWebhookModule( + config: {topic: string; uri: string; api_version?: string}, + uuid = 'webhook-subscription-uuid', +): AppModuleVersion { + const title = `${config.topic}::::${config.uri}` + return { + registrationId: title, + registrationUuid: uuid, + registrationTitle: title, + type: 'webhook_subscription', + config, + specification: { + identifier: 'webhook_subscription', + name: 'Webhook Subscription', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + } as AppModuleVersion +} + +// The option bag both entry points accept; each test overrides only what it exercises. +function deployOptions( + overrides: Partial = {}, +): EnsureDeploymentIdsPresenceOptions { + return { + app: APP, + appId: REMOTE_APP.apiKey, + appName: REMOTE_APP.title, + release: true, + developerPlatformClient: testDeveloperPlatformClient(), + envIdentifiers: {}, + remoteApp: REMOTE_APP, + ...overrides, + } +} + +beforeAll(async () => { + EXTENSION_A = await buildUiExtension({ + directory: '/extension-a', + name: 'Extension A', + type: 'checkout_post_purchase', + uid: 'uid-a', + }) + EXTENSION_B = await buildUiExtension({ + directory: '/extension-b', + name: 'Extension B', + type: 'checkout_post_purchase', + uid: 'uid-b', + }) + EXTENSION_TO_MIGRATE = await buildUiExtension({ + directory: '/extension-to-migrate', + name: 'Legacy UI', + type: 'ui_extension', + uid: 'uid-migration', + }) + + CONFIG_EXTENSION = await testAppConfigExtensions() + WEBHOOK_SUBSCRIPTION_EXTENSION = await testSingleWebhookSubscriptionExtension() + + APP = testApp({ + name: 'my-app', + directory: '/app', + configuration: { + client_id: REMOTE_APP.apiKey, + name: 'my-app', + application_url: 'https://example.com', + embedded: true, + access_scopes: { + scopes: 'read_products', + }, + }, + allExtensions: [EXTENSION_A, EXTENSION_B, CONFIG_EXTENSION], + }) + + REMOTE_EXTENSION_A = { + registrationId: EXTENSION_A.uid, + registrationUuid: 'remote-uuid-a', + registrationTitle: 'Remote Extension A', + type: 'checkout_post_purchase', + config: await EXTENSION_A.deployConfig({apiKey: REMOTE_APP.apiKey, appConfiguration: APP.configuration}), + specification: { + identifier: 'checkout_post_purchase', + name: 'Post purchase UI extension', + experience: 'extension', + options: {managementExperience: 'cli'}, + }, + } + + REMOTE_EXTENSION_DELETED = { + registrationId: 'deleted-uid', + registrationUuid: 'deleted-uuid', + registrationTitle: 'Deleted Extension', + type: 'checkout_post_purchase', + config: {}, + specification: { + identifier: 'checkout_post_purchase', + name: 'Post purchase UI extension', + experience: 'extension', + options: {managementExperience: 'cli'}, + }, + } + + REMOTE_CONFIG_EXTENSION = { + registrationId: CONFIG_EXTENSION.uid, + registrationUuid: 'remote-config-uuid', + registrationTitle: 'Point of Sale', + type: 'point_of_sale', + config: {}, + specification: { + identifier: 'point_of_sale', + name: 'Point of Sale', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + } +}) + +beforeEach(() => { + vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) + vi.mocked(extensionMigrationPrompt).mockResolvedValue(true) + vi.mocked(migrateExtensionsToUIExtension).mockResolvedValue([]) +}) + +describe('classifyDeployExtensionChanges', () => { + test('classifies local and active app version extensions in one pass', async () => { + const changes = await classifyDeployExtensionChanges({ + options: deployOptions(), + activeAppVersion: {appModuleVersions: [REMOTE_EXTENSION_A, REMOTE_EXTENSION_DELETED, REMOTE_CONFIG_EXTENSION]}, + }) + + expect( + changes.map((change) => ({ + status: change.status, + local: change.local?.localIdentifier, + remote: change.remote?.registrationTitle, + })), + ).toEqual([ + {status: 'unchanged', local: EXTENSION_A.localIdentifier, remote: 'Remote Extension A'}, + {status: 'deleted', local: undefined, remote: 'Deleted Extension'}, + {status: 'unchanged', local: CONFIG_EXTENSION.localIdentifier, remote: 'Point of Sale'}, + {status: 'created', local: EXTENSION_B.localIdentifier, remote: undefined}, + ]) + }) + + test('classifies UUID fallback matches as updated', async () => { + const remoteWithoutMatchingUID = { + ...REMOTE_EXTENSION_A, + registrationId: '', + } + + const changes = await classifyDeployExtensionChanges({ + options: deployOptions({ + app: testApp({...APP, allExtensions: [EXTENSION_A]}), + envIdentifiers: {[EXTENSION_A.localIdentifier]: REMOTE_EXTENSION_A.registrationUuid!}, + }), + activeAppVersion: {appModuleVersions: [remoteWithoutMatchingUID]}, + }) + + expect(changes).toMatchObject([ + { + status: 'updated', + local: {localIdentifier: EXTENSION_A.localIdentifier}, + remote: {registrationUuid: REMOTE_EXTENSION_A.registrationUuid}, + }, + ]) + }) + + test('keeps same-UID non-config extensions unchanged even when config differs', async () => { + const changes = await classifyDeployExtensionChanges({ + options: deployOptions({app: testApp({...APP, allExtensions: [EXTENSION_A]})}), + activeAppVersion: { + appModuleVersions: [ + { + ...REMOTE_EXTENSION_A, + config: {changed: true}, + }, + ], + }, + }) + + expect(changes).toMatchObject([ + { + status: 'unchanged', + local: {localIdentifier: EXTENSION_A.localIdentifier}, + remote: {registrationUuid: REMOTE_EXTENSION_A.registrationUuid}, + }, + ]) + }) + + test('groups remote-only webhook subscription changes under configuration webhooks', async () => { + const remoteWebhookSubscriptions = [ + remoteWebhookModule( + {topic: 'app/uninstalled', uri: 'https://example.com/webhooks/app/uninstalled'}, + 'webhook-subscription-uuid-1', + ), + remoteWebhookModule( + {topic: 'scopes/update', uri: 'https://example.com/webhooks/scopes/update'}, + 'webhook-subscription-uuid-2', + ), + ] + const app = testApp({ + ...APP, + allExtensions: [], + specifications: [WEBHOOK_SUBSCRIPTION_EXTENSION.specification], + }) + + await ensureDeployIdentifiersFromAppVersion( + deployOptions({app, activeAppVersion: {appModuleVersions: remoteWebhookSubscriptions}, allowDeletes: true}), + ) + + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + extensionIdentifiersBreakdown: EMPTY_EXTENSION_BREAKDOWN, + configExtensionIdentifiersBreakdown: { + existingFieldNames: [], + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: ['webhooks'], + }, + }), + ) + }) + + test('trusts remote experience when deciding if remote-only modules are configuration', async () => { + const remoteConfigLikeExtension = { + registrationId: 'remote-config-id', + registrationUuid: 'remote-config-uuid', + registrationTitle: 'Remote Config-Like Extension', + type: 'unknown_remote_type', + config: {}, + specification: { + identifier: 'unknown_remote_type', + name: 'Unknown Remote Type', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + } as AppModuleVersion + const app = testApp({...APP, allExtensions: [], specifications: []}) + + await ensureDeployIdentifiersFromAppVersion( + deployOptions({app, activeAppVersion: {appModuleVersions: [remoteConfigLikeExtension]}, allowDeletes: true}), + ) + + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + extensionIdentifiersBreakdown: EMPTY_EXTENSION_BREAKDOWN, + configExtensionIdentifiersBreakdown: undefined, + }), + ) + }) + + test('shows each config field once and collapses mixed webhook changes to updated', async () => { + const remoteWebhookSubscription = remoteWebhookModule( + {topic: 'app/uninstalled', uri: 'https://example.com/webhooks/app/uninstalled'}, + 'webhook-subscription-uuid-1', + ) + const app = testApp({ + ...APP, + allExtensions: [WEBHOOK_SUBSCRIPTION_EXTENSION], + specifications: [WEBHOOK_SUBSCRIPTION_EXTENSION.specification], + }) + + await ensureDeployIdentifiersFromAppVersion( + deployOptions({app, activeAppVersion: {appModuleVersions: [remoteWebhookSubscription]}, allowDeletes: true}), + ) + + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: { + existingFieldNames: [], + existingUpdatedFieldNames: ['webhooks'], + newFieldNames: [], + deletedFieldNames: [], + }, + }), + ) + }) + + test('only marks changed fields as updated when one config specification owns multiple fields', async () => { + type DataConfig = BaseConfigType & { + product?: {enabled: boolean} + metaobject?: {enabled: boolean} + } + const dataSpec = createConfigExtensionSpecification({ + identifier: 'data', + schema: zod.object({ + product: zod.object({enabled: zod.boolean()}).optional(), + metaobject: zod.object({enabled: zod.boolean()}).optional(), + }), + transformConfig: { + product: 'product', + metaobject: 'metaobject', + }, + }) + const dataExtension = new ExtensionInstance({ + configuration: { + product: {enabled: true}, + metaobject: {enabled: true}, + }, + configurationPath: 'shopify.app.toml', + directory: '/app', + specification: dataSpec, + }) + const remoteDataModule = { + registrationId: dataExtension.uid, + registrationUuid: 'data-uuid', + registrationTitle: 'Data', + type: 'data', + config: { + product: {enabled: false}, + metaobject: {enabled: true}, + }, + specification: { + identifier: 'data', + name: 'Data', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + } as AppModuleVersion + + await ensureDeployIdentifiersFromAppVersion( + deployOptions({ + app: testApp({...APP, allExtensions: [dataExtension], specifications: [dataSpec]}), + activeAppVersion: {appModuleVersions: [remoteDataModule]}, + allowDeletes: true, + }), + ) + + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: { + existingFieldNames: ['metaobject'], + existingUpdatedFieldNames: ['product'], + newFieldNames: [], + deletedFieldNames: [], + }, + }), + ) + }) + + test('compares webhook subscriptions by config content instead of matching by UID', async () => { + const localWebhookSubscription = await testSingleWebhookSubscriptionExtension({ + config: { + topic: 'orders/delete', + api_version: '2024-01', + uri: '/webhooks/orders/delete', + }, + }) + const remoteWebhookSubscription = remoteWebhookModule({ + topic: 'orders/delete', + api_version: '2024-01', + uri: 'https://example.com/webhooks/orders/delete', + }) + + await ensureDeployIdentifiersFromAppVersion( + deployOptions({ + app: testApp({ + ...APP, + allExtensions: [localWebhookSubscription], + specifications: [WEBHOOK_SUBSCRIPTION_EXTENSION.specification], + }), + activeAppVersion: {appModuleVersions: [remoteWebhookSubscription]}, + allowDeletes: true, + }), + ) + + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + extensionIdentifiersBreakdown: EMPTY_EXTENSION_BREAKDOWN, + configExtensionIdentifiersBreakdown: { + existingFieldNames: ['webhooks'], + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + }, + }), + ) + }) + + test('does not mark webhooks as updated when subscriptions are in a different order', async () => { + const localWebhookSubscriptions = [ + await testSingleWebhookSubscriptionExtension({ + config: { + topic: 'orders/delete', + api_version: '2024-01', + uri: 'https://example.com/webhooks/orders/delete', + }, + }), + await testSingleWebhookSubscriptionExtension({ + config: { + topic: 'products/update', + api_version: '2024-01', + uri: 'https://example.com/webhooks/products/update', + }, + }), + ] + const remoteWebhookSubscriptions = [ + remoteWebhookModule( + {topic: 'products/update', api_version: '2024-01', uri: 'https://example.com/webhooks/products/update'}, + 'webhook-subscription-uuid-1', + ), + remoteWebhookModule( + {topic: 'orders/delete', api_version: '2024-01', uri: 'https://example.com/webhooks/orders/delete'}, + 'webhook-subscription-uuid-2', + ), + ] + + await ensureDeployIdentifiersFromAppVersion( + deployOptions({ + app: testApp({ + ...APP, + allExtensions: localWebhookSubscriptions, + specifications: [WEBHOOK_SUBSCRIPTION_EXTENSION.specification], + }), + activeAppVersion: {appModuleVersions: remoteWebhookSubscriptions}, + allowDeletes: true, + }), + ) + + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: { + existingFieldNames: ['webhooks'], + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + }, + }), + ) + }) + + test('relinks a created local to an un-migrated remote that shares its handle and type', async () => { + const pendingRemote = { + registrationId: '', + registrationUuid: 'pending-uuid', + registrationTitle: EXTENSION_A.handle, + type: 'checkout_post_purchase', + config: {}, + specification: { + identifier: 'checkout_post_purchase', + name: 'Post purchase UI extension', + experience: 'extension', + options: {managementExperience: 'cli'}, + }, + } as AppModuleVersion + + const changes = await classifyDeployExtensionChanges({ + options: deployOptions({app: testApp({...APP, allExtensions: [EXTENSION_A]})}), + activeAppVersion: {appModuleVersions: [pendingRemote]}, + }) + + expect(changes).toMatchObject([ + { + status: 'updated', + local: {localIdentifier: EXTENSION_A.localIdentifier}, + remote: {registrationUuid: 'pending-uuid'}, + }, + ]) + }) + + test('does not relink a remote that already has a UID even if handle and type match', async () => { + const remoteWithUid = { + registrationId: 'some-other-uid', + registrationUuid: 'remote-uuid', + registrationTitle: EXTENSION_A.handle, + type: 'checkout_post_purchase', + config: {}, + specification: { + identifier: 'checkout_post_purchase', + name: 'Post purchase UI extension', + experience: 'extension', + options: {managementExperience: 'cli'}, + }, + } as AppModuleVersion + + const changes = await classifyDeployExtensionChanges({ + options: deployOptions({app: testApp({...APP, allExtensions: [EXTENSION_A]})}), + activeAppVersion: {appModuleVersions: [remoteWithUid]}, + }) + + expect(changes).toMatchObject([ + {status: 'deleted', remote: {registrationTitle: EXTENSION_A.handle}}, + {status: 'created', local: {localIdentifier: EXTENSION_A.localIdentifier}}, + ]) + }) + + test('does not relink by handle and type when more than one remote matches', async () => { + const pendingRemote = (registrationUuid: string) => + ({ + registrationId: '', + registrationUuid, + registrationTitle: EXTENSION_A.handle, + type: 'checkout_post_purchase', + config: {}, + specification: { + identifier: 'checkout_post_purchase', + name: 'Post purchase UI extension', + experience: 'extension', + options: {managementExperience: 'cli'}, + }, + }) as AppModuleVersion + + const changes = await classifyDeployExtensionChanges({ + options: deployOptions({app: testApp({...APP, allExtensions: [EXTENSION_A]})}), + activeAppVersion: {appModuleVersions: [pendingRemote('uuid-1'), pendingRemote('uuid-2')]}, + }) + + expect(changes).toMatchObject([ + {status: 'deleted', remote: {registrationUuid: 'uuid-1'}}, + {status: 'deleted', remote: {registrationUuid: 'uuid-2'}}, + {status: 'created', local: {localIdentifier: EXTENSION_A.localIdentifier}}, + ]) + }) +}) + +describe('ensureDeployIdentifiersFromAppVersion', () => { + test('prompts with the existing UI breakdown shape and returns deploy identifiers', async () => { + const identifiers = await ensureDeployIdentifiersFromAppVersion( + deployOptions({ + activeAppVersion: {appModuleVersions: [REMOTE_EXTENSION_A, REMOTE_EXTENSION_DELETED, REMOTE_CONFIG_EXTENSION]}, + allowDeletes: true, + }), + ) + + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith({ + extensionIdentifiersBreakdown: { + onlyRemote: [{title: 'Deleted Extension', uid: 'deleted-uid', experience: 'extension'}], + toCreate: [{title: EXTENSION_B.localIdentifier, uid: EXTENSION_B.uid, experience: 'extension'}], + toUpdate: [], + unchanged: [{title: EXTENSION_A.localIdentifier, uid: undefined, experience: 'extension'}], + }, + configExtensionIdentifiersBreakdown: { + existingFieldNames: [], + existingUpdatedFieldNames: [], + newFieldNames: ['pos'], + deletedFieldNames: [], + }, + appTitle: REMOTE_APP.title, + release: true, + allowUpdates: undefined, + allowDeletes: true, + installCount: undefined, + }) + expect(identifiers).toEqual({ + appModuleUuids: { + [EXTENSION_A.localIdentifier]: REMOTE_EXTENSION_A.registrationUuid, + [EXTENSION_B.localIdentifier]: EXTENSION_B.uid, + [CONFIG_EXTENSION.localIdentifier]: REMOTE_CONFIG_EXTENSION.registrationUuid, + }, + appModuleRegistrationIds: { + [EXTENSION_A.localIdentifier]: EXTENSION_A.uid, + [EXTENSION_B.localIdentifier]: EXTENSION_B.uid, + [CONFIG_EXTENSION.localIdentifier]: CONFIG_EXTENSION.uid, + }, + }) + }) + + test('runs extension migrations before classifying the app version', async () => { + const legacyRemoteExtension = { + uuid: 'legacy-uuid-a', + id: '', + title: EXTENSION_TO_MIGRATE.localIdentifier, + type: 'CHECKOUT_UI_EXTENSION', + } + const migratedModule = { + registrationId: EXTENSION_TO_MIGRATE.uid, + registrationUuid: legacyRemoteExtension.uuid, + registrationTitle: 'Legacy UI', + type: 'ui_extension', + config: await EXTENSION_TO_MIGRATE.deployConfig({apiKey: REMOTE_APP.apiKey, appConfiguration: APP.configuration}), + specification: { + identifier: 'ui_extension', + name: 'UI Extension', + experience: 'extension', + options: {managementExperience: 'cli'}, + }, + } as AppModuleVersion + const activeAppVersion = {appModuleVersions: [migratedModule]} + const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ + appExtensionRegistrations: () => + Promise.resolve({ + app: { + extensionRegistrations: [legacyRemoteExtension], + dashboardManagedExtensionRegistrations: [], + configurationRegistrations: [], + }, + }), + activeAppVersion: () => Promise.resolve(activeAppVersion), + }) + + await ensureDeployIdentifiersFromAppVersion( + deployOptions({ + app: testApp({...APP, allExtensions: [EXTENSION_TO_MIGRATE]}), + developerPlatformClient, + activeAppVersion: {appModuleVersions: []}, + allowUpdates: true, + }), + ) + + expect(extensionMigrationPrompt).toHaveBeenCalledWith([ + {local: EXTENSION_TO_MIGRATE, remote: legacyRemoteExtension}, + ]) + expect(migrateExtensionsToUIExtension).toHaveBeenCalledWith({ + extensionsToMigrate: [{local: EXTENSION_TO_MIGRATE, remote: legacyRemoteExtension}], + appId: REMOTE_APP.apiKey, + remoteExtensions: [legacyRemoteExtension], + migrationClient: expect.objectContaining({clientName: 'partners'}), + }) + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + extensionIdentifiersBreakdown: expect.objectContaining({ + unchanged: [{title: EXTENSION_TO_MIGRATE.localIdentifier, uid: undefined, experience: 'extension'}], + }), + }), + ) + }) + + test('aborts when extension migration is declined', async () => { + const legacyRemoteExtension = { + uuid: 'legacy-uuid-a', + id: '', + title: EXTENSION_TO_MIGRATE.localIdentifier, + type: 'CHECKOUT_UI_EXTENSION', + } + const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ + appExtensionRegistrations: () => + Promise.resolve({ + app: { + extensionRegistrations: [legacyRemoteExtension], + dashboardManagedExtensionRegistrations: [], + configurationRegistrations: [], + }, + }), + }) + vi.mocked(extensionMigrationPrompt).mockResolvedValue(false) + + await expect( + ensureDeployIdentifiersFromAppVersion( + deployOptions({ + app: testApp({...APP, allExtensions: [EXTENSION_TO_MIGRATE]}), + developerPlatformClient, + activeAppVersion: {appModuleVersions: []}, + }), + ), + ).rejects.toThrow(AbortSilentError) + expect(deployOrReleaseConfirmationPrompt).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app/src/cli/services/context/deploy-app-version.ts b/packages/app/src/cli/services/context/deploy-app-version.ts new file mode 100644 index 00000000000..e8be3816f44 --- /dev/null +++ b/packages/app/src/cli/services/context/deploy-app-version.ts @@ -0,0 +1,214 @@ +import { + buildDashboardBreakdownInfo, + buildConfigExtensionIdentifiersBreakdown, + buildExtensionBreakdownInfo, + ExtensionIdentifiersBreakdown, +} from './breakdown-extensions.js' +import {activeAppVersionAfterMigrations} from './deploy-app-version-migrations.js' +import {EnsureDeploymentIdsPresenceOptions} from './identifiers.js' +import {remoteAppConfigurationExtensionContent} from '../app/select-app.js' +import {AppInterface} from '../../models/app/app.js' +import {DeployIdentifiers, ExtensionUuidsByLocalIdentifier} from '../../models/app/identifiers.js' +import {MinimalOrganizationApp} from '../../models/organization.js' +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' + +type DeployExtensionChangeStatus = 'created' | 'updated' | 'deleted' | 'unchanged' + +interface DeployExtensionChange { + status: DeployExtensionChangeStatus + experience: 'configuration' | 'extension' | 'deprecated' + local?: ExtensionInstance + remote?: AppModuleVersion +} + +interface ClassifyDeployExtensionChangesOptions { + options: EnsureDeploymentIdsPresenceOptions + activeAppVersion?: AppVersion +} + +/** Builds deploy identifiers after migration, classification, and confirmation. */ +export async function ensureDeployIdentifiersFromAppVersion( + options: EnsureDeploymentIdsPresenceOptions, +): Promise { + const activeAppVersion = await activeAppVersionAfterMigrations(options) + const changes = await classifyDeployExtensionChanges({options, activeAppVersion}) + const extensionIdentifiersBreakdown = buildExtensionIdentifiersBreakdown(changes) + const configExtensionIdentifiersBreakdown = await buildDeployConfigExtensionIdentifiersBreakdown( + options, + activeAppVersion, + ) + + const shouldFetchInstallCount = + options.release && !options.allowDeletes && extensionIdentifiersBreakdown.onlyRemote.length > 0 + const installCount = shouldFetchInstallCount ? await fetchInstallCount(options).catch(() => undefined) : undefined + + const confirmed = await deployOrReleaseConfirmationPrompt({ + extensionIdentifiersBreakdown, + configExtensionIdentifiersBreakdown, + appTitle: options.remoteApp?.title, + release: options.release, + allowUpdates: options.allowUpdates, + allowDeletes: options.allowDeletes, + installCount, + }) + if (!confirmed) throw new AbortSilentError() + + return buildDeployIdentifiersFromChanges(changes) +} + +/** Classifies local and active-version extensions by deploy outcome. */ +export async function classifyDeployExtensionChanges({ + options, + activeAppVersion, +}: ClassifyDeployExtensionChangesOptions): Promise { + const remoteModules = activeAppVersion?.appModuleVersions ?? [] + const allExtensions = options.app.allExtensions + const envUUIDs = options.envIdentifiers + const pendingRemotes = remoteModules.filter((remote) => remote.registrationId === '') + + const remoteChanges = remoteModules.map((remote): DeployExtensionChange => { + if (remote.registrationId === '') { + // Non-migrated modules flow (temporary, to be removed eventually): match by the stored legacy + // UUID, then fall back to a unique handle + type match. + const local = + allExtensions.find((local) => envUUIDs[local.localIdentifier] === remote.registrationUuid) ?? + uniqueHandleAndTypeMatch(allExtensions, pendingRemotes, remote) + if (local) return {experience: local.specification.experience, status: 'updated', local, remote} + } else { + // Normal flow: the remote module has a UID. + const local = allExtensions.find((local) => remote.registrationId === local.uid) + if (local) return {experience: local.specification.experience, status: 'unchanged', local, remote} + } + + return {experience: remote.specification?.experience ?? 'extension', status: 'deleted', remote} + }) + + const matchedLocals = remoteChanges.map((change) => change.local) + const createdChanges = allExtensions + .filter((local) => !matchedLocals.includes(local)) + .map( + (local): DeployExtensionChange => ({ + experience: local.specification.experience, + status: 'created', + local, + }), + ) + + return [...remoteChanges, ...createdChanges] +} + +/** + * Finds the single local extension that matches a remote by handle and type. Returns undefined when + * there is no match or when it would be ambiguous — more than one local matches, or more than one + * un-migrated remote shares that handle and type — so an ambiguous relink is never guessed. + */ +function uniqueHandleAndTypeMatch( + allExtensions: ExtensionInstance[], + pendingRemotes: AppModuleVersion[], + remote: AppModuleVersion, +): ExtensionInstance | undefined { + const sameHandleAndType = (local: ExtensionInstance, candidate: AppModuleVersion) => + local.handle === candidate.registrationTitle && + local.specification.identifier === candidate.specification?.identifier + + const localMatches = allExtensions.filter((local) => sameHandleAndType(local, remote)) + if (localMatches.length !== 1) return undefined + + const local = localMatches[0]! + const remoteMatches = pendingRemotes.filter((candidate) => sameHandleAndType(local, candidate)) + return remoteMatches.length === 1 ? local : undefined +} + +/** Converts config deploy changes into the existing config prompt breakdown. */ +async function buildDeployConfigExtensionIdentifiersBreakdown( + options: EnsureDeploymentIdsPresenceOptions, + activeAppVersion: AppVersion | undefined, +): Promise> { + const {app, appId} = options + const localConfig = await localAppConfigurationExtensionContent(app, appId) + const remoteConfig = remoteAppConfigurationExtensionContent( + activeAppVersion?.appModuleVersions ?? [], + app.specifications ?? [], + app.remoteFlags, + ) + + return buildConfigExtensionIdentifiersBreakdown(localConfig, remoteConfig) +} + +/** Converts deploy changes into the existing extension prompt breakdown. */ +function buildExtensionIdentifiersBreakdown(changes: DeployExtensionChange[]): ExtensionIdentifiersBreakdown { + const visibleChanges = changes.filter((change) => change.experience === 'extension') + + return { + onlyRemote: visibleChanges + .filter((change) => change.status === 'deleted') + .map((change) => buildRemoteBreakdownInfo(change.remote!)), + toCreate: visibleChanges + .filter((change) => change.status === 'created') + .map((change) => buildExtensionBreakdownInfo(change.local!.localIdentifier, change.local!.uid)), + toUpdate: visibleChanges + .filter((change) => change.status === 'updated') + .map((change) => buildExtensionBreakdownInfo(change.local!.localIdentifier, undefined)), + unchanged: visibleChanges + .filter((change) => change.status === 'unchanged') + .map((change) => buildExtensionBreakdownInfo(change.local!.localIdentifier, undefined)), + } +} + +/** Converts deploy changes into the identifiers needed by upload. */ +function buildDeployIdentifiersFromChanges(changes: DeployExtensionChange[]) { + const appModuleUuids: ExtensionUuidsByLocalIdentifier = {} + const appModuleRegistrationIds: ExtensionUuidsByLocalIdentifier = {} + + for (const change of changes) { + if (!change.local) continue + + const localUID = change.local.uid + const uuid = change.remote?.registrationUuid ?? localUID + const registrationId = change.remote?.registrationId ?? localUID + appModuleUuids[change.local.localIdentifier] = uuid + appModuleRegistrationIds[change.local.localIdentifier] = registrationId + } + + return {appModuleUuids, appModuleRegistrationIds} +} + +async function localAppConfigurationExtensionContent(app: AppInterface, apiKey: string) { + let appConfig: {[key: string]: unknown} = {} + const configExtensions = app.allExtensions.filter((extension) => extension.isAppConfigExtension) + + 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) + } + + return appConfig +} + +/** Builds prompt metadata for a remote-only module. */ +function buildRemoteBreakdownInfo(remote: AppModuleVersion) { + if (remote.specification?.options.managementExperience === 'dashboard') { + return buildDashboardBreakdownInfo(remote.registrationTitle) + } + return buildExtensionBreakdownInfo(remote.registrationTitle, remote.registrationId) +} + +/** Fetches install count for delete warnings. */ +async function fetchInstallCount(options: { + developerPlatformClient: EnsureDeploymentIdsPresenceOptions['developerPlatformClient'] + remoteApp: MinimalOrganizationApp +}) { + return options.developerPlatformClient.appInstallCount({ + id: options.remoteApp.id, + apiKey: options.remoteApp.apiKey, + organizationId: options.remoteApp.organizationId, + }) +} diff --git a/packages/app/src/cli/services/context/identifiers-extensions.test.ts b/packages/app/src/cli/services/context/identifiers-extensions.test.ts deleted file mode 100644 index ec5c8b3f3de..00000000000 --- a/packages/app/src/cli/services/context/identifiers-extensions.test.ts +++ /dev/null @@ -1,1099 +0,0 @@ -/* eslint-disable @shopify/prefer-module-scope-constants */ -import {automaticMatchmaking} from './id-matching.js' -import { - deployConfirmed, - ensureExtensionsIds, - ensureNonUuidManagedExtensionsIds, - groupRegistrationByUidStrategy, -} from './identifiers-extensions.js' -import {extensionMigrationPrompt, matchConfirmationPrompt} from './prompts.js' -import {manualMatchIds} from './id-manual-matching.js' -import {EnsureDeploymentIdsPresenceOptions, LocalSource, RemoteSource} from './identifiers.js' -import {AppInterface} from '../../models/app/app.js' -import { - testApp, - testAppConfigExtensions, - testFunctionExtension, - testOrganizationApp, - testUIExtension, - testPaymentsAppExtension, - testDeveloperPlatformClient, - testSingleWebhookSubscriptionExtension, -} from '../../models/app/app.test-data.js' -import {migrateExtensionsToUIExtension} from '../dev/migrate-to-ui-extension.js' -import {OrganizationApp} from '../../models/organization.js' -import {ExtensionInstance} from '../../models/extensions/extension-instance.js' -import {DeveloperPlatformClient, Flag} from '../../utilities/developer-platform-client.js' -import appPOSSpec from '../../models/extensions/specifications/app_config_point_of_sale.js' -import appWebhookSubscriptionSpec from '../../models/extensions/specifications/app_config_webhook_subscription.js' -import {getModulesToMigrate} from '../dev/migrate-app-module.js' -import {ExtensionSpecification} from '../../models/extensions/specification.js' -import {PartnersClient} from '../../utilities/developer-platform-client/partners-client.js' -import {beforeEach, describe, expect, vi, test, beforeAll} from 'vitest' -import {AbortSilentError} from '@shopify/cli-kit/node/error' -import {setPathValue} from '@shopify/cli-kit/common/object' - -const REGISTRATION_A = { - uuid: 'UUID_A', - id: 'A', - title: 'A', - type: 'CHECKOUT_POST_PURCHASE', - contextValue: '', -} - -const REGISTRATION_A_2 = { - uuid: 'UUID_A_2', - id: 'A_2', - title: 'A_2', - type: 'CHECKOUT_POST_PURCHASE', - contextValue: '', -} - -const REGISTRATION_A_3 = { - uuid: 'UUID_A_3', - id: 'A_3', - title: 'A_3', - type: 'CHECKOUT_POST_PURCHASE', - contextValue: '', -} - -const REGISTRATION_B = { - uuid: 'UUID_B', - id: 'B', - title: 'B', - type: 'SUBSCRIPTION_MANAGEMENT', - contextValue: '', -} - -const FUNCTION_REGISTRATION_A = { - uuid: 'FUNCTION_A_UUID', - id: 'FUNCTION_A', - title: 'FUNCTION_A', - type: 'FUNCTION', - contextValue: '', -} - -const PAYMENTS_REGISTRATION_A = { - uuid: 'PAYMENTS_A_UUID', - id: 'PAYMENTS_A', - title: 'PAYMENTS_A', - type: 'PAYMENTS', - contextValue: 'payments.offsite.render', -} - -let EXTENSION_A: ExtensionInstance -let EXTENSION_A_2: ExtensionInstance -let EXTENSION_B: ExtensionInstance -let FUNCTION_A: ExtensionInstance -let PAYMENTS_A: ExtensionInstance - -const LOCAL_APP = ( - uiExtensions: ExtensionInstance[], - functionExtensions: ExtensionInstance[] = [], - includeDeployConfig = false, - configExtensions: ExtensionInstance[] = [], -): AppInterface => { - return testApp({ - name: 'my-app', - directory: '/app', - configuration: { - client_id: 'test-client-id', - name: 'my-app', - application_url: 'https://example.com', - embedded: true, - access_scopes: { - scopes: 'read_products', - }, - extension_directories: ['extensions/*'], - ...(includeDeployConfig ? {build: {include_config_on_deploy: true}} : {}), - }, - allExtensions: [...uiExtensions, ...functionExtensions, ...configExtensions], - }) -} - -const options = ( - uiExtensions: ExtensionInstance[], - functionExtensions: ExtensionInstance[] = [], - options: { - identifiers?: any - remoteApp?: OrganizationApp - release?: boolean - includeDeployConfig?: boolean - configExtensions?: ExtensionInstance[] - flags?: Flag[] - developerPlatformClient?: DeveloperPlatformClient - } = {}, -): EnsureDeploymentIdsPresenceOptions => { - const { - identifiers = {}, - remoteApp = testOrganizationApp(), - release = true, - includeDeployConfig = false, - configExtensions = [], - flags = [], - developerPlatformClient = testDeveloperPlatformClient(), - } = options - const localApp = { - app: LOCAL_APP(uiExtensions, functionExtensions, includeDeployConfig, configExtensions), - developerPlatformClient, - appId: 'appId', - appName: 'appName', - envIdentifiers: {extensions: identifiers}, - force: false, - remoteApp, - release, - } - setPathValue(localApp.app, 'remoteFlags', flags) - return localApp -} - -vi.mock('@shopify/cli-kit/node/session') - -vi.mock('./prompts', async () => { - const prompts: any = await vi.importActual('./prompts') - return { - ...prompts, - matchConfirmationPrompt: vi.fn(), - deployConfirmationPrompt: vi.fn(), - extensionMigrationPrompt: vi.fn(), - } -}) -vi.mock('./id-matching') -vi.mock('./id-manual-matching') -vi.mock('../dev/migrate-to-ui-extension') -vi.mock('../dev/migrate-app-module') -beforeAll(async () => { - EXTENSION_A = await testUIExtension({ - directory: 'EXTENSION_A', - type: 'checkout_post_purchase', - configuration: { - name: 'EXTENSION A', - type: 'checkout_post_purchase', - metafields: [], - capabilities: { - network_access: false, - block_progress: false, - api_access: false, - collect_buyer_consent: { - sms_marketing: false, - customer_privacy: false, - }, - iframe: { - sources: [], - }, - }, - }, - entrySourceFilePath: '', - }) - - EXTENSION_A_2 = await testUIExtension({ - directory: 'EXTENSION_A_2', - type: 'checkout_post_purchase', - configuration: { - name: 'EXTENSION A 2', - type: 'checkout_post_purchase', - metafields: [], - capabilities: { - network_access: false, - block_progress: false, - api_access: false, - collect_buyer_consent: { - sms_marketing: false, - customer_privacy: false, - }, - iframe: { - sources: [], - }, - }, - }, - entrySourceFilePath: '', - devUUID: 'devUUID', - }) - - EXTENSION_B = await testUIExtension({ - directory: 'EXTENSION_B', - type: 'checkout_post_purchase', - configuration: { - name: 'EXTENSION_B', - type: 'checkout_post_purchase', - metafields: [], - capabilities: { - network_access: false, - block_progress: false, - api_access: false, - collect_buyer_consent: { - sms_marketing: false, - customer_privacy: false, - }, - iframe: { - sources: [], - }, - }, - }, - entrySourceFilePath: '', - devUUID: 'devUUID', - }) - - FUNCTION_A = await testFunctionExtension({ - dir: '/function', - config: { - name: 'FUNCTION A', - type: 'product_discounts', - description: 'Function', - build: { - command: 'make build', - path: 'dist/index.wasm', - wasm_opt: true, - }, - configuration_ui: false, - api_version: '2022-07', - }, - }) - - PAYMENTS_A = await testPaymentsAppExtension({ - dir: '/payments', - config: { - name: 'Payments Extension', - type: 'payments_extension', - description: 'Payments App Extension', - api_version: '2022-07', - payment_session_url: 'https://example.com/payment', - supported_countries: ['US'], - supported_payment_methods: ['VISA'], - test_mode_available: true, - merchant_label: 'Merchant Label', - supports_installments: false, - supports_deferred_payments: false, - supports_3ds: false, - targeting: [{target: 'payments.offsite.render'}], - }, - }) -}) - -beforeEach(() => { - vi.mocked(getModulesToMigrate).mockReturnValue([]) -}) - -describe('matchmaking returns more remote sources than local', () => { - test('ensureExtensionsIds', async () => { - // Given - vi.mocked(matchConfirmationPrompt).mockResolvedValueOnce(true) - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A'}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [], - remote: [REGISTRATION_B], - }, - }) - - // When - const got = await ensureExtensionsIds(options([EXTENSION_A]), { - extensionRegistrations: [REGISTRATION_A, REGISTRATION_B], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [], - validMatches: { - EXTENSION_A: 'UUID_A', - }, - didMigrateDashboardExtensions: false, - }) - }) - test('deployConfirmed', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = { - EXTENSION_A: 'UUID_A', - } - const remoteExtensions = [REGISTRATION_A, REGISTRATION_B] - - // When - const got = await deployConfirmed(options([EXTENSION_A]), remoteExtensions, [], {extensionsToCreate, validMatches}) - - // Then - expect(got).toEqual({ - extensions: { - EXTENSION_A: 'UUID_A', - }, - extensionIds: {EXTENSION_A: 'A'}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('matchmaking returns ok with pending manual matches', () => { - test('ensureExtensionsIds: will call manualMatch and merge automatic and manual matches and create missing extensions', async () => { - // Given - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [EXTENSION_A, EXTENSION_A_2, EXTENSION_B], - remote: [REGISTRATION_A, REGISTRATION_A_2], - }, - }) - - vi.mocked(manualMatchIds).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A', EXTENSION_A_2: 'UUID_A_2'}, - toCreate: [EXTENSION_B], - onlyRemote: [], - }) - - // When - const got = await ensureExtensionsIds(options([EXTENSION_A, EXTENSION_A_2]), { - extensionRegistrations: [REGISTRATION_A, REGISTRATION_A_2], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(manualMatchIds).toHaveBeenCalledWith({ - local: [EXTENSION_A, EXTENSION_A_2, EXTENSION_B], - remote: [REGISTRATION_A, REGISTRATION_A_2], - }) - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [EXTENSION_B], - validMatches: { - EXTENSION_A: 'UUID_A', - EXTENSION_A_2: 'UUID_A_2', - }, - didMigrateDashboardExtensions: false, - }) - }) - - test('deployConfirmed: create missing extensions', async () => { - // Given - const extensionsToCreate: LocalSource[] = [EXTENSION_B] - const validMatches = { - EXTENSION_A: 'UUID_A', - EXTENSION_A_2: 'UUID_A_2', - } - const remoteExtensions = [REGISTRATION_A, REGISTRATION_A_2] - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const got = await deployConfirmed( - options([EXTENSION_A, EXTENSION_A_2], [], {developerPlatformClient}), - remoteExtensions, - [], - { - extensionsToCreate, - validMatches, - }, - ) - - // Then - expect(got).toEqual({ - extensions: { - EXTENSION_A: 'UUID_A', - EXTENSION_A_2: 'UUID_A_2', - 'extension-b': EXTENSION_B.uid, - }, - extensionIds: {EXTENSION_A: 'A', EXTENSION_A_2: 'A_2', 'extension-b': EXTENSION_B.uid}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('matchmaking returns ok with pending manual matches and manual match fails', () => { - test('ensureExtensionsIds', async () => { - // Given - vi.mocked(matchConfirmationPrompt).mockResolvedValueOnce(true) - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [EXTENSION_A, EXTENSION_A_2], - remote: [REGISTRATION_A, REGISTRATION_A_2], - }, - }) - vi.mocked(manualMatchIds).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A'}, - toCreate: [EXTENSION_A_2], - onlyRemote: [REGISTRATION_A_2], - }) - - // When - const got = await ensureExtensionsIds(options([EXTENSION_A, EXTENSION_A_2]), { - extensionRegistrations: [REGISTRATION_A, REGISTRATION_A_2], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [EXTENSION_A_2], - validMatches: { - EXTENSION_A: 'UUID_A', - }, - didMigrateDashboardExtensions: false, - }) - expect(manualMatchIds).toBeCalledWith({ - local: [EXTENSION_A, EXTENSION_A_2], - remote: [REGISTRATION_A, REGISTRATION_A_2], - }) - }) - test('deployConfirmed', async () => { - // Given - const extensionsToCreate: LocalSource[] = [EXTENSION_A_2] - const validMatches = { - EXTENSION_A: 'UUID_A', - } - const remoteExtensions = [REGISTRATION_A, REGISTRATION_A_2] - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const got = await deployConfirmed( - options([EXTENSION_A, EXTENSION_A_2], [], {developerPlatformClient}), - remoteExtensions, - [], - { - extensionsToCreate, - validMatches, - }, - ) - - // Then - expect(got).toEqual({ - extensions: { - EXTENSION_A: 'UUID_A', - 'extension-a-2': EXTENSION_A_2.uid, - }, - extensionIds: {EXTENSION_A: 'A', 'extension-a-2': EXTENSION_A_2.uid}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('matchmaking returns ok with pending some pending to create', () => { - test('ensureExtensionsIds', async () => { - // Given - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {}, - toConfirm: [], - toCreate: [EXTENSION_A, EXTENSION_A_2], - toManualMatch: { - local: [], - remote: [], - }, - }) - - // When - const got = await ensureExtensionsIds(options([EXTENSION_A, EXTENSION_A_2]), { - extensionRegistrations: [REGISTRATION_A, REGISTRATION_A_2], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [EXTENSION_A, EXTENSION_A_2], - validMatches: {}, - didMigrateDashboardExtensions: false, - }) - }) - test('deployConfirmed: Create the pending extensions and succeeds', async () => { - // Given - const extensionsToCreate: LocalSource[] = [EXTENSION_A, EXTENSION_A_2] - const validMatches = {} - const remoteExtensions = [REGISTRATION_A, REGISTRATION_A_2] - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const got = await deployConfirmed( - options([EXTENSION_A, EXTENSION_A_2], [], {developerPlatformClient}), - remoteExtensions, - [], - { - extensionsToCreate, - validMatches, - }, - ) - - // Then - expect(got).toEqual({ - extensions: {'extension-a': EXTENSION_A.uid, 'extension-a-2': EXTENSION_A_2.uid}, - extensionIds: {'extension-a': EXTENSION_A.uid, 'extension-a-2': EXTENSION_A_2.uid}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('matchmaking returns ok with some pending confirmation', () => { - test('ensureExtensionsIds: confirms the pending ones and succeeds', async () => { - // Given - vi.mocked(matchConfirmationPrompt).mockResolvedValueOnce(true) - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {}, - toConfirm: [{local: EXTENSION_B, remote: REGISTRATION_B}], - toCreate: [], - toManualMatch: { - local: [], - remote: [], - }, - }) - - // When - const got = await ensureExtensionsIds(options([EXTENSION_B]), { - extensionRegistrations: [REGISTRATION_B], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [], - validMatches: { - 'extension-b': 'UUID_B', - }, - didMigrateDashboardExtensions: false, - }) - }) - test('deployConfirmed', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = {'extension-b': 'UUID_B'} - const remoteExtensions = [REGISTRATION_B] - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const got = await deployConfirmed(options([EXTENSION_B], [], {developerPlatformClient}), remoteExtensions, [], { - extensionsToCreate, - validMatches, - }) - - // Then - expect(got).toEqual({ - extensions: {'extension-b': 'UUID_B'}, - extensionIds: {'extension-b': 'B'}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('matchmaking returns ok with some pending confirmation', () => { - test('ensureExtensionsIds: does not confirm the pending ones', async () => { - // Given - vi.mocked(matchConfirmationPrompt).mockResolvedValueOnce(false) - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {}, - toConfirm: [{local: EXTENSION_B, remote: REGISTRATION_B}], - toCreate: [], - toManualMatch: { - local: [], - remote: [], - }, - }) - - // When - const got = await ensureExtensionsIds(options([EXTENSION_B]), { - extensionRegistrations: [REGISTRATION_B], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [EXTENSION_B], - validMatches: {}, - didMigrateDashboardExtensions: false, - }) - }) - test('deployConfirmed: creates non confirmed as new extensions', async () => { - // Given - const extensionsToCreate: LocalSource[] = [EXTENSION_B] - const validMatches = {} - const remoteExtensions = [REGISTRATION_B] - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const got = await deployConfirmed(options([EXTENSION_B], [], {developerPlatformClient}), remoteExtensions, [], { - extensionsToCreate, - validMatches, - }) - - // Then - expect(got).toEqual({ - extensions: {'extension-b': EXTENSION_B.uid}, - extensionIds: {'extension-b': EXTENSION_B.uid}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('matchmaking returns ok with nothing pending', () => { - test('ensureExtensionsIds: succeeds and returns all identifiers', async () => { - // Given - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A', EXTENSION_A_2: 'UUID_A_2'}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [], - remote: [], - }, - }) - - // When - const got = await ensureExtensionsIds(options([EXTENSION_A, EXTENSION_A_2]), { - extensionRegistrations: [REGISTRATION_A, REGISTRATION_A_2], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [], - validMatches: {EXTENSION_A: 'UUID_A', EXTENSION_A_2: 'UUID_A_2'}, - didMigrateDashboardExtensions: false, - }) - }) - test('deployConfirmed: does not create any extension', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = {EXTENSION_A: 'UUID_A', EXTENSION_A_2: 'UUID_A_2'} - const remoteExtensions = [REGISTRATION_A, REGISTRATION_A_2] - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const got = await deployConfirmed( - options([EXTENSION_A, EXTENSION_A_2], [], {developerPlatformClient}), - remoteExtensions, - [], - { - extensionsToCreate, - validMatches, - }, - ) - - // Then - expect(got).toEqual({ - extensions: {EXTENSION_A: 'UUID_A', EXTENSION_A_2: 'UUID_A_2'}, - extensionIds: {EXTENSION_A: 'A', EXTENSION_A_2: 'A_2'}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('includes functions', () => { - test('ensureExtensionsIds: succeeds and returns all identifiers', async () => { - // Given - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A', FUNCTION_A: 'FUNCTION_A_UUID'}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [], - remote: [], - }, - }) - - // When - const ensureExtensionsIdsOptions = options([EXTENSION_A], [FUNCTION_A]) - const got = await ensureExtensionsIds(ensureExtensionsIdsOptions, { - extensionRegistrations: [REGISTRATION_A, FUNCTION_REGISTRATION_A], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(automaticMatchmaking).toHaveBeenCalledWith( - [EXTENSION_A, FUNCTION_A], - [REGISTRATION_A, FUNCTION_REGISTRATION_A], - {}, - ) - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [], - validMatches: {EXTENSION_A: 'UUID_A', FUNCTION_A: 'FUNCTION_A_UUID'}, - didMigrateDashboardExtensions: false, - }) - }) - test('deployConfirmed: does not create any extension', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = {EXTENSION_A: 'UUID_A', FUNCTION_A: 'FUNCTION_A_UUID'} - const remoteExtensions = [REGISTRATION_A, FUNCTION_REGISTRATION_A] - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const got = await deployConfirmed( - options([EXTENSION_A], [FUNCTION_A], {developerPlatformClient}), - remoteExtensions, - [], - { - extensionsToCreate, - validMatches, - }, - ) - - // Then - expect(got).toEqual({ - extensions: {EXTENSION_A: 'UUID_A', FUNCTION_A: 'FUNCTION_A_UUID'}, - extensionIds: {EXTENSION_A: 'A', FUNCTION_A: 'FUNCTION_A'}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('excludes non uuid managed extensions', () => { - test("ensureExtensionsIds: automatic matching logic doesn't receive the non uuid managed extensions", async () => { - // Given - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A', FUNCTION_A: 'FUNCTION_A_UUID'}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [], - remote: [], - }, - }) - - // When - const CONFIG_A = await testAppConfigExtensions() - const ensureExtensionsIdsOptions = options([EXTENSION_A], [], {configExtensions: [CONFIG_A]}) - await ensureExtensionsIds(ensureExtensionsIdsOptions, { - extensionRegistrations: [REGISTRATION_A], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(automaticMatchmaking).toHaveBeenCalledWith([EXTENSION_A], [REGISTRATION_A], {}) - }) -}) - -describe('ensureExtensionsIds: Migrates extension', () => { - test('shows confirmation prompt', async () => { - // Given - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A', EXTENSION_A_2: 'UUID_A_2'}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [], - remote: [], - }, - }) - const extensionsToMigrate = [ - {local: EXTENSION_A, remote: REGISTRATION_A}, - {local: EXTENSION_A_2, remote: REGISTRATION_A_2}, - ] - vi.mocked(getModulesToMigrate).mockReturnValueOnce(extensionsToMigrate) - - // When / Then - await expect(() => - ensureExtensionsIds(options([EXTENSION_A, EXTENSION_A_2]), { - extensionRegistrations: [REGISTRATION_A, REGISTRATION_A_2], - dashboardManagedExtensionRegistrations: [], - }), - ).rejects.toThrowError(AbortSilentError) - - expect(extensionMigrationPrompt).toBeCalledWith(extensionsToMigrate) - }) - - test('migrates extensions', async () => { - // Given - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A', EXTENSION_A_2: 'UUID_A_2'}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [], - remote: [], - }, - }) - const extensionsToMigrate = [ - {local: EXTENSION_A, remote: REGISTRATION_A}, - {local: EXTENSION_A_2, remote: REGISTRATION_A_2}, - ] - vi.mocked(getModulesToMigrate).mockReturnValueOnce(extensionsToMigrate) - vi.mocked(extensionMigrationPrompt).mockResolvedValueOnce(true) - const opts = options([EXTENSION_A, EXTENSION_A_2]) - const remoteExtensions = [REGISTRATION_A, REGISTRATION_A_2] - - // When - const result = await ensureExtensionsIds(opts, { - extensionRegistrations: remoteExtensions, - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(result).toEqual({ - didMigrateDashboardExtensions: true, - dashboardOnlyExtensions: [], - extensionsToCreate: [], - validMatches: { - EXTENSION_A: 'UUID_A', - EXTENSION_A_2: 'UUID_A_2', - }, - }) - - expect(migrateExtensionsToUIExtension).toBeCalledWith({ - extensionsToMigrate, - appId: opts.appId, - remoteExtensions, - migrationClient: expect.any(PartnersClient), - }) - }) -}) - -describe('deployConfirmed: handle non existent uuid managed extensions', () => { - test('when include config on deploy flag is enabled configuration extensions are created', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = {} - const REGISTRATION_CONFIG_A = { - uuid: 'UUID_C_A', - id: 'C_A', - title: 'C_A', - type: 'POINT_OF_SALE', - } - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const CONFIG_A = await testAppConfigExtensions() - const ensureExtensionsIdsOptions = options([], [], { - includeDeployConfig: true, - configExtensions: [CONFIG_A], - developerPlatformClient, - }) - const got = await deployConfirmed(ensureExtensionsIdsOptions, [], [REGISTRATION_CONFIG_A], { - extensionsToCreate, - validMatches, - }) - - // Then - expect(got).toEqual({ - extensions: {}, - extensionIds: {point_of_sale: 'C_A'}, - extensionsNonUuidManaged: {point_of_sale: 'UUID_C_A'}, - }) - }) - test('when the include config on deploy flag is disabled configuration extensions are not created', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = {} - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const CONFIG_A = await testAppConfigExtensions() - // Don't pass config extensions when includeConfigOnDeploy is false - they won't be in allExtensions - const ensureExtensionsIdsOptions = options([], [], {developerPlatformClient}) - const got = await deployConfirmed(ensureExtensionsIdsOptions, [], [], { - extensionsToCreate, - validMatches, - }) - - // Then - expect(got).toEqual({ - extensions: {}, - extensionIds: {}, - extensionsNonUuidManaged: {}, - }) - }) -}) -describe('deployConfirmed: handle existent uuid managed extensions', () => { - test('when the include config on deploy flag is enabled configuration extensions are not created but the uuids are returned', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = {} - const REGISTRATION_CONFIG_A = { - uuid: 'UUID_C_A', - id: 'C_A', - title: 'C_A', - type: 'POINT_OF_SALE', - } - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const CONFIG_A = await testAppConfigExtensions() - - const ensureExtensionsIdsOptions = options([], [], { - includeDeployConfig: true, - configExtensions: [CONFIG_A], - developerPlatformClient, - }) - const got = await deployConfirmed(ensureExtensionsIdsOptions, [], [REGISTRATION_CONFIG_A], { - extensionsToCreate, - validMatches, - }) - - // Then - expect(got).toEqual({ - extensions: {}, - extensionIds: {point_of_sale: 'C_A'}, - extensionsNonUuidManaged: {point_of_sale: 'UUID_C_A'}, - }) - }) -}) - -describe('deployConfirmed: extensions that should be managed in the TOML', () => { - test('are also passed to the `ensureNonUuidManagedExtensionsIds` function', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = {} - const REGISTRATION_CONFIG_A = { - uuid: 'UUID_C_A', - id: 'C_A', - title: 'C_A', - type: 'POINT_OF_SALE', - } - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const CONFIG_A = await testAppConfigExtensions() - - const ensureExtensionsIdsOptions = options([], [], { - includeDeployConfig: true, - configExtensions: [CONFIG_A], - developerPlatformClient, - }) - const got = await deployConfirmed(ensureExtensionsIdsOptions, [], [REGISTRATION_CONFIG_A], { - extensionsToCreate, - validMatches, - }) - - // Then - expect(got).toEqual({ - extensions: {}, - extensionIds: {point_of_sale: 'C_A'}, - extensionsNonUuidManaged: {point_of_sale: 'UUID_C_A'}, - }) - }) -}) - -describe('groupRegistrationByUidStrategy', () => { - test('extension registrations have dynamic UID strategy should be returned in the same array as configuration registrations', () => { - // Given - const dynamicUidStrategyExtension = { - uuid: 'webhook-subscription-uuid', - id: 'webhook-subscription-id', - title: 'Webhook Subscription', - type: 'WEBHOOK_SUBSCRIPTION', - } - - const extensionRegistrations = [REGISTRATION_B, dynamicUidStrategyExtension] - const configurationRegistrations = [ - { - uuid: 'UUID_C_A', - id: 'C_A', - title: 'C_A', - type: 'POINT_OF_SALE', - }, - ] - const specifications = [appPOSSpec, appWebhookSubscriptionSpec] as ExtensionSpecification[] - - const dynamicUidStrategySpec = specifications.find( - (spec) => spec.identifier === dynamicUidStrategyExtension.type.toLowerCase(), - ) - expect(dynamicUidStrategySpec?.experience).toEqual('configuration') - expect(dynamicUidStrategySpec?.uidStrategy).toEqual('dynamic') - - // When - const {uuidUidStrategyExtensions, singleAndDynamicStrategyExtensions} = groupRegistrationByUidStrategy( - extensionRegistrations, - configurationRegistrations, - specifications, - ) - - // Then - expect(uuidUidStrategyExtensions).toEqual([REGISTRATION_B]) - expect(singleAndDynamicStrategyExtensions).toEqual([configurationRegistrations[0], dynamicUidStrategyExtension]) - }) -}) - -describe('ensureNonUuidManagedExtensionsIds: for extensions managed in the TOML', () => { - test('creates the extension if no possible matches', async () => { - // Given - const remoteSources: RemoteSource[] = [] - const localSources = [ - await testSingleWebhookSubscriptionExtension(), - await testSingleWebhookSubscriptionExtension({topic: 'products/create'}), - await testSingleWebhookSubscriptionExtension({topic: 'products/update'}), - await testSingleWebhookSubscriptionExtension({topic: 'products/delete'}), - ] - const app = options(localSources, [], { - includeDeployConfig: true, - flags: [], - }).app - const developerPlatformClient = testDeveloperPlatformClient() - - // When - - const {extensionsNonUuidManaged, extensionsIdsNonUuidManaged} = await ensureNonUuidManagedExtensionsIds( - remoteSources, - app, - developerPlatformClient, - ) - - // Then - expect(Object.values(extensionsNonUuidManaged)).toEqual(localSources.map((source) => source.uid)) - expect(Object.values(extensionsIdsNonUuidManaged)).toEqual(localSources.map((source) => source.uid)) - }) - - test('if there are possible matches, creates the extension for those that dont match', async () => { - // Given - const config = { - topic: 'orders/delete', - api_version: '2024-01', - uri: '/webhooks', - include_fields: ['id'], - filter: 'id:*', - } - const sameTopicConfig = { - topic: 'orders/delete', - api_version: '2024-01', - uri: 'https://my-app.com/webhooks', - include_fields: ['id'], - filter: 'id:1234', - } - - const webhookSubscriptionExtension = { - uuid: 'webhook-subscription-uuid', - id: 'webhook-subscription-id', - title: 'Webhook Subscription', - type: 'WEBHOOK_SUBSCRIPTION', - activeVersion: { - // use absolute path here to test that it matches both absolute and relative paths from the local config - config: JSON.stringify({...config, uri: 'https://my-app.com/webhooks'}), - }, - } - - const localSources = [ - await testSingleWebhookSubscriptionExtension({config}), - await testSingleWebhookSubscriptionExtension({config: sameTopicConfig}), - await testSingleWebhookSubscriptionExtension({topic: 'products/create'}), - await testSingleWebhookSubscriptionExtension({topic: 'products/update'}), - await testSingleWebhookSubscriptionExtension({topic: 'products/delete'}), - ] - const remoteSources = [webhookSubscriptionExtension] - const app = options(localSources, [], { - includeDeployConfig: true, - flags: [], - }).app - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const {extensionsNonUuidManaged, extensionsIdsNonUuidManaged} = await ensureNonUuidManagedExtensionsIds( - remoteSources, - app, - developerPlatformClient, - ) - - // Then - expect(Object.values(extensionsNonUuidManaged)).toEqual([ - 'webhook-subscription-uuid', - ...localSources.slice(1).map((source) => source.uid), - ]) - expect(Object.values(extensionsIdsNonUuidManaged)).toEqual([ - 'webhook-subscription-id', - ...localSources.slice(1).map((source) => source.uid), - ]) - }) -}) diff --git a/packages/app/src/cli/services/context/identifiers-extensions.ts b/packages/app/src/cli/services/context/identifiers-extensions.ts index 6f96b48e7ab..2f1034ec7ea 100644 --- a/packages/app/src/cli/services/context/identifiers-extensions.ts +++ b/packages/app/src/cli/services/context/identifiers-extensions.ts @@ -37,7 +37,7 @@ export async function ensureExtensionsIds( }: AppWithExtensions, ) { let remoteExtensions = initialRemoteExtensions - const identifiers = options.envIdentifiers.extensions ?? {} + const identifiers = options.envIdentifiers ?? {} const localExtensions = options.app.allExtensions.filter((ext) => !ext.isAppConfigExtension) const uiExtensionsToMigrate = getModulesToMigrate(localExtensions, remoteExtensions, identifiers, UIModulesMap) @@ -261,7 +261,7 @@ function loadExtensionIds( }) } -export async function ensureNonUuidManagedExtensionsIds( +async function ensureNonUuidManagedExtensionsIds( remoteConfigurationRegistrations: RemoteSource[], app: AppInterface, developerPlatformClient: DeveloperPlatformClient, @@ -305,7 +305,7 @@ async function createExtensions(extensions: LocalSource[]) { return result } -export function groupRegistrationByUidStrategy( +function groupRegistrationByUidStrategy( extensionRegistrations: RemoteSource[], configurationRegistrations: RemoteSource[], specifications: ExtensionSpecification[], diff --git a/packages/app/src/cli/services/context/identifiers.ts b/packages/app/src/cli/services/context/identifiers.ts index 1f2b7eeefcf..bbd44c30f62 100644 --- a/packages/app/src/cli/services/context/identifiers.ts +++ b/packages/app/src/cli/services/context/identifiers.ts @@ -1,7 +1,7 @@ import {deployConfirmed} from './identifiers-extensions.js' import {configExtensionsIdentifiersBreakdown, extensionsIdentifiersDeployBreakdown} from './breakdown-extensions.js' import {AppInterface} from '../../models/app/app.js' -import {Identifiers} from '../../models/app/identifiers.js' +import {ExtensionUuidsByLocalIdentifier} from '../../models/app/identifiers.js' import {MinimalOrganizationApp} from '../../models/organization.js' import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' import {AppVersion, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' @@ -14,7 +14,7 @@ export interface EnsureDeploymentIdsPresenceOptions { developerPlatformClient: DeveloperPlatformClient appId: string appName: string - envIdentifiers: Partial + envIdentifiers: ExtensionUuidsByLocalIdentifier /** If true, allow adding and updating extensions and configuration without user confirmation */ allowUpdates?: boolean /** If true, allow removing extensions and configuration without user confirmation */