Skip to content

Commit

Permalink
Merge main
Browse files Browse the repository at this point in the history
  • Loading branch information
kemmerle committed Jan 30, 2025
2 parents d152eb4 + d70603e commit ab16eff
Show file tree
Hide file tree
Showing 17 changed files with 327 additions and 12,069 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ coverage
.env
dist
.node-version
yarn.lock

# Acceptance Tests
acceptance-tests/my-project
Expand Down
1 change: 1 addition & 0 deletions .yarnrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
--install.no-lockfile true
11 changes: 9 additions & 2 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,15 @@ const loadConfigMiddleware = async options => {
})
);
process.exit(EXIT_CODES.ERROR);
} else if (!options._.includes('init')) {
} else if (!isTargetedCommand(options, { init: { target: true } })) {
const { config: configPath } = options;
loadConfig(configPath, options);
const config = loadConfig(configPath, options);

// We don't run validateConfig() for auth because users should be able to run it when
// no accounts are configured, but we still want to exit if the config file is not found
if (isTargetedCommand(options, { auth: { target: true } }) && !config) {
process.exit(EXIT_CODES.ERROR);
}
}

maybeValidateConfig();
Expand All @@ -277,6 +283,7 @@ const accountsSubCommands = {
remove: { target: true },
},
};

const sandboxesSubCommands = {
target: false,
subCommands: {
Expand Down
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
2 changes: 1 addition & 1 deletion commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ exports.describe = i18n(`${i18nKey}.describe`, {

exports.handler = async options => {
const {
auth: authTypeFlagValue,
authType: authTypeFlagValue,
c: configFlagValue,
providedAccountId,
disableTracking,
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 @@ -1129,7 +1129,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 @@ -1145,6 +1144,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
Loading

0 comments on commit ab16eff

Please sign in to comment.