Skip to content

Commit

Permalink
Merge pull request #68 from HubSpot/br-unit-tests-12
Browse files Browse the repository at this point in the history
Separating github api calls into new file and adding some tests
  • Loading branch information
brandenrodgers authored Dec 8, 2023
2 parents c0c2d8c + 7be2ecd commit 3bb2307
Show file tree
Hide file tree
Showing 6 changed files with 226 additions and 58 deletions.
84 changes: 84 additions & 0 deletions api/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import axios, { AxiosResponse } from 'axios';
import { DEFAULT_USER_AGENT_HEADERS } from '../http/getAxiosConfig';
import { GithubReleaseData, GithubRepoFile } from '../types/Github';

const GITHUB_REPOS_API = 'https://api.github.com/repos';
const GITHUB_RAW_CONTENT_API_PATH = 'https://raw.githubusercontent.com';

declare global {
// eslint-disable-next-line no-var
var githubToken: string;
}

type RepoPath = `${string}/${string}`;

const GITHUB_AUTH_HEADERS = {
authorization:
global && global.githubToken ? `Bearer ${global.githubToken}` : null,
};

// Returns information about the repo's releases. Defaults to "latest" if no tag is provided
// https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-a-release-by-tag-name
export async function fetchRepoReleaseData(
repoPath: RepoPath,
tag = ''
): Promise<AxiosResponse<GithubReleaseData>> {
const URL = `${GITHUB_REPOS_API}/${repoPath}/releases`;

return axios.get<GithubReleaseData>(
`${URL}/${tag ? `tags/${tag}` : 'latest'}`,
{
headers: { ...DEFAULT_USER_AGENT_HEADERS, ...GITHUB_AUTH_HEADERS },
}
);
}

// Returns the entire repo content as a zip, using the zipball_url from fetchRepoReleaseData()
// https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28#download-a-repository-archive-zip
export async function fetchRepoAsZip(
zipUrl: string
): Promise<AxiosResponse<Buffer>> {
return axios.get<Buffer>(zipUrl, {
headers: { ...DEFAULT_USER_AGENT_HEADERS, ...GITHUB_AUTH_HEADERS },
});
}

// Returns the raw file contents via the raw.githubusercontent endpoint
export async function fetchRepoFile(
repoPath: RepoPath,
filePath: string,
ref: string
): Promise<AxiosResponse<Buffer>> {
return axios.get<Buffer>(
`${GITHUB_RAW_CONTENT_API_PATH}/${repoPath}/${ref}/${filePath}`,
{
headers: { ...DEFAULT_USER_AGENT_HEADERS, ...GITHUB_AUTH_HEADERS },
}
);
}

// Returns the raw file contents via the raw.githubusercontent endpoint
export async function fetchRepoFileByDownloadUrl(
downloadUrl: string
): Promise<AxiosResponse<Buffer>> {
return axios.get<Buffer>(downloadUrl, {
headers: { ...DEFAULT_USER_AGENT_HEADERS, ...GITHUB_AUTH_HEADERS },
});
}

// Returns the contents of a file or directory in a repository by path
// https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28#get-repository-content
export async function fetchRepoContents(
repoPath: RepoPath,
path: string,
ref?: string
): Promise<AxiosResponse<Array<GithubRepoFile>>> {
const refQuery = ref ? `?ref=${ref}` : '';

return axios.get<Array<GithubRepoFile>>(
`${GITHUB_REPOS_API}/${repoPath}/contents/${path}${refQuery}`,
{
headers: { ...DEFAULT_USER_AGENT_HEADERS, ...GITHUB_AUTH_HEADERS },
}
);
}
4 changes: 2 additions & 2 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@
}
},
"github": {
"fetchJsonFromRepository": {
"fetching": "Fetching {{ url }}...",
"fetchFileFromRepository": {
"fetching": "Fetching {{ path }}...",
"errors": {
"fetchFail": "An error occured fetching JSON file."
}
Expand Down
26 changes: 26 additions & 0 deletions lib/__tests__/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { fetchFileFromRepository } from '../github';
import { fetchRepoFile as __fetchRepoFile } from '../../api/github';

jest.mock('../../api/github');

const fetchRepoFile = __fetchRepoFile as jest.MockedFunction<
typeof __fetchRepoFile
>;

describe('lib/github', () => {
describe('fetchFileFromRepository()', () => {
beforeAll(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fetchRepoFile.mockResolvedValue({ data: null } as any);
});

afterAll(() => {
fetchRepoFile.mockReset();
});

it('downloads a github repo and writes it to a destination folder', async () => {
await fetchFileFromRepository('owner/repo', 'file', 'ref');
expect(fetchRepoFile).toHaveBeenCalledWith('owner/repo', 'file', 'ref');
});
});
});
75 changes: 75 additions & 0 deletions lib/__tests__/trackUsage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import axios from 'axios';
import { trackUsage } from '../trackUsage';
import {
getAccountConfig as __getAccountConfig,
getAndLoadConfigIfNeeded as __getAndLoadConfigIfNeeded,
} from '../../config';
import { AuthType } from '../../types/Accounts';
import { ENVIRONMENTS } from '../../constants/environments';

jest.mock('axios');
jest.mock('../../config');

const mockedAxios = jest.mocked(axios);
const getAccountConfig = __getAccountConfig as jest.MockedFunction<
typeof __getAccountConfig
>;
const getAndLoadConfigIfNeeded =
__getAndLoadConfigIfNeeded as jest.MockedFunction<
typeof __getAndLoadConfigIfNeeded
>;

mockedAxios.mockResolvedValue({});
getAndLoadConfigIfNeeded.mockReturnValue({});

const account = {
accountId: 12345,
authType: 'personalaccesskey' as AuthType,
personalAccessKey: 'let-me-in-3',
auth: {
tokenInfo: {
expiresAt: '',
accessToken: 'test-token',
},
},
env: ENVIRONMENTS.QA,
};

const usageTrackingMeta = {
action: 'cli-command',
command: 'test-command',
};

describe('lib/trackUsage', () => {
describe('trackUsage()', () => {
beforeEach(() => {
mockedAxios.mockClear();
getAccountConfig.mockReset();
getAccountConfig.mockReturnValue(account);
});

it('tracks correctly for unauthenticated accounts', async () => {
await trackUsage('test-action', 'INTERACTION', usageTrackingMeta);
const requestArgs = mockedAxios.mock.lastCall
? mockedAxios.mock.lastCall[0]
: ({} as any); // eslint-disable-line @typescript-eslint/no-explicit-any

expect(mockedAxios).toHaveBeenCalled();
expect(requestArgs!.data.eventName).toEqual('test-action');
expect(requestArgs!.url.includes('authenticated')).toBeFalsy();
expect(getAccountConfig).not.toHaveBeenCalled();
});

it('tracks correctly for authenticated accounts', async () => {
await trackUsage('test-action', 'INTERACTION', usageTrackingMeta, 12345);
const requestArgs = mockedAxios.mock.lastCall
? mockedAxios.mock.lastCall[0]
: ({} as any); // eslint-disable-line @typescript-eslint/no-explicit-any

expect(mockedAxios).toHaveBeenCalled();
expect(requestArgs!.data.eventName).toEqual('test-action');
expect(requestArgs!.url.includes('authenticated')).toBeTruthy();
expect(getAccountConfig).toHaveBeenCalled();
});
});
});
10 changes: 5 additions & 5 deletions lib/validate.ts → lib/cms/validate.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import fs from 'fs-extra';
import { HUBL_EXTENSIONS } from '../constants/extensions';
import { validateHubl } from '../api/validateHubl';
import { walk } from './fs';
import { getExt } from './path';
import { LintResult } from '../types/HublValidation';
import { HUBL_EXTENSIONS } from '../../constants/extensions';
import { validateHubl } from '../../api/validateHubl';
import { walk } from '../fs';
import { getExt } from '../path';
import { LintResult } from '../../types/HublValidation';

export async function lint(
accountId: number,
Expand Down
85 changes: 34 additions & 51 deletions lib/github.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import axios from 'axios';
import path from 'path';
import fs from 'fs-extra';

Expand All @@ -7,42 +6,37 @@ import { throwError, throwErrorWithMessage } from '../errors/standardErrors';
import { extractZipArchive } from './archive';

import { GITHUB_RELEASE_TYPES } from '../constants/github';
import { DEFAULT_USER_AGENT_HEADERS } from '../http/getAxiosConfig';
import { BaseError } from '../types/Error';
import { GithubReleaseData, GithubRepoFile } from '../types/Github';
import { ValueOf } from '../types/Utils';
import { LogCallbacksArg } from '../types/LogCallbacks';
import {
fetchRepoFile,
fetchRepoFileByDownloadUrl,
fetchRepoAsZip,
fetchRepoReleaseData,
fetchRepoContents,
} from '../api/github';

const i18nKey = 'lib.github';

declare global {
// eslint-disable-next-line no-var
var githubToken: string;
}

type RepoPath = `${string}/${string}`;

const GITHUB_AUTH_HEADERS = {
authorization:
global && global.githubToken ? `Bearer ${global.githubToken}` : null,
};

export async function fetchJsonFromRepository(
export async function fetchFileFromRepository(
repoPath: RepoPath,
filePath: string,
ref: string
): Promise<JSON> {
): Promise<Buffer> {
try {
const URL = `https://raw.githubusercontent.com/${repoPath}/${ref}/${filePath}`;
debug(`${i18nKey}.fetchJsonFromRepository.fetching`, { url: URL });

const { data } = await axios.get<JSON>(URL, {
headers: { ...DEFAULT_USER_AGENT_HEADERS, ...GITHUB_AUTH_HEADERS },
debug(`${i18nKey}.fetchFileFromRepository.fetching`, {
path: `${repoPath}/${ref}/${filePath}`,
});

const { data } = await fetchRepoFile(repoPath, filePath, ref);
return data;
} catch (err) {
throwErrorWithMessage(
`${i18nKey}.fetchJsonFromRepository.errors.fetchFail`,
`${i18nKey}.fetchFileFromRepository.errors.fetchFail`,
{},
err as BaseError
);
Expand All @@ -57,13 +51,8 @@ export async function fetchReleaseData(
if (tag.length && tag[0] !== 'v') {
tag = `v${tag}`;
}
const URI = tag
? `https://api.github.com/repos/${repoPath}/releases/tags/${tag}`
: `https://api.github.com/repos/${repoPath}/releases/latest`;
try {
const { data } = await axios.get<GithubReleaseData>(URI, {
headers: { ...DEFAULT_USER_AGENT_HEADERS, ...GITHUB_AUTH_HEADERS },
});
const { data } = await fetchRepoReleaseData(repoPath, tag);
return data;
} catch (err) {
const error = err as BaseError;
Expand Down Expand Up @@ -99,9 +88,7 @@ async function downloadGithubRepoZip(
const { name } = releaseData;
debug(`${i18nKey}.downloadGithubRepoZip.fetchingName`, { name });
}
const { data } = await axios.get<Buffer>(zipUrl, {
headers: { ...DEFAULT_USER_AGENT_HEADERS, ...GITHUB_AUTH_HEADERS },
});
const { data } = await fetchRepoAsZip(zipUrl);
debug(`${i18nKey}.downloadGithubRepoZip.completed`);
return data;
} catch (err) {
Expand Down Expand Up @@ -144,29 +131,11 @@ export async function cloneGithubRepo(
return success;
}

async function getGitHubRepoContentsAtPath(
repoPath: RepoPath,
path: string,
ref?: string
): Promise<Array<GithubRepoFile>> {
const refQuery = ref ? `?ref=${ref}` : '';
const contentsRequestUrl = `https://api.github.com/repos/${repoPath}/contents/${path}${refQuery}`;

const response = await axios.get<Array<GithubRepoFile>>(contentsRequestUrl, {
headers: { ...DEFAULT_USER_AGENT_HEADERS, ...GITHUB_AUTH_HEADERS },
});

return response.data;
}

async function fetchGitHubRepoContentFromDownloadUrl(
dest: string,
downloadUrl: string
): Promise<void> {
const resp = await axios.get<Buffer>(downloadUrl, {
headers: { ...DEFAULT_USER_AGENT_HEADERS, ...GITHUB_AUTH_HEADERS },
});

const resp = await fetchRepoFileByDownloadUrl(downloadUrl);
fs.writeFileSync(dest, resp.data, 'utf8');
}

Expand All @@ -181,7 +150,7 @@ export async function downloadGithubRepoContents(
fs.ensureDirSync(path.dirname(dest));

try {
const contentsResp = await getGitHubRepoContentsAtPath(
const { data: contentsResp } = await fetchRepoContents(
repoPath,
contentPath,
ref
Expand All @@ -190,7 +159,11 @@ export async function downloadGithubRepoContents(
const downloadContent = async (
contentPiece: GithubRepoFile
): Promise<void> => {
const { path: contentPiecePath, download_url } = contentPiece;
const {
path: contentPiecePath,
download_url,
type: contentPieceType,
} = contentPiece;
const downloadPath = path.join(
dest,
contentPiecePath.replace(contentPath, '')
Expand All @@ -206,6 +179,16 @@ export async function downloadGithubRepoContents(
downloadPath,
});

if (contentPieceType === 'dir') {
const { data: innerDirContent } = await fetchRepoContents(
repoPath,
contentPiecePath,
ref
);
await Promise.all(innerDirContent.map(downloadContent));
return Promise.resolve();
}

return fetchGitHubRepoContentFromDownloadUrl(downloadPath, download_url);
};

Expand All @@ -217,7 +200,7 @@ export async function downloadGithubRepoContents(
contentPromises = [downloadContent(contentsResp)];
}

Promise.all(contentPromises);
await Promise.all(contentPromises);
} catch (e) {
const error = e as BaseError;
if (error?.error?.message) {
Expand Down

0 comments on commit 3bb2307

Please sign in to comment.