diff --git a/CLAUDE.md b/CLAUDE.md index 4c2379ce7..951e73bc4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,4 +27,5 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Early returns preferred for readability - ESM modules (Node.js 18+ support) - Type definitions in separate `/types` directory -- Never commit or push to main branch without permission \ No newline at end of file +- Never commit or push to main branch without permission +- Skipping tests is never a valid solution to fixing them \ No newline at end of file diff --git a/api/__tests__/migrate.test.ts b/api/__tests__/migrate.test.ts index 24d6be4f1..74a73de64 100644 --- a/api/__tests__/migrate.test.ts +++ b/api/__tests__/migrate.test.ts @@ -7,6 +7,7 @@ import { checkMigrationStatusV2, ListAppsResponse, MigrationStatus, + isMigrationStatus, } from '../migrate'; jest.mock('@hubspot/local-dev-lib/http'); @@ -193,3 +194,51 @@ describe('api/migrate', () => { }); }); }); + +describe('isMigrationStatus', () => { + it.each([ + { + id: 123, + status: MIGRATION_STATUS.IN_PROGRESS, + }, + { + id: 456, + status: MIGRATION_STATUS.INPUT_REQUIRED, + componentsRequiringUids: { + 'component-1': { + componentType: 'type1', + componentHint: 'hint1', + }, + }, + }, + { + id: 789, + status: MIGRATION_STATUS.SUCCESS, + buildId: 98765, + }, + { + id: 101, + status: MIGRATION_STATUS.FAILURE, + projectErrorDetail: 'Error details', + componentErrors: [], + }, + ])('should return true for valid MigrationStatus object %j', status => { + expect(isMigrationStatus(status)).toBe(true); + }); + + it.each([null, undefined, 123, 'string', true, false, []])( + 'should return false for non-object value %j', + value => { + expect(isMigrationStatus(value)).toBe(false); + } + ); + + it.each([ + {}, + { id: 123 }, + { status: MIGRATION_STATUS.IN_PROGRESS }, + { foo: 'bar' }, + ])('should return false for invalid object %j', obj => { + expect(isMigrationStatus(obj)).toBe(false); + }); +}); diff --git a/commands/app/__tests__/migrate.test.ts b/commands/app/__tests__/migrate.test.ts index c39ea9b39..7614dcf01 100644 --- a/commands/app/__tests__/migrate.test.ts +++ b/commands/app/__tests__/migrate.test.ts @@ -48,27 +48,46 @@ describe('commands/app/migrate', () => { }); it('should call migrateApp2025_2 for platform version 2025.2', async () => { - await migrateCommand.handler({ + const options = { derivedAccountId: mockAccountId, platformVersion: PLATFORM_VERSIONS.v2025_2, - } as ArgumentsCamelCase); + } as ArgumentsCamelCase; + + await migrateCommand.handler(options); expect(mockedMigrateApp2025_2).toHaveBeenCalledWith( mockAccountId, - expect.any(Object) + options + ); + expect(mockedMigrateApp2023_2).not.toHaveBeenCalled(); + }); + + it('should call migrateApp2025_2 when unstable is true', async () => { + const options = { + derivedAccountId: mockAccountId, + unstable: true, + } as ArgumentsCamelCase; + + await migrateCommand.handler(options); + + expect(mockedMigrateApp2025_2).toHaveBeenCalledWith( + mockAccountId, + options ); expect(mockedMigrateApp2023_2).not.toHaveBeenCalled(); }); it('should call migrateApp2023_2 for platform version 2023.2', async () => { - await migrateCommand.handler({ + const options = { derivedAccountId: mockAccountId, platformVersion: PLATFORM_VERSIONS.v2023_2, - } as ArgumentsCamelCase); + } as ArgumentsCamelCase; + + await migrateCommand.handler(options); expect(mockedMigrateApp2023_2).toHaveBeenCalledWith( mockAccountId, - expect.any(Object), + options, mockAccountConfig ); expect(mockedMigrateApp2025_2).not.toHaveBeenCalled(); diff --git a/commands/project/__tests__/migrate.test.ts b/commands/project/__tests__/migrate.test.ts new file mode 100644 index 000000000..e42fc7242 --- /dev/null +++ b/commands/project/__tests__/migrate.test.ts @@ -0,0 +1,140 @@ +import yargs, { Argv, ArgumentsCamelCase } from 'yargs'; +import { logger } from '@hubspot/local-dev-lib/logger'; +import { PLATFORM_VERSIONS } from '@hubspot/local-dev-lib/constants/projects'; +import { ProjectMigrateArgs } from '../migrate'; +import migrateCommand from '../migrate'; +import { migrateApp2025_2 } from '../../../lib/app/migrate'; +import { getProjectConfig } from '../../../lib/projects/config'; +import { commands } from '../../../lang/en'; +import { uiBetaTag, uiCommandReference } from '../../../lib/ui'; + +jest.mock('yargs'); +jest.mock('@hubspot/local-dev-lib/logger'); +jest.mock('../../../lib/app/migrate'); +jest.mock('../../../lib/projects/config'); +jest.mock('../../../lib/ui'); + +const { v2025_2 } = PLATFORM_VERSIONS; + +describe('commands/project/migrate', () => { + const yargsMock = yargs as Argv; + const optionsSpy = jest.spyOn(yargsMock, 'option').mockReturnValue(yargsMock); + + // Mock the imported functions + const migrateApp2025_2Mock = migrateApp2025_2 as jest.Mock; + const getProjectConfigMock = getProjectConfig as jest.Mock; + const uiBetaTagMock = uiBetaTag as jest.Mock; + const uiCommandReferenceMock = uiCommandReference as jest.Mock; + + beforeEach(() => { + // Mock logger methods + jest.spyOn(logger, 'log').mockImplementation(); + jest.spyOn(logger, 'error').mockImplementation(); + migrateApp2025_2Mock.mockResolvedValue(undefined); + getProjectConfigMock.mockResolvedValue({ + projectConfig: { name: 'test-project' }, + }); + uiBetaTagMock.mockReturnValue('beta test description'); + uiCommandReferenceMock.mockReturnValue('command reference'); + }); + + describe('command', () => { + it('should have the correct command structure', () => { + expect(migrateCommand.command).toEqual('migrate'); + }); + }); + + describe('describe', () => { + it('should provide a description', () => { + expect(migrateCommand.describe).toBe(undefined); + }); + }); + + describe('builder', () => { + it('should support the correct options', () => { + migrateCommand.builder(yargsMock); + + expect(optionsSpy).toHaveBeenCalledWith('platform-version', { + type: 'string', + choices: [v2025_2], + default: v2025_2, + hidden: true, + }); + + expect(optionsSpy).toHaveBeenCalledWith('unstable', { + type: 'boolean', + default: false, + hidden: true, + }); + }); + }); + + describe('handler', () => { + let options: ArgumentsCamelCase; + let mockExit: jest.SpyInstance; + + beforeEach(() => { + mockExit = jest.spyOn(process, 'exit').mockImplementation(); + options = { + platformVersion: v2025_2, + unstable: false, + derivedAccountId: 123, + } as ArgumentsCamelCase; + }); + + afterEach(() => { + mockExit.mockRestore(); + }); + + it('should exit with error if no project config exists', async () => { + getProjectConfigMock.mockResolvedValue({ projectConfig: null }); + + await migrateCommand.handler(options); + + expect(logger.error).toHaveBeenCalledWith( + commands.project.migrate.errors.noProjectConfig('command reference') + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should call migrateApp2025_2 with correct parameters', async () => { + await migrateCommand.handler(options); + + expect(migrateApp2025_2Mock).toHaveBeenCalledWith( + 123, + { + ...options, + name: 'test-project', + platformVersion: v2025_2, + }, + { projectConfig: { name: 'test-project' } } + ); + expect(mockExit).toHaveBeenCalledWith(0); + }); + + it('should use unstable platform version when unstable flag is true', async () => { + options.unstable = true; + + await migrateCommand.handler(options); + + expect(migrateApp2025_2Mock).toHaveBeenCalledWith( + 123, + { + ...options, + name: 'test-project', + platformVersion: PLATFORM_VERSIONS.unstable, + }, + { projectConfig: { name: 'test-project' } } + ); + }); + + it('should handle errors and exit with error code', async () => { + const error = new Error('Test error'); + migrateApp2025_2Mock.mockRejectedValue(error); + + await migrateCommand.handler(options); + + expect(mockExit).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/commands/project/__tests__/migrateApp.test.ts b/commands/project/__tests__/migrateApp.test.ts new file mode 100644 index 000000000..8121f01a1 --- /dev/null +++ b/commands/project/__tests__/migrateApp.test.ts @@ -0,0 +1,106 @@ +import yargs, { Argv, ArgumentsCamelCase } from 'yargs'; +import { i18n } from '../../../lib/lang'; +import { uiCommandReference, uiDeprecatedTag } from '../../../lib/ui'; +import { handlerGenerator } from '../../app/migrate'; +import { PLATFORM_VERSIONS } from '@hubspot/local-dev-lib/constants/projects'; +import { MigrateAppArgs } from '../../../lib/app/migrate'; +import migrateAppCommand from '../migrateApp'; + +jest.mock('yargs'); +jest.mock('@hubspot/local-dev-lib/logger'); +jest.mock('../../../lib/lang'); +jest.mock('../../../lib/ui'); +jest.mock('../../app/migrate'); + +const { v2023_2, v2025_2 } = PLATFORM_VERSIONS; + +describe('commands/project/migrateApp', () => { + const yargsMock = yargs as Argv; + const optionsSpy = jest + .spyOn(yargsMock, 'options') + .mockReturnValue(yargsMock); + const exampleSpy = jest + .spyOn(yargsMock, 'example') + .mockReturnValue(yargsMock); + + // Mock the imported functions + const i18nMock = i18n as jest.Mock; + const uiDeprecatedTagMock = uiDeprecatedTag as jest.Mock; + const uiCommandReferenceMock = uiCommandReference as jest.Mock; + const handlerGeneratorMock = handlerGenerator as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + i18nMock.mockReturnValue('test description'); + uiDeprecatedTagMock.mockReturnValue('deprecated test description'); + uiCommandReferenceMock.mockReturnValue('command reference'); + handlerGeneratorMock.mockReturnValue( + jest.fn().mockResolvedValue(undefined) + ); + }); + + describe('command', () => { + it('should have the correct command structure', () => { + expect(migrateAppCommand.command).toEqual('migrate-app'); + }); + }); + + describe('describe', () => { + it('should provide a description', () => { + expect(migrateAppCommand.describe).toBe(undefined); + }); + + it('should be marked as deprecated', () => { + expect(migrateAppCommand.deprecated).toBe(true); + }); + }); + + describe('builder', () => { + it('should support the correct options', () => { + migrateAppCommand.builder(yargsMock); + + expect(optionsSpy).toHaveBeenCalledWith({ + name: expect.objectContaining({ + describe: expect.any(String), + type: 'string', + }), + dest: expect.objectContaining({ + describe: expect.any(String), + type: 'string', + }), + 'app-id': expect.objectContaining({ + describe: expect.any(String), + type: 'number', + }), + 'platform-version': expect.objectContaining({ + type: 'string', + choices: [v2023_2, v2025_2], + hidden: true, + default: v2023_2, + }), + }); + + expect(exampleSpy).toHaveBeenCalled(); + }); + }); + + describe('handler', () => { + let options: ArgumentsCamelCase; + const mockLocalHandler = jest.fn().mockResolvedValue(undefined); + + beforeEach(() => { + options = { + platformVersion: v2023_2, + } as ArgumentsCamelCase; + + handlerGeneratorMock.mockReturnValue(mockLocalHandler); + }); + + it('should call the local handler with the provided options', async () => { + await migrateAppCommand.handler(options); + + expect(handlerGeneratorMock).toHaveBeenCalledWith('migrate-app'); + expect(mockLocalHandler).toHaveBeenCalledWith(options); + }); + }); +}); diff --git a/commands/project/migrate.ts b/commands/project/migrate.ts index 1150fe0f1..8faa54335 100644 --- a/commands/project/migrate.ts +++ b/commands/project/migrate.ts @@ -22,6 +22,7 @@ export type ProjectMigrateArgs = CommonArgs & EnvironmentArgs & ConfigArgs & { platformVersion: string; + unstable: boolean; }; const { v2025_2 } = PLATFORM_VERSIONS; diff --git a/lib/app/__tests__/migrate.test.ts b/lib/app/__tests__/migrate.test.ts new file mode 100644 index 000000000..59c2dd7e8 --- /dev/null +++ b/lib/app/__tests__/migrate.test.ts @@ -0,0 +1,783 @@ +import { logger } from '@hubspot/local-dev-lib/logger'; +import { getCwd, sanitizeFileName } from '@hubspot/local-dev-lib/path'; +import { extractZipArchive } from '@hubspot/local-dev-lib/archive'; +import { ArgumentsCamelCase } from 'yargs'; +import { validateUid } from '@hubspot/project-parsing-lib'; +import { UNMIGRATABLE_REASONS } from '@hubspot/local-dev-lib/constants/projects'; +import { MIGRATION_STATUS } from '@hubspot/local-dev-lib/types/Migration'; +import { downloadProject } from '@hubspot/local-dev-lib/api/projects'; +import fs from 'fs'; + +import { + confirmPrompt, + inputPrompt, + listPrompt, +} from '../../prompts/promptUtils'; +import { LoadedProjectConfig } from '../../projects/config'; +import { ensureProjectExists } from '../../projects/ensureProjectExists'; +import { poll } from '../../polling'; +import { + CLI_UNMIGRATABLE_REASONS, + continueMigration, + initializeMigration, + listAppsForMigration, + MigrationApp, + MigrationFailed, + MigratableApp, + UnmigratableApp, +} from '../../../api/migrate'; +import { lib } from '../../../lang/en'; + +import { hasFeature } from '../../hasFeature'; + +import { + getUnmigratableReason, + generateFilterAppsByProjectNameFunction, + buildErrorMessageFromMigrationStatus, + fetchMigrationApps, + promptForAppToMigrate, + selectAppToMigrate, + handleMigrationSetup, + beginMigration, + pollMigrationStatus, + finalizeMigration, + downloadProjectFiles, + migrateApp2025_2, + logInvalidAccountError, + MigrateAppArgs, +} from '../migrate'; + +jest.mock('@hubspot/local-dev-lib/logger'); +jest.mock('@hubspot/local-dev-lib/path'); +jest.mock('@hubspot/local-dev-lib/archive'); +jest.mock('@hubspot/project-parsing-lib'); +jest.mock('@hubspot/local-dev-lib/api/projects'); +jest.mock('inquirer'); +jest.mock('../../prompts/promptUtils'); +jest.mock('../../projects/config'); +jest.mock('../../projects/ensureProjectExists'); +jest.mock('../../ui/SpinniesManager'); +jest.mock('../../polling'); +jest.mock('../../../api/migrate'); +jest.mock('../../hasFeature'); +jest.mock('../../projects/urls'); +jest.mock('fs'); + +const mockedLogger = logger as jest.Mocked; +const mockedGetCwd = getCwd as jest.MockedFunction; +const mockedSanitizeFileName = sanitizeFileName as jest.MockedFunction< + typeof sanitizeFileName +>; +const mockedExtractZipArchive = extractZipArchive as jest.MockedFunction< + typeof extractZipArchive +>; +const mockedValidateUid = validateUid as jest.MockedFunction< + typeof validateUid +>; +const mockedDownloadProject = downloadProject as jest.MockedFunction< + typeof downloadProject +>; +const mockedConfirmPrompt = confirmPrompt as jest.MockedFunction< + typeof confirmPrompt +>; +const mockedInputPrompt = inputPrompt as jest.MockedFunction< + typeof inputPrompt +>; +const mockedListPrompt = listPrompt as jest.MockedFunction; +const mockedEnsureProjectExists = ensureProjectExists as jest.MockedFunction< + typeof ensureProjectExists +>; +const mockedPoll = poll as jest.MockedFunction; +const mockedListAppsForMigration = listAppsForMigration as jest.MockedFunction< + typeof listAppsForMigration +>; +const mockedInitializeMigration = initializeMigration as jest.MockedFunction< + typeof initializeMigration +>; +const mockedContinueMigration = continueMigration as jest.MockedFunction< + typeof continueMigration +>; + +const mockedHasFeature = hasFeature as jest.MockedFunction; +const mockedFs = fs as jest.Mocked; + +const createMockMigratableApp = ( + id: number, + name: string, + projectName?: string +): MigratableApp => ({ + appId: id, + appName: name, + isMigratable: true, + migrationComponents: [], + ...(projectName && { projectName }), +}); + +const createMockUnmigratableApp = ( + id: number, + name: string, + reason: string +): UnmigratableApp => ({ + appId: id, + appName: name, + isMigratable: false, + // @ts-expect-error + unmigratableReason: reason, + migrationComponents: [], +}); + +const createLoadedProjectConfig = (name: string): LoadedProjectConfig => + ({ + projectConfig: { name }, + projectDir: MOCK_PROJECT_DIR, + }) as LoadedProjectConfig; + +const ACCOUNT_ID = 123; +const PROJECT_NAME = 'Test Project'; +const APP_ID = 1; +const PLATFORM_VERSION = '2025.2'; +const MIGRATION_ID = 456; +const BUILD_ID = 789; +const PROJECT_DEST = '/mock/dest'; +const MOCK_CWD = '/mock/cwd'; +const MOCK_PROJECT_DIR = '/mock/project/dir'; + +const mockUnmigratableApps: UnmigratableApp[] = [ + createMockUnmigratableApp(3, 'App 3', UNMIGRATABLE_REASONS.UP_TO_DATE), + createMockUnmigratableApp(4, 'App 4', UNMIGRATABLE_REASONS.IS_A_PRIVATE_APP), +]; + +describe('lib/app/migrate', () => { + beforeEach(() => { + mockedGetCwd.mockReturnValue(MOCK_CWD); + mockedSanitizeFileName.mockImplementation(name => name); + mockedValidateUid.mockReturnValue(undefined); + mockedHasFeature.mockResolvedValue(true); + mockedFs.renameSync.mockImplementation(() => {}); + }); + + describe('getUnmigratableReason', () => { + const testCases = [ + { + name: 'UP_TO_DATE', + reason: UNMIGRATABLE_REASONS.UP_TO_DATE, + expected: lib.migrate.errors.unmigratableReasons.upToDate, + }, + { + name: 'IS_A_PRIVATE_APP', + reason: UNMIGRATABLE_REASONS.IS_A_PRIVATE_APP, + expected: lib.migrate.errors.unmigratableReasons.isPrivateApp, + }, + { + name: 'LISTED_IN_MARKETPLACE', + reason: UNMIGRATABLE_REASONS.LISTED_IN_MARKETPLACE, + expected: lib.migrate.errors.unmigratableReasons.listedInMarketplace, + }, + { + name: 'PROJECT_CONNECTED_TO_GITHUB', + reason: UNMIGRATABLE_REASONS.PROJECT_CONNECTED_TO_GITHUB, + expected: + lib.migrate.errors.unmigratableReasons.projectConnectedToGitHub( + PROJECT_NAME, + ACCOUNT_ID + ), + }, + { + name: 'PART_OF_PROJECT_ALREADY', + reason: CLI_UNMIGRATABLE_REASONS.PART_OF_PROJECT_ALREADY, + expected: lib.migrate.errors.unmigratableReasons.partOfProjectAlready, + }, + { + name: 'UNKNOWN_REASON', + reason: 'UNKNOWN_REASON', + expected: + lib.migrate.errors.unmigratableReasons.generic('UNKNOWN_REASON'), + }, + ]; + + testCases.forEach(testCase => { + it(`should return the correct message for ${testCase.name}`, () => { + const result = getUnmigratableReason( + testCase.reason, + PROJECT_NAME, + ACCOUNT_ID + ); + expect(result).toBe(testCase.expected); + }); + }); + }); + + describe('generateFilterAppsByProjectNameFunction', () => { + it('should return a function that filters by project name when projectConfig is provided', () => { + const projectConfig = createLoadedProjectConfig(PROJECT_NAME); + const filterFn = generateFilterAppsByProjectNameFunction(projectConfig); + + const matchingApp = createMockMigratableApp(1, 'App 1', PROJECT_NAME); + const nonMatchingApp = createMockMigratableApp( + 2, + 'App 2', + 'Other Project' + ); + + expect(filterFn(matchingApp)).toBe(true); + expect(filterFn(nonMatchingApp)).toBe(false); + }); + + it('should return a function that always returns true when projectConfig is not provided', () => { + const filterFn = generateFilterAppsByProjectNameFunction(undefined); + + const app1 = createMockMigratableApp(1, 'App 1', PROJECT_NAME); + const app2 = createMockMigratableApp(2, 'App 2', 'Other Project'); + + expect(filterFn(app1)).toBe(true); + expect(filterFn(app2)).toBe(true); + }); + }); + + describe('buildErrorMessageFromMigrationStatus', () => { + it('should return projectErrorDetail when there are no component errors', () => { + const error: MigrationFailed = { + id: 123, + status: MIGRATION_STATUS.FAILURE, + projectErrorDetail: 'Project error', + componentErrors: [], + }; + + const result = buildErrorMessageFromMigrationStatus(error); + expect(result).toBe('Project error'); + }); + + it('should return formatted error message with component errors', () => { + const error: MigrationFailed = { + id: 123, + status: MIGRATION_STATUS.FAILURE, + projectErrorDetail: 'Project error', + componentErrors: [ + { + componentType: 'CARD', + developerSymbol: 'card1', + errorMessage: 'Card error', + }, + { + componentType: 'FUNCTION', + errorMessage: 'Function error', + }, + ], + }; + + const result = buildErrorMessageFromMigrationStatus(error); + expect(result).toBe( + 'Project error: \n\t- CARD (card1): Card error\n\t- FUNCTION: Function error' + ); + }); + }); + + describe('fetchMigrationApps', () => { + const setupMockApps = ( + migratableApps: MigratableApp[] = [], + unmigratableApps: UnmigratableApp[] = [] + ) => { + // @ts-expect-error + mockedListAppsForMigration.mockResolvedValue({ + data: { + migratableApps, + unmigratableApps, + }, + }); + }; + + beforeEach(() => { + setupMockApps( + [ + createMockMigratableApp(1, 'App 1', PROJECT_NAME), + createMockMigratableApp(2, 'App 2', PROJECT_NAME), + ], + [createMockUnmigratableApp(3, 'App 3', UNMIGRATABLE_REASONS.UP_TO_DATE)] + ); + }); + + it('should return all apps when no projectConfig is provided', async () => { + setupMockApps([createMockMigratableApp(1, 'App 1')]); + + const result = await fetchMigrationApps( + undefined, + ACCOUNT_ID, + PLATFORM_VERSION + ); + expect(result).toHaveLength(1); + expect(result[0].appId).toBe(1); + }); + + it('should filter apps by project name when projectConfig is provided', async () => { + const projectConfig = createLoadedProjectConfig(PROJECT_NAME); + setupMockApps([createMockMigratableApp(1, 'App 1', PROJECT_NAME)]); + + const result = await fetchMigrationApps( + undefined, + ACCOUNT_ID, + PLATFORM_VERSION, + projectConfig + ); + expect(result).toHaveLength(1); + expect(result[0].projectName).toBe(PROJECT_NAME); + }); + + it('should throw an error when multiple apps are found for a project', async () => { + const projectConfig = createLoadedProjectConfig(PROJECT_NAME); + setupMockApps([ + createMockMigratableApp(1, 'App 1', PROJECT_NAME), + createMockMigratableApp(2, 'App 2', PROJECT_NAME), + ]); + + await expect( + fetchMigrationApps( + undefined, + ACCOUNT_ID, + PLATFORM_VERSION, + projectConfig + ) + ).rejects.toThrow(lib.migrate.errors.project.multipleApps); + }); + + it('should throw an error when no apps are found for a project', async () => { + const projectConfig = createLoadedProjectConfig(PROJECT_NAME); + setupMockApps([], []); + + await expect( + fetchMigrationApps( + undefined, + ACCOUNT_ID, + PLATFORM_VERSION, + projectConfig + ) + ).rejects.toThrow(lib.migrate.errors.noAppsForProject(PROJECT_NAME)); + }); + + it('should throw an error when no migratable apps are found', async () => { + setupMockApps([], mockUnmigratableApps); + + await expect( + fetchMigrationApps(undefined, ACCOUNT_ID, PLATFORM_VERSION) + ).rejects.toThrow(/No apps in account/); + }); + + it('should throw an error when appId is provided but not found', async () => { + await expect( + fetchMigrationApps(999, ACCOUNT_ID, PLATFORM_VERSION) + ).rejects.toThrow(/No apps in account/); + }); + }); + + describe('promptForAppToMigrate', () => { + const mockApps: MigrationApp[] = [ + createMockMigratableApp(1, 'App 1'), + createMockUnmigratableApp(2, 'App 2', UNMIGRATABLE_REASONS.UP_TO_DATE), + ]; + + beforeEach(() => { + mockedListPrompt.mockResolvedValue(mockApps[0]); + }); + + it('should prompt the user to select an app', async () => { + await promptForAppToMigrate(mockApps, ACCOUNT_ID); + + expect(mockedListPrompt).toHaveBeenCalledWith( + lib.migrate.prompt.chooseApp, + expect.any(Object) + ); + }); + + it('should return the selected app', async () => { + mockedListPrompt.mockResolvedValue({ appId: mockApps[0].appId }); + const result = await promptForAppToMigrate(mockApps, ACCOUNT_ID); + expect(result).toBe(mockApps[0].appId); + }); + }); + + describe('selectAppToMigrate', () => { + const mockApps: MigrationApp[] = [ + { + appId: 1, + appName: 'App 1', + isMigratable: true, + migrationComponents: [ + { id: '1', componentType: 'CARD', isSupported: true }, + { id: '2', componentType: 'FUNCTION', isSupported: false }, + ], + }, + createMockUnmigratableApp(2, 'App 2', UNMIGRATABLE_REASONS.UP_TO_DATE), + ]; + + beforeEach(() => { + mockedListPrompt.mockResolvedValue({ appId: 1 }); + mockedConfirmPrompt.mockResolvedValue(true); + }); + + it('should throw an error when appId is provided but not found', async () => { + await expect( + selectAppToMigrate(mockApps, ACCOUNT_ID, 999) + ).rejects.toThrow(lib.migrate.errors.appWithAppIdNotFound(999)); + }); + + it('should call listPrompt when appId is not provided', async () => { + await selectAppToMigrate(mockApps, ACCOUNT_ID); + + expect(mockedListPrompt).toHaveBeenCalledWith( + lib.migrate.prompt.chooseApp, + expect.any(Object) + ); + }); + + it('should return proceed: false and appIdToMigrate when user cancels', async () => { + mockedConfirmPrompt.mockResolvedValue(false); + const result = await selectAppToMigrate(mockApps, ACCOUNT_ID); + expect(result).toEqual({ proceed: false, appIdToMigrate: 1 }); + }); + + it('should return proceed: true and appIdToMigrate when user confirms', async () => { + const result = await selectAppToMigrate(mockApps, ACCOUNT_ID); + expect(result).toEqual({ proceed: true, appIdToMigrate: 1 }); + }); + }); + + describe('handleMigrationSetup', () => { + const defaultOptions = { + name: PROJECT_NAME, + dest: PROJECT_DEST, + appId: APP_ID, + platformVersion: PLATFORM_VERSION, + unstable: false, + } as ArgumentsCamelCase; + + beforeEach(() => { + setupMockForHandleMigrationSetup(); + }); + + function setupMockForHandleMigrationSetup() { + // @ts-expect-error + mockedListAppsForMigration.mockResolvedValue({ + data: { + migratableApps: [createMockMigratableApp(1, 'App 1')], + unmigratableApps: [], + }, + }); + + mockedListPrompt.mockResolvedValue({ appId: 1 }); + mockedConfirmPrompt.mockResolvedValue(true); + mockedEnsureProjectExists.mockResolvedValue({ projectExists: false }); + } + + it('should return early when user cancels', async () => { + mockedConfirmPrompt.mockResolvedValueOnce(false); + const result = await handleMigrationSetup(ACCOUNT_ID, defaultOptions); + expect(result).toEqual({}); + }); + + it('should return project details when projectConfig is provided', async () => { + const projectConfig = createLoadedProjectConfig(PROJECT_NAME); + + // @ts-expect-error + mockedListAppsForMigration.mockResolvedValueOnce({ + data: { + migratableApps: [createMockMigratableApp(1, 'App 1', PROJECT_NAME)], + unmigratableApps: [], + }, + }); + + const result = await handleMigrationSetup( + ACCOUNT_ID, + defaultOptions, + projectConfig + ); + + expect(result).toEqual({ + appIdToMigrate: 1, + projectName: PROJECT_NAME, + projectDest: MOCK_PROJECT_DIR, + }); + }); + + it('should prompt for project name when not provided', async () => { + const optionsWithoutName = { ...defaultOptions, name: undefined }; + mockedInputPrompt.mockResolvedValue('New Project'); + + await handleMigrationSetup(ACCOUNT_ID, optionsWithoutName); + + expect(mockedInputPrompt).toHaveBeenCalledWith( + lib.migrate.prompt.inputName, + expect.any(Object) + ); + }); + + it('should prompt for project destination when not provided', async () => { + const optionsWithoutDest = { ...defaultOptions, dest: undefined }; + mockedInputPrompt.mockResolvedValue('/mock/new/dest'); + + await handleMigrationSetup(ACCOUNT_ID, optionsWithoutDest); + + expect(mockedInputPrompt).toHaveBeenCalledWith( + lib.migrate.prompt.inputDest, + expect.any(Object) + ); + }); + + it('should throw an error when project already exists', async () => { + mockedEnsureProjectExists.mockResolvedValue({ projectExists: true }); + + await expect( + handleMigrationSetup(ACCOUNT_ID, defaultOptions) + ).rejects.toThrow(lib.migrate.errors.project.alreadyExists(PROJECT_NAME)); + }); + }); + + describe('beginMigration', () => { + beforeEach(() => { + // @ts-expect-error + mockedInitializeMigration.mockResolvedValue({ + data: { migrationId: MIGRATION_ID }, + }); + + mockedPoll.mockResolvedValue({ + status: MIGRATION_STATUS.INPUT_REQUIRED, + // @ts-expect-error + componentsRequiringUids: {}, + }); + + mockedInputPrompt.mockResolvedValue('test-uid'); + }); + + it('should initialize migration and return migrationId and uidMap', async () => { + const result = await beginMigration(ACCOUNT_ID, APP_ID, PLATFORM_VERSION); + + expect(result).toEqual({ + migrationId: MIGRATION_ID, + uidMap: {}, + }); + }); + + it('should prompt for UIDs when components require them', async () => { + const componentHint = 'test-card'; + mockedPoll.mockResolvedValue({ + status: MIGRATION_STATUS.INPUT_REQUIRED, + // @ts-expect-error + componentsRequiringUids: { + '1': { + componentType: 'CARD', + componentHint, + }, + }, + }); + + await beginMigration(ACCOUNT_ID, APP_ID, PLATFORM_VERSION); + + expect(mockedInputPrompt).toHaveBeenCalledWith( + lib.migrate.prompt.uidForComponent("card 'test-card'"), + { + defaultAnswer: componentHint, + validate: expect.any(Function), + } + ); + }); + + it('should throw an error when migration fails', async () => { + mockedPoll.mockRejectedValue(new Error('Failed')); + + await expect( + beginMigration(ACCOUNT_ID, APP_ID, PLATFORM_VERSION) + ).rejects.toThrow(/Migration Failed/); + }); + }); + + describe('pollMigrationStatus', () => { + it('should call poll with checkMigrationStatusV2', async () => { + const mockStatus = { + id: MIGRATION_ID, + status: MIGRATION_STATUS.SUCCESS, + buildId: BUILD_ID, + }; + + mockedPoll.mockResolvedValue(mockStatus); + + const result = await pollMigrationStatus(ACCOUNT_ID, MIGRATION_ID); + + expect(mockedPoll).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Object) + ); + expect(result).toBe(mockStatus); + }); + }); + + describe('finalizeMigration', () => { + const uidMap = { '1': 'test-uid' }; + + beforeEach(() => { + // @ts-expect-error + mockedContinueMigration.mockResolvedValue({ + data: { migrationId: MIGRATION_ID }, + }); + + mockedPoll.mockResolvedValue({ + status: MIGRATION_STATUS.SUCCESS, + // @ts-expect-error + buildId: BUILD_ID, + }); + }); + + it('should continue migration and return buildId', async () => { + const result = await finalizeMigration( + ACCOUNT_ID, + MIGRATION_ID, + uidMap, + PROJECT_NAME + ); + + expect(result).toBe(BUILD_ID); + }); + + it('should throw an error when migration fails', async () => { + mockedPoll.mockRejectedValue(new Error('Test error')); + + await expect( + finalizeMigration(ACCOUNT_ID, MIGRATION_ID, uidMap, PROJECT_NAME) + ).rejects.toThrow(/Migration Failed/); + }); + }); + + describe('downloadProjectFiles', () => { + beforeEach(() => { + // @ts-expect-error + mockedDownloadProject.mockResolvedValue({ + data: Buffer.from('mock-zip-data'), + }); + mockedExtractZipArchive.mockResolvedValue(true); + mockedGetCwd.mockReturnValue(MOCK_CWD); + mockedSanitizeFileName.mockReturnValue(PROJECT_NAME); + }); + + it('should download and extract project files', async () => { + await downloadProjectFiles( + ACCOUNT_ID, + PROJECT_NAME, + BUILD_ID, + PROJECT_DEST + ); + + expect(mockedDownloadProject).toHaveBeenCalledWith( + ACCOUNT_ID, + PROJECT_NAME, + BUILD_ID + ); + + expect(mockedExtractZipArchive).toHaveBeenCalledWith( + expect.any(Buffer), + PROJECT_NAME, + expect.stringContaining(PROJECT_DEST), + { + includesRootDir: true, + hideLogs: true, + } + ); + }); + + it('should handle projectConfig correctly', async () => { + const projectConfig = { + projectConfig: { name: PROJECT_NAME, srcDir: 'src' }, + projectDir: MOCK_PROJECT_DIR, + } as LoadedProjectConfig; + + await downloadProjectFiles( + ACCOUNT_ID, + PROJECT_NAME, + BUILD_ID, + PROJECT_DEST, + projectConfig + ); + + expect(mockedFs.renameSync).toHaveBeenCalledWith( + `${MOCK_PROJECT_DIR}/src`, + `${MOCK_PROJECT_DIR}/archive` + ); + + expect(mockedExtractZipArchive).toHaveBeenCalledWith( + expect.any(Buffer), + PROJECT_NAME, + MOCK_PROJECT_DIR, + { + includesRootDir: true, + hideLogs: true, + } + ); + }); + + it('should throw an error when download fails', async () => { + const error = new Error('Download failed'); + mockedDownloadProject.mockRejectedValue(error); + + await expect( + downloadProjectFiles(ACCOUNT_ID, PROJECT_NAME, BUILD_ID, PROJECT_DEST) + ).rejects.toThrow(error); + }); + }); + + describe('migrateApp2025_2', () => { + const options = { + name: PROJECT_NAME, + dest: PROJECT_DEST, + appId: APP_ID, + platformVersion: PLATFORM_VERSION, + unstable: false, + } as ArgumentsCamelCase; + + beforeEach(() => { + mockedHasFeature.mockResolvedValue(true); + }); + + it('should throw an error when account is not ungated for unified apps', async () => { + mockedHasFeature.mockResolvedValueOnce(false); + + await expect(migrateApp2025_2(ACCOUNT_ID, options)).rejects.toThrowError( + /isn't enrolled in the required product beta to access this command./ + ); + }); + + it('should throw an error when projectConfig is invalid', async () => { + const invalidProjectConfig = { + projectConfig: undefined, + projectDir: '/mock/project/dir', + } as unknown as LoadedProjectConfig; + + await expect( + migrateApp2025_2(ACCOUNT_ID, options, invalidProjectConfig) + ).rejects.toThrow(/The project configuration file is invalid/); + }); + + it('should throw an error when project does not exist', async () => { + const projectConfig = createLoadedProjectConfig(PROJECT_NAME); + + mockedEnsureProjectExists.mockResolvedValueOnce({ + projectExists: false, + }); + + await expect( + migrateApp2025_2(ACCOUNT_ID, options, projectConfig) + ).rejects.toThrow(/Migrations are only supported for existing projects/); + }); + }); + + describe('logInvalidAccountError', () => { + it('should log the invalid account error message', () => { + logInvalidAccountError(); + + expect(mockedLogger.error).toHaveBeenCalledWith( + lib.migrate.errors.invalidAccountTypeTitle + ); + + expect(mockedLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'Only public apps created in a developer account can be converted to a project component' + ) + ); + }); + }); +}); diff --git a/lib/app/__tests__/migrate_legacy.test.ts b/lib/app/__tests__/migrate_legacy.test.ts new file mode 100644 index 000000000..ba22b8805 --- /dev/null +++ b/lib/app/__tests__/migrate_legacy.test.ts @@ -0,0 +1,209 @@ +import { fetchPublicAppMetadata as _fetchPublicAppMetadata } from '@hubspot/local-dev-lib/api/appsDev'; +import { CLIAccount } from '@hubspot/local-dev-lib/types/Accounts'; +import { + downloadProject as _downloadProject, + migrateApp as _migrateNonProjectApp_v2023_2, +} from '@hubspot/local-dev-lib/api/projects'; +import { extractZipArchive } from '@hubspot/local-dev-lib/archive'; +import { ArgumentsCamelCase } from 'yargs'; +import { promptUser as _promptUser } from '../../prompts/promptUtils'; +import { EXIT_CODES } from '../../enums/exitCodes'; +import { + isAppDeveloperAccount as _isAppDeveloperAccount, + isUnifiedAccount as _isUnifiedAccount, +} from '../../accountTypes'; +import { selectPublicAppPrompt as _selectPublicAppPrompt } from '../../prompts/selectPublicAppPrompt'; +import { createProjectPrompt as _createProjectPrompt } from '../../prompts/createProjectPrompt'; +import { ensureProjectExists as _ensureProjectExists } from '../../projects/ensureProjectExists'; +import { poll as _poll } from '../../polling'; +import { migrateApp2023_2 } from '../migrate_legacy'; +import { MigrateAppArgs } from '../migrate'; + +// Mock all external dependencies +jest.mock('@hubspot/local-dev-lib/api/appsDev'); +jest.mock('@hubspot/local-dev-lib/logger'); +jest.mock('@hubspot/local-dev-lib/api/projects'); +jest.mock('@hubspot/local-dev-lib/path'); +jest.mock('@hubspot/local-dev-lib/urls'); +jest.mock('@hubspot/local-dev-lib/archive'); +jest.mock('../../prompts/promptUtils'); +jest.mock('../../errorHandlers'); +jest.mock('../../accountTypes'); +jest.mock('../../prompts/selectPublicAppPrompt'); +jest.mock('../../prompts/createProjectPrompt'); +jest.mock('../../projects/ensureProjectExists'); +jest.mock('../../usageTracking'); +jest.mock('../../ui/SpinniesManager'); +jest.mock('../../process'); +jest.mock('../../polling'); + +const isAppDeveloperAccount = _isAppDeveloperAccount as jest.MockedFunction< + typeof _isAppDeveloperAccount +>; + +const isUnifiedAccount = _isUnifiedAccount as jest.MockedFunction< + typeof _isUnifiedAccount +>; + +const selectPublicAppPrompt = _selectPublicAppPrompt as jest.MockedFunction< + typeof _selectPublicAppPrompt +>; + +const createProjectPrompt = _createProjectPrompt as jest.MockedFunction< + typeof _createProjectPrompt +>; + +const ensureProjectExists = _ensureProjectExists as jest.MockedFunction< + typeof _ensureProjectExists +>; + +const poll = _poll as jest.MockedFunction; +const fetchPublicAppMetadata = _fetchPublicAppMetadata as jest.MockedFunction< + typeof _fetchPublicAppMetadata +>; + +const migrateNonProjectApp_v2023_2 = + _migrateNonProjectApp_v2023_2 as jest.MockedFunction< + typeof _migrateNonProjectApp_v2023_2 + >; + +const downloadProject = _downloadProject as jest.MockedFunction< + typeof _downloadProject +>; +const promptUser = _promptUser as jest.MockedFunction; + +describe('migrateApp2023_2', () => { + const mockDerivedAccountId = 123; + const mockOptions: ArgumentsCamelCase = { + _: [], + $0: 'test', + derivedAccountId: 123, + d: false, + debug: false, + platformVersion: '2023.2', + unstable: false, + }; + const mockAccountConfig: CLIAccount = { + accountId: 123, + name: 'Test Account', + env: 'prod', + }; + const appId = 12345; + const projectName = 'test-project'; + + beforeEach(() => { + // @ts-expect-error function mismatch + jest.spyOn(process, 'exit').mockImplementation(() => {}); + selectPublicAppPrompt.mockResolvedValue({ + appId, + }); + + isAppDeveloperAccount.mockReturnValue(true); + isUnifiedAccount.mockResolvedValue(false); + + fetchPublicAppMetadata.mockResolvedValue({ + // @ts-expect-error Mocking the return type + data: { preventProjectMigrations: false }, + }); + + createProjectPrompt.mockResolvedValue({ + name: projectName, + dest: '/test/dest', + }); + + promptUser.mockResolvedValue({ + shouldCreateApp: true, + }); + + ensureProjectExists.mockResolvedValue({ + projectExists: false, + }); + + migrateNonProjectApp_v2023_2.mockResolvedValue({ + // @ts-expect-error Mocking the return type + data: { id: 'migration-id' }, + }); + + poll.mockResolvedValue({ + status: 'SUCCESS', + // @ts-expect-error + project: { + name: projectName, + }, + }); + + downloadProject.mockResolvedValue({ + // @ts-expect-error Mocking the return type + data: 'zipped-project-data', + }); + }); + + it('should exit if account is not an app developer account and not unified', async () => { + isAppDeveloperAccount.mockReturnValue(false); + + await migrateApp2023_2( + mockDerivedAccountId, + mockOptions, + mockAccountConfig + ); + + expect(migrateNonProjectApp_v2023_2).not.toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.SUCCESS); + }); + + it('should proceed with migration for valid app developer account', async () => { + await migrateApp2023_2( + mockDerivedAccountId, + mockOptions, + mockAccountConfig + ); + + expect(selectPublicAppPrompt).toHaveBeenCalled(); + expect(fetchPublicAppMetadata).toHaveBeenCalledWith( + appId, + mockDerivedAccountId + ); + expect(createProjectPrompt).toHaveBeenCalled(); + expect(ensureProjectExists).toHaveBeenCalled(); + expect(migrateNonProjectApp_v2023_2).toHaveBeenCalled(); + expect(poll).toHaveBeenCalled(); + expect(downloadProject).toHaveBeenCalled(); + expect(extractZipArchive).toHaveBeenCalled(); + }); + + it('should handle migration failure gracefully', async () => { + const errorMessage = 'Migration failed'; + migrateNonProjectApp_v2023_2.mockRejectedValue(new Error(errorMessage)); + + await expect( + migrateApp2023_2(mockDerivedAccountId, mockOptions, mockAccountConfig) + ).rejects.toThrow(errorMessage); + }); + + it('should handle non-migratable apps', async () => { + fetchPublicAppMetadata.mockResolvedValue({ + data: { + preventProjectMigrations: true, + // @ts-expect-error + listingInfo: { someInfo: 'test' }, + }, + }); + + await migrateApp2023_2( + mockDerivedAccountId, + mockOptions, + mockAccountConfig + ); + expect(process.exit).toHaveBeenCalledWith(EXIT_CODES.ERROR); + }); + + it('should handle existing project error', async () => { + ensureProjectExists.mockResolvedValue({ + projectExists: true, + }); + + await expect( + migrateApp2023_2(mockDerivedAccountId, mockOptions, mockAccountConfig) + ).rejects.toThrow('A project with name test-project already exists'); + }); +}); diff --git a/lib/app/migrate.ts b/lib/app/migrate.ts index bad2a0e9a..35d2c0f5e 100644 --- a/lib/app/migrate.ts +++ b/lib/app/migrate.ts @@ -56,9 +56,10 @@ export type MigrateAppArgs = CommonArgs & dest?: string; appId?: number; platformVersion: string; + unstable: boolean; }; -function getUnmigratableReason( +export function getUnmigratableReason( reasonCode: string, projectName: string | undefined, accountId: number @@ -82,7 +83,7 @@ function getUnmigratableReason( } } -function filterAppsByProjectName( +export function generateFilterAppsByProjectNameFunction( projectConfig?: LoadedProjectConfig ): (app: MigrationApp) => boolean { return (app: MigrationApp) => { @@ -93,7 +94,9 @@ function filterAppsByProjectName( }; } -function buildErrorMessageFromMigrationStatus(error: MigrationFailed): string { +export function buildErrorMessageFromMigrationStatus( + error: MigrationFailed +): string { const { componentErrors, projectErrorDetail } = error; if (!componentErrors || !componentErrors.length) { return projectErrorDetail; @@ -111,7 +114,7 @@ function buildErrorMessageFromMigrationStatus(error: MigrationFailed): string { .join('\n\t- ')}`; } -async function fetchMigrationApps( +export async function fetchMigrationApps( appId: MigrateAppArgs['appId'], derivedAccountId: number, platformVersion: string, @@ -122,11 +125,11 @@ async function fetchMigrationApps( } = await listAppsForMigration(derivedAccountId, platformVersion); const filteredMigratableApps = migratableApps.filter( - filterAppsByProjectName(projectConfig) + generateFilterAppsByProjectNameFunction(projectConfig) ); const filteredUnmigratableApps = unmigratableApps.filter( - filterAppsByProjectName(projectConfig) + generateFilterAppsByProjectNameFunction(projectConfig) ); const allApps = [...filteredMigratableApps, ...filteredUnmigratableApps]; @@ -179,7 +182,7 @@ async function fetchMigrationApps( return allApps; } -async function promptForAppToMigrate( +export async function promptForAppToMigrate( allApps: MigrationApp[], derivedAccountId: number ) { @@ -213,7 +216,7 @@ async function promptForAppToMigrate( return selectedAppId; } -async function selectAppToMigrate( +export async function selectAppToMigrate( allApps: MigrationApp[], derivedAccountId: number, appId?: number @@ -272,7 +275,7 @@ async function selectAppToMigrate( }; } -async function handleMigrationSetup( +export async function handleMigrationSetup( derivedAccountId: number, options: ArgumentsCamelCase, projectConfig?: LoadedProjectConfig @@ -350,7 +353,7 @@ async function handleMigrationSetup( return { appIdToMigrate, projectName, projectDest }; } -async function beginMigration( +export async function beginMigration( derivedAccountId: number, appId: number, platformVersion: string @@ -429,7 +432,7 @@ async function beginMigration( return { migrationId, uidMap }; } -async function pollMigrationStatus( +export async function pollMigrationStatus( derivedAccountId: number, migrationId: number, successStates: string[] = [] @@ -440,7 +443,7 @@ async function pollMigrationStatus( }); } -async function finalizeMigration( +export async function finalizeMigration( derivedAccountId: number, migrationId: number, uidMap: Record, @@ -483,7 +486,7 @@ async function finalizeMigration( return pollResponse.buildId; } -async function downloadProjectFiles( +export async function downloadProjectFiles( derivedAccountId: number, projectName: string, buildId: number, diff --git a/lib/app/migrate_legacy.ts b/lib/app/migrate_legacy.ts index 2add73e31..5bf55f548 100644 --- a/lib/app/migrate_legacy.ts +++ b/lib/app/migrate_legacy.ts @@ -37,7 +37,7 @@ export async function migrateApp2023_2( if (!isAppDeveloperAccount(accountConfig) && !defaultAccountIsUnified) { logInvalidAccountError(); - process.exit(EXIT_CODES.SUCCESS); + return process.exit(EXIT_CODES.SUCCESS); } let appId = options.appId; @@ -66,11 +66,11 @@ export async function migrateApp2023_2( appId, }) ); - process.exit(EXIT_CODES.ERROR); + return process.exit(EXIT_CODES.ERROR); } } catch (error) { logError(error, new ApiErrorContext({ accountId: derivedAccountId })); - process.exit(EXIT_CODES.ERROR); + return process.exit(EXIT_CODES.ERROR); } const createProjectPromptResponse = await createProjectPrompt(options); @@ -130,7 +130,7 @@ export async function migrateApp2023_2( process.stdin.resume(); if (!shouldCreateApp) { - process.exit(EXIT_CODES.SUCCESS); + return process.exit(EXIT_CODES.SUCCESS); } try { @@ -148,7 +148,7 @@ export async function migrateApp2023_2( logger.log( i18n(`commands.project.subcommands.migrateApp.migrationInterrupted`) ); - process.exit(EXIT_CODES.SUCCESS); + return process.exit(EXIT_CODES.SUCCESS); } });