Skip to content

Commit

Permalink
Merge branch 'main' of github.com:HubSpot/hubspot-cli into jy/pin-ver…
Browse files Browse the repository at this point in the history
…sions-nuke-lockfiles
  • Loading branch information
joe-yeager committed Jan 29, 2025
2 parents a93366c + 81229c1 commit 2da1d56
Show file tree
Hide file tree
Showing 11 changed files with 297 additions and 37 deletions.
4 changes: 3 additions & 1 deletion commands/function/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ exports.handler = async options => {
derivedAccountId,
functionPath
);
const successResp = await poll(getBuildStatus, derivedAccountId, buildId);
const successResp = await poll(() =>
getBuildStatus(derivedAccountId, buildId)
);
const buildTimeSeconds = (successResp.buildTime / 1000).toFixed(2);

SpinniesManager.succeed('loading');
Expand Down
4 changes: 3 additions & 1 deletion commands/project/cloneApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ exports.handler = async options => {
const {
data: { exportId },
} = await cloneApp(derivedAccountId, appId);
const { status } = await poll(checkCloneStatus, derivedAccountId, exportId);
const { status } = await poll(() =>
checkCloneStatus(derivedAccountId, exportId)
);
if (status === 'SUCCESS') {
// Ensure correct project folder structure exists
const baseDestPath = path.resolve(getCwd(), projectDest);
Expand Down
4 changes: 3 additions & 1 deletion commands/project/migrateApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,9 @@ exports.handler = async options => {
projectName
);
const { id } = migrateResponse;
const pollResponse = await poll(checkMigrationStatus, derivedAccountId, id);
const pollResponse = await poll(() =>
checkMigrationStatus(derivedAccountId, id)
);
const { status, project } = pollResponse;
if (status === 'SUCCESS') {
const absoluteDestPath = path.resolve(getCwd(), projectDest);
Expand Down
2 changes: 1 addition & 1 deletion lang/en.lyaml
Original file line number Diff line number Diff line change
Expand Up @@ -1113,7 +1113,6 @@ en:
notFound: "Your project {{#bold}}{{ projectName }}{{/bold}} could not be found in {{#bold}}{{ accountIdentifier }}{{/bold}}."
pollFetchProject:
checkingProject: "Checking if project exists in {{ accountIdentifier }}"
unableToFindAutodeployStatus: "Unable to find the auto deploy for build #{{ buildId }}. This deploy may have been skipped. {{ viewDeploysLink }}."
logFeedbackMessage:
feedbackHeader: "We'd love to hear your feedback!"
feedbackMessage: "How are you liking the new projects and developer tools? \n > Run `{{#yellow}}hs feedback{{/yellow}}` to let us know what you think!\n"
Expand All @@ -1129,6 +1128,7 @@ en:
buildSucceededAutomaticallyDeploying: "Build #{{ buildId }} succeeded. {{#bold}}Automatically deploying{{/bold}} to {{ accountIdentifier }}\n"
cleanedUpTempFile: "Cleaned up temporary file {{ path }}"
viewDeploys: "View all deploys for this project in HubSpot"
unableToFindAutodeployStatus: "Unable to find the auto deploy for build #{{ buildId }}. This deploy may have been skipped. {{ viewDeploysLink }}."
projectUpload:
uploadProjectFiles:
add: "Uploading {{#bold}}{{ projectName }}{{/bold}} project files to {{ accountIdentifier }}"
Expand Down
142 changes: 142 additions & 0 deletions lib/__tests__/oauth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import express, { Request, Response } from 'express';
import open from 'open';
import { OAuth2Manager } from '@hubspot/local-dev-lib/models/OAuth2Manager';
import { getAccountConfig } from '@hubspot/local-dev-lib/config';
import { addOauthToAccountConfig } from '@hubspot/local-dev-lib/oauth';
import { logger } from '@hubspot/local-dev-lib/logger';
import { ENVIRONMENTS } from '@hubspot/local-dev-lib/constants/environments';
import { DEFAULT_OAUTH_SCOPES } from '@hubspot/local-dev-lib/constants/auth';
import { authenticateWithOauth } from '../oauth';

jest.mock('express');
jest.mock('open');
jest.mock('@hubspot/local-dev-lib/models/OAuth2Manager');
jest.mock('@hubspot/local-dev-lib/config');
jest.mock('@hubspot/local-dev-lib/oauth');
jest.mock('@hubspot/local-dev-lib/logger');

const mockedExpress = express as unknown as jest.Mock;
const mockedOAuth2Manager = OAuth2Manager as unknown as jest.Mock;
const mockedGetAccountConfig = getAccountConfig as jest.Mock;

describe('lib/oauth', () => {
const mockExpressReq = {
query: { code: 'test-auth-code' },
} as unknown as Request;
const mockExpressResp = { send: jest.fn() } as unknown as Response;

const mockAccountConfig = {
accountId: 123,
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
scopes: ['test-scope'],
env: ENVIRONMENTS.PROD,
};

beforeEach(() => {
mockedExpress.mockReturnValue({
get: jest.fn().mockImplementation((path, callback) => {
if (path === '/oauth-callback') {
callback(mockExpressReq, mockExpressResp);
}
}),
listen: jest.fn().mockReturnValue({ close: jest.fn() }),
});
});

afterEach(() => {
jest.clearAllMocks();
});

describe('authenticateWithOauth()', () => {
it('should setup OAuth and authenticate successfully', async () => {
// Mock successful OAuth flow
const mockOAuth2Manager = {
account: mockAccountConfig,
exchangeForTokens: jest.fn().mockResolvedValue({}),
};

mockedOAuth2Manager.mockImplementation(() => mockOAuth2Manager);
mockedGetAccountConfig.mockReturnValue({
env: ENVIRONMENTS.PROD,
});

await authenticateWithOauth(mockAccountConfig);

// Verify OAuth2Manager was initialized correctly
expect(mockedOAuth2Manager).toHaveBeenCalledWith({
...mockAccountConfig,
env: ENVIRONMENTS.PROD,
});

// Verify logger was called
expect(logger.log).toHaveBeenCalledWith('Authorizing');

// Verify OAuth tokens were added to config
expect(addOauthToAccountConfig).toHaveBeenCalledWith(mockOAuth2Manager);
});

it('should handle missing clientId', async () => {
const invalidConfig = {
...mockAccountConfig,
clientId: undefined,
};

mockedOAuth2Manager.mockImplementation(() => ({
account: invalidConfig,
}));

const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('exit');
});

await expect(authenticateWithOauth(invalidConfig)).rejects.toThrow(
'exit'
);
expect(logger.error).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalled();
exitSpy.mockRestore();
});

it('should use default scopes when none provided', async () => {
const configWithoutScopes = {
...mockAccountConfig,
scopes: undefined,
};

const mockOAuth2Manager = {
account: configWithoutScopes,
exchangeForTokens: jest.fn().mockResolvedValue({}),
};

mockedOAuth2Manager.mockImplementation(() => mockOAuth2Manager);

await authenticateWithOauth(configWithoutScopes);

// Verify default scopes were used
expect(open).toHaveBeenCalledWith(
expect.stringContaining(
encodeURIComponent(DEFAULT_OAUTH_SCOPES.join(' '))
),
expect.anything()
);
});

it('should handle OAuth exchange failure', async () => {
const mockOAuth2Manager = {
account: mockAccountConfig,
exchangeForTokens: jest
.fn()
.mockRejectedValue(new Error('Exchange failed')),
};

mockedOAuth2Manager.mockImplementation(() => mockOAuth2Manager);

await authenticateWithOauth(mockAccountConfig);

expect(mockExpressResp.send).toHaveBeenCalledWith(
expect.stringContaining('Authorization failed')
);
});
});
});
107 changes: 107 additions & 0 deletions lib/__tests__/polling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { poll, DEFAULT_POLLING_STATES } from '../polling';
import { DEFAULT_POLLING_DELAY } from '../constants';
import { HubSpotPromise } from '@hubspot/local-dev-lib/types/Http';

// Mock response types
type MockResponse = {
status: string;
};

// Helper to create a mock polling callback
const createMockCallback = (responses: MockResponse[]) => {
let callCount = 0;
return jest.fn((): HubSpotPromise<{ status: string }> => {
const response = responses[callCount];
callCount++;
return Promise.resolve({ data: response }) as HubSpotPromise<{
status: string;
}>;
});
};

describe('lib/polling', () => {
beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

describe('poll()', () => {
it('should resolve when status is SUCCESS', async () => {
const mockCallback = createMockCallback([
{ status: DEFAULT_POLLING_STATES.STARTED },
{ status: DEFAULT_POLLING_STATES.SUCCESS },
]);

const pollPromise = poll(mockCallback);

// Fast-forward through two polling intervals
jest.advanceTimersByTime(DEFAULT_POLLING_DELAY * 2);

const result = await pollPromise;
expect(result.status).toBe(DEFAULT_POLLING_STATES.SUCCESS);
expect(mockCallback).toHaveBeenCalledTimes(2);
});

it('should reject when status is ERROR', async () => {
const mockCallback = createMockCallback([
{ status: DEFAULT_POLLING_STATES.STARTED },
{ status: DEFAULT_POLLING_STATES.ERROR },
]);

const pollPromise = poll(mockCallback);

jest.advanceTimersByTime(DEFAULT_POLLING_DELAY * 2);

await expect(pollPromise).rejects.toEqual({
status: DEFAULT_POLLING_STATES.ERROR,
});
expect(mockCallback).toHaveBeenCalledTimes(2);
});

it('should reject when status is FAILURE', async () => {
const mockCallback = createMockCallback([
{ status: DEFAULT_POLLING_STATES.STARTED },
{ status: DEFAULT_POLLING_STATES.FAILURE },
]);

const pollPromise = poll(mockCallback);

jest.advanceTimersByTime(DEFAULT_POLLING_DELAY * 2);

await expect(pollPromise).rejects.toEqual({
status: DEFAULT_POLLING_STATES.FAILURE,
});
});

it('should reject when status is REVERTED', async () => {
const mockCallback = createMockCallback([
{ status: DEFAULT_POLLING_STATES.STARTED },
{ status: DEFAULT_POLLING_STATES.REVERTED },
]);

const pollPromise = poll(mockCallback);

jest.advanceTimersByTime(DEFAULT_POLLING_DELAY * 2);

await expect(pollPromise).rejects.toEqual({
status: DEFAULT_POLLING_STATES.REVERTED,
});
});

it('should reject when callback throws an error', async () => {
const mockCallback = jest
.fn()
.mockRejectedValue(new Error('Network error'));

const pollPromise = poll(mockCallback);

jest.advanceTimersByTime(DEFAULT_POLLING_DELAY);

await expect(pollPromise).rejects.toThrow('Network error');
expect(mockCallback).toHaveBeenCalledTimes(1);
});
});
});
9 changes: 1 addition & 8 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,7 @@ export const CONFIG_FLAGS = {
USE_CUSTOM_OBJECT_HUBFILE: 'useCustomObjectHubfile',
} as const;

export const POLLING_DELAY = 2000;

export const POLLING_STATUS = {
SUCCESS: 'SUCCESS',
ERROR: 'ERROR',
REVERTED: 'REVERTED',
FAILURE: 'FAILURE',
} as const;
export const DEFAULT_POLLING_DELAY = 2000;

export const PROJECT_CONFIG_FILE = 'hsproject.json' as const;

Expand Down
46 changes: 29 additions & 17 deletions lib/polling.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,54 @@
import { HubSpotPromise } from '@hubspot/local-dev-lib/types/Http';
import { ValueOf } from '@hubspot/local-dev-lib/types/Utils';
import { POLLING_DELAY, POLLING_STATUS } from './constants';
import { DEFAULT_POLLING_DELAY } from './constants';

export const DEFAULT_POLLING_STATES = {
STARTED: 'STARTED',
SUCCESS: 'SUCCESS',
ERROR: 'ERROR',
REVERTED: 'REVERTED',
FAILURE: 'FAILURE',
} as const;

const DEFAULT_POLLING_STATUS_LOOKUP = {
successStates: [DEFAULT_POLLING_STATES.SUCCESS],
errorStates: [
DEFAULT_POLLING_STATES.ERROR,
DEFAULT_POLLING_STATES.REVERTED,
DEFAULT_POLLING_STATES.FAILURE,
],
};

type GenericPollingResponse = {
status: ValueOf<typeof POLLING_STATUS>;
status: string;
};

type PollingCallback<T extends GenericPollingResponse> = (
accountId: number,
taskId: number | string
) => HubSpotPromise<T>;
type PollingCallback<T extends GenericPollingResponse> =
() => HubSpotPromise<T>;

export function poll<T extends GenericPollingResponse>(
callback: PollingCallback<T>,
accountId: number,
taskId: number | string
statusLookup: {
successStates: string[];
errorStates: string[];
} = DEFAULT_POLLING_STATUS_LOOKUP
): Promise<T> {
return new Promise((resolve, reject) => {
const pollInterval = setInterval(async () => {
try {
const { data: pollResp } = await callback(accountId, taskId);
const { data: pollResp } = await callback();
const { status } = pollResp;

if (status === POLLING_STATUS.SUCCESS) {
if (statusLookup.successStates.includes(status)) {
clearInterval(pollInterval);
resolve(pollResp);
} else if (
status === POLLING_STATUS.ERROR ||
status === POLLING_STATUS.REVERTED ||
status === POLLING_STATUS.FAILURE
) {
} else if (statusLookup.errorStates.includes(status)) {
clearInterval(pollInterval);
reject(pollResp);
}
} catch (error) {
clearInterval(pollInterval);
reject(error);
}
}, POLLING_DELAY);
}, DEFAULT_POLLING_DELAY);
});
}
Loading

0 comments on commit 2da1d56

Please sign in to comment.