diff --git a/lib/__tests__/functions.test.ts b/lib/__tests__/functions.test.ts new file mode 100644 index 0000000..0f95053 --- /dev/null +++ b/lib/__tests__/functions.test.ts @@ -0,0 +1,146 @@ +import fs, { PathLike } from 'fs-extra'; +import findup from 'findup-sync'; +import { getCwd } from '../path'; +import { downloadGithubRepoContents } from '../github'; +import { + createFunction, + isObjectOrFunction, + createEndpoint, + createConfig, +} from '../cms/functions'; + +jest.mock('fs-extra'); +jest.mock('findup-sync'); +jest.mock('../path'); +jest.mock('../github'); + +const mockedGetCwd = getCwd as jest.MockedFunction; +const mockedFindup = findup as jest.MockedFunction; +const mockedFsExistsSync = fs.existsSync as jest.MockedFunction< + typeof fs.existsSync +>; +const mockedFsReadFileSync = fs.readFileSync as jest.MockedFunction< + typeof fs.readFileSync +>; + +describe('lib/cms/functions', () => { + describe('createFunction', () => { + const mockFunctionInfo = { + functionsFolder: 'testFolder', + filename: 'testFunction', + endpointPath: '/api/test', + endpointMethod: 'GET', + }; + const mockDest = '/mock/dest'; + + beforeEach(() => { + mockedGetCwd.mockReturnValue('/mock/cwd'); + mockedFindup.mockReturnValue(null); + + // Set up fs.existsSync to return different values for different paths + mockedFsExistsSync.mockImplementation((path: PathLike) => { + if (path === '/mock/dest/testFolder.functions/testFunction.js') { + return false; + } + if (path === '/mock/dest/testFolder.functions/serverless.json') { + return true; + } + if (path === '/mock/dest/testFolder.functions') { + return true; + } + return false; + }); + + // Mock fs.readFileSync to return a valid JSON for the config file + mockedFsReadFileSync.mockReturnValue( + JSON.stringify({ + endpoints: {}, + }) + ); + }); + + it('should create a new function successfully', async () => { + await createFunction(mockFunctionInfo, mockDest); + + expect(fs.mkdirp).not.toHaveBeenCalled(); + + expect(downloadGithubRepoContents).toHaveBeenCalledWith( + 'HubSpot/cms-sample-assets', + 'functions/sample-function.js', + '/mock/dest/testFolder.functions/testFunction.js' + ); + + // Check that the config file was updated + expect(fs.writeFileSync).toHaveBeenCalledWith( + '/mock/dest/testFolder.functions/serverless.json', + expect.any(String) + ); + }); + }); + + describe('isObjectOrFunction', () => { + it('should return true for objects', () => { + expect(isObjectOrFunction({})).toBe(true); + }); + + it('should return true for functions', () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + expect(isObjectOrFunction(() => {})).toBe(true); + }); + + it('should return false for null', () => { + // @ts-expect-error test case + expect(isObjectOrFunction(null)).toBe(false); + }); + + it('should return false for primitives', () => { + // @ts-expect-error test case + expect(isObjectOrFunction(5)).toBe(false); + // @ts-expect-error test case + expect(isObjectOrFunction('string')).toBe(false); + // @ts-expect-error test case + expect(isObjectOrFunction(true)).toBe(false); + }); + }); + + describe('createEndpoint', () => { + it('should create an endpoint object', () => { + const result = createEndpoint('POST', 'test.js'); + expect(result).toEqual({ + method: 'POST', + file: 'test.js', + }); + }); + + it('should default to GET method if not provided', () => { + const result = createEndpoint('', 'test.js'); + expect(result).toEqual({ + method: 'GET', + file: 'test.js', + }); + }); + }); + + describe('createConfig', () => { + it('should create a config object', () => { + const result = createConfig({ + endpointPath: '/api/test', + endpointMethod: 'POST', + functionFile: 'test.js', + }); + + expect(result).toEqual({ + runtime: 'nodejs18.x', + version: '1.0', + environment: {}, + secrets: [], + endpoints: { + '/api/test': { + method: 'POST', + file: 'test.js', + }, + }, + }); + }); + }); +}); diff --git a/lib/__tests__/themes.test.ts b/lib/__tests__/themes.test.ts new file mode 100644 index 0000000..391d536 --- /dev/null +++ b/lib/__tests__/themes.test.ts @@ -0,0 +1,88 @@ +import findup from 'findup-sync'; +import { getHubSpotWebsiteOrigin } from '../urls'; +import { getThemeJSONPath, getThemePreviewUrl } from '../cms/themes'; +import { getEnv } from '../../config'; +import { ENVIRONMENTS } from '../../constants/environments'; + +jest.mock('findup-sync'); +jest.mock('../urls'); +jest.mock('../../config'); +jest.mock('../../constants/environments', () => ({ + ENVIRONMENTS: { + PROD: 'https://prod.hubspot.com', + QA: 'https://qa.hubspot.com', + }, +})); + +const mockedFindup = findup as jest.MockedFunction; +const mockedGetEnv = getEnv as jest.MockedFunction; +const mockedGetHubSpotWebsiteOrigin = + getHubSpotWebsiteOrigin as jest.MockedFunction< + typeof getHubSpotWebsiteOrigin + >; + +describe('lib/cms/themes', () => { + describe('getThemeJSONPath', () => { + it('should return the theme.json path if found', () => { + mockedFindup.mockReturnValue('/path/to/theme.json'); + + const result = getThemeJSONPath('/some/path'); + + expect(findup).toHaveBeenCalledWith('theme.json', { + cwd: '/some/path', + nocase: true, + }); + expect(result).toBe('/path/to/theme.json'); + }); + + it('should return null if theme.json is not found', () => { + mockedFindup.mockReturnValue(null); + + const result = getThemeJSONPath('/some/path'); + + expect(findup).toHaveBeenCalledWith('theme.json', { + cwd: '/some/path', + nocase: true, + }); + expect(result).toBeNull(); + }); + }); + + describe('getThemePreviewUrl', () => { + it('should return the correct theme preview URL for PROD environment', () => { + mockedFindup.mockReturnValue('/src/my-theme/theme.json'); + mockedGetEnv.mockReturnValue('prod'); + mockedGetHubSpotWebsiteOrigin.mockReturnValue('https://prod.hubspot.com'); + + const result = getThemePreviewUrl('/path/to/file', 12345); + + expect(getEnv).toHaveBeenCalledWith(12345); + expect(getHubSpotWebsiteOrigin).toHaveBeenCalledWith(ENVIRONMENTS.PROD); + expect(result).toBe( + 'https://prod.hubspot.com/theme-previewer/12345/edit/my-theme' + ); + }); + + it('should return the correct theme preview URL for QA environment', () => { + mockedFindup.mockReturnValue('/src/my-theme/theme.json'); + mockedGetEnv.mockReturnValue('qa'); + mockedGetHubSpotWebsiteOrigin.mockReturnValue('https://qa.hubspot.com'); + + const result = getThemePreviewUrl('/path/to/file', 12345); + + expect(getEnv).toHaveBeenCalledWith(12345); + expect(getHubSpotWebsiteOrigin).toHaveBeenCalledWith(ENVIRONMENTS.QA); + expect(result).toBe( + 'https://qa.hubspot.com/theme-previewer/12345/edit/my-theme' + ); + }); + + it('should return undefined if theme.json is not found', () => { + mockedFindup.mockReturnValue(null); + + const result = getThemePreviewUrl('/invalid/path', 12345); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/lib/__tests__/validate.test.ts b/lib/__tests__/validate.test.ts new file mode 100644 index 0000000..7cd9816 --- /dev/null +++ b/lib/__tests__/validate.test.ts @@ -0,0 +1,115 @@ +import fs, { Stats } from 'fs-extra'; +import { validateHubl } from '../../api/validateHubl'; +import { walk } from '../fs'; +import { lint } from '../cms/validate'; +import { LintResult, Validation } from '../../types/HublValidation'; +import { AxiosPromise } from 'axios'; + +jest.mock('fs-extra'); +jest.mock('../../api/validateHubl'); +jest.mock('../fs'); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockedFsStat = fs.stat as jest.MockedFunction; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockedFsReadFile = fs.readFile as jest.MockedFunction; +const mockedWalk = walk as jest.MockedFunction; +const mockedValidateHubl = validateHubl as jest.MockedFunction< + typeof validateHubl +>; + +const mockFsStats = jest.createMockFromModule('fs-extra'); + +mockFsStats.isDirectory = jest.fn(() => true); + +const mockValidation: Validation = { + meta: { + all_widgets: [], + widgets_in_rich_text: [], + editable_flex_areas: [], + editable_layout_sections: null, + email_style_settings: null, + sms_flex_area: [], + google_font_variations: null, + custom_font_variations: [], + has_style_tag: false, + has_header_tag: false, + output_html: '', + has_menu_tag: false, + has_theme_setting_function: false, + template_source: '', + attribute_defaults: null, + template_errors: [], + path_dependencies: [], + theme_field_dependencies: [], + template_type_ids: null, + exact_path_references: [], + required_scopes_to_render: [], + }, + renderingErrors: [], + html: '', +}; + +describe('lib/cms/validate', () => { + const accountId = 123; + const filePath = 'test.html'; + + it('should return an empty array if directory has no files', async () => { + mockedFsStat.mockResolvedValue(mockFsStats); + mockedWalk.mockResolvedValue([]); + + const result = await lint(accountId, filePath); + expect(result).toEqual([]); + }); + + it('should return the correct object if a file has no content', async () => { + mockedFsStat.mockResolvedValue({ isDirectory: () => false }); + mockedFsReadFile.mockResolvedValue(' '); + + const result = await lint(accountId, filePath); + expect(result).toEqual([{ file: filePath, validation: null }]); + }); + + it('should call validateHubl with the correct parameters', async () => { + const mockSource = 'valid HUBL content'; + mockedFsStat.mockResolvedValue({ isDirectory: () => false }); + mockedFsReadFile.mockResolvedValue(mockSource); + mockedValidateHubl.mockResolvedValue({ + data: mockValidation, + } as unknown as AxiosPromise); + const result = await lint(accountId, filePath); + expect(validateHubl).toHaveBeenCalledWith(accountId, mockSource); + expect(result).toEqual([{ file: filePath, validation: mockValidation }]); + }); + + it('should filter out files with invalid extensions', async () => { + const invalidFile = 'test.txt'; + mockedFsStat.mockResolvedValue({ isDirectory: () => true }); + mockedWalk.mockResolvedValue([invalidFile, filePath]); + mockedFsReadFile.mockResolvedValue('valid HUBL content'); + mockedValidateHubl.mockResolvedValue({ + data: mockValidation, + } as unknown as AxiosPromise); + + const result = await lint(accountId, filePath); + + expect(result).toHaveLength(1); + expect((result as Partial[])[0].file).toBe(filePath); + }); + + it('should execute callback if provided', async () => { + const mockCallback = jest.fn(); + const mockSource = 'valid HUBL content'; + mockedFsStat.mockResolvedValue({ isDirectory: () => false }); + mockedFsReadFile.mockResolvedValue(mockSource); + mockedValidateHubl.mockResolvedValue({ + data: mockValidation, + } as unknown as AxiosPromise); + + await lint(accountId, filePath, mockCallback); + expect(mockCallback).toHaveBeenCalledWith({ + file: filePath, + validation: mockValidation, + }); + }); +}); diff --git a/lib/__tests__/watch.test.ts b/lib/__tests__/watch.test.ts new file mode 100644 index 0000000..c0f23c5 --- /dev/null +++ b/lib/__tests__/watch.test.ts @@ -0,0 +1,144 @@ +import chokidar from 'chokidar'; +import PQueue from 'p-queue'; + +import { uploadFolder } from '../cms/uploadFolder'; +import { watch } from '../cms/watch'; +import { MODE } from '../../constants/files'; + +jest.mock('chokidar'); +jest.mock('axios'); +jest.mock('p-queue'); +jest.mock('../cms/uploadFolder'); + +describe('lib/cms/watch', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let chokidarMock: any; + let pQueueAddMock: jest.Mock; + + beforeEach(() => { + chokidarMock = { + watch: jest.fn().mockReturnThis(), + on: jest.fn().mockReturnThis(), + }; + (chokidar.watch as jest.Mock).mockReturnValue(chokidarMock); + + pQueueAddMock = jest.fn(); + + // @ts-expect-error test case + PQueue.mockImplementation(() => ({ + add: pQueueAddMock, + size: 0, + })); + }); + + it('should call chokidar.watch with correct arguments', () => { + const accountId = 123; + const src = 'src-folder'; + const dest = 'dest-folder'; + const options = { + mode: MODE.draft, + remove: false, + disableInitial: true, + notify: '', + commandOptions: {}, + filePaths: [], + }; + + watch(accountId, src, dest, options); + + expect(chokidar.watch).toHaveBeenCalledWith(src, { + ignoreInitial: true, + ignored: expect.any(Function), + }); + }); + + it('should trigger folder upload on initialization', async () => { + const accountId = 123; + const src = 'src-folder'; + const dest = 'dest-folder'; + const options = { + mode: MODE.draft, + remove: false, + disableInitial: false, + notify: '', + commandOptions: {}, + filePaths: [], + }; + const postInitialUploadCallback = jest.fn(); + + (uploadFolder as jest.Mock).mockResolvedValueOnce([]); + + await watch(accountId, src, dest, options, postInitialUploadCallback); + + expect(uploadFolder).toHaveBeenCalledWith( + accountId, + src, + dest, + {}, + options.commandOptions, + options.filePaths, + options.mode + ); + expect(postInitialUploadCallback).toHaveBeenCalled(); + }); + + it('should upload file when file is added', () => { + const accountId = 123; + const src = 'src-folder'; + const dest = 'dest-folder'; + const options = { + mode: MODE.draft, + remove: false, + disableInitial: true, + notify: '', + commandOptions: {}, + filePaths: [], + }; + + watch(accountId, src, dest, options); + + expect(chokidarMock.on).toHaveBeenCalledWith('add', expect.any(Function)); + }); + + it('should handle file change event and upload file', () => { + const accountId = 123; + const src = 'src-folder'; + const dest = 'dest-folder'; + const options = { + mode: MODE.draft, + remove: false, + disableInitial: true, + notify: '', + commandOptions: {}, + filePaths: [], + }; + + watch(accountId, src, dest, options); + + expect(chokidarMock.on).toHaveBeenCalledWith( + 'change', + expect.any(Function) + ); + }); + + it('should handle file delete event', () => { + const accountId = 123; + const src = 'src-folder'; + const dest = 'dest-folder'; + const options = { + mode: MODE.draft, + remove: true, + disableInitial: true, + notify: '', + commandOptions: {}, + filePaths: [], + }; + + watch(accountId, src, dest, options); + + expect(chokidarMock.on).toHaveBeenCalledWith( + 'unlink', + expect.any(Function) + ); + }); +}); diff --git a/lib/cms/functions.ts b/lib/cms/functions.ts index 2222e8d..49a2285 100644 --- a/lib/cms/functions.ts +++ b/lib/cms/functions.ts @@ -15,12 +15,12 @@ import { } from '../../types/Functions'; const i18nKey = 'lib.cms.functions'; -function isObjectOrFunction(value: object): boolean { +export function isObjectOrFunction(value: object): boolean { const type = typeof value; return value != null && (type === 'object' || type === 'function'); } -function createEndpoint( +export function createEndpoint( endpointMethod: string, filename: string ): { method: string; file: string } { @@ -30,7 +30,7 @@ function createEndpoint( }; } -function createConfig({ +export function createConfig({ endpointPath, endpointMethod, functionFile,