Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- Never commit or push to main branch without permission
- Skipping tests is never a valid solution to fixing them
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😆

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haha yeah, Claude had some creative ideas about how to fix the tests

49 changes: 49 additions & 0 deletions api/__tests__/migrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
checkMigrationStatusV2,
ListAppsResponse,
MigrationStatus,
isMigrationStatus,
} from '../migrate';

jest.mock('@hubspot/local-dev-lib/http');
Expand Down Expand Up @@ -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);
});
});
31 changes: 25 additions & 6 deletions commands/app/__tests__/migrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MigrateAppArgs>);
} as ArgumentsCamelCase<MigrateAppArgs>;

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<MigrateAppArgs>;

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<MigrateAppArgs>);
} as ArgumentsCamelCase<MigrateAppArgs>;

await migrateCommand.handler(options);

expect(mockedMigrateApp2023_2).toHaveBeenCalledWith(
mockAccountId,
expect.any(Object),
options,
mockAccountConfig
);
expect(mockedMigrateApp2025_2).not.toHaveBeenCalled();
Expand Down
140 changes: 140 additions & 0 deletions commands/project/__tests__/migrate.test.ts
Original file line number Diff line number Diff line change
@@ -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<ProjectMigrateArgs>;
let mockExit: jest.SpyInstance;

beforeEach(() => {
mockExit = jest.spyOn(process, 'exit').mockImplementation();
options = {
platformVersion: v2025_2,
unstable: false,
derivedAccountId: 123,
} as ArgumentsCamelCase<ProjectMigrateArgs>;
});

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);
});
});
});
106 changes: 106 additions & 0 deletions commands/project/__tests__/migrateApp.test.ts
Original file line number Diff line number Diff line change
@@ -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<MigrateAppArgs>;
const mockLocalHandler = jest.fn().mockResolvedValue(undefined);

beforeEach(() => {
options = {
platformVersion: v2023_2,
} as ArgumentsCamelCase<MigrateAppArgs>;

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);
});
});
});
1 change: 1 addition & 0 deletions commands/project/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type ProjectMigrateArgs = CommonArgs &
EnvironmentArgs &
ConfigArgs & {
platformVersion: string;
unstable: boolean;
};

const { v2025_2 } = PLATFORM_VERSIONS;
Expand Down
Loading