Skip to content
Draft
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
68 changes: 17 additions & 51 deletions tests/unit/GithubUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,54 +2,28 @@ import CONST from '@github/libs/CONST';
import type {InternalOctokit} from '@github/libs/GithubUtils';
import GithubUtils from '@github/libs/GithubUtils';

import type {Writable} from 'type-fest';

/**
* @jest-environment node
*/
/* eslint-disable @typescript-eslint/naming-convention */
import * as core from '@actions/core';
import {RequestError} from '@octokit/request-error';

const mockGetInput = jest.fn();
const mockListIssues = jest.fn();

type ObjectMethodData<T> = {
data: T;
};
import createMock from '../utils/createMock';

type OctokitCreateIssue = InternalOctokit['rest']['issues']['create'];
type OctokitCompareCommits = InternalOctokit['rest']['repos']['compareCommits'];
type OctokitCompareCommitsResponse = Awaited<ReturnType<OctokitCompareCommits>>;

const asMutable = <T>(value: T): Writable<T> => value as Writable<T>;
let internalOctokit: InternalOctokit;

beforeAll(() => {
// Mock core module
asMutable(core).getInput = mockGetInput;

// Mock octokit module
const mockOctokit = {
rest: {
issues: {
create: jest.fn().mockImplementation((arg: Parameters<OctokitCreateIssue>[0]) =>
Promise.resolve({
data: {
...arg,
html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/29`,
},
}),
),
listForRepo: mockListIssues,
},
},
paginate: jest.fn().mockImplementation(<T>(objectMethod: () => Promise<ObjectMethodData<T>>) => objectMethod().then(({data}) => data)),
} as unknown as InternalOctokit;

GithubUtils.internalOctokit = mockOctokit;
});
GithubUtils.initOctokitWithToken('fake_token');
const initializedOctokit = GithubUtils.internalOctokit;
if (!initializedOctokit) {
throw new Error('Expected GithubUtils to initialize an Octokit client.');
}

afterEach(() => {
mockGetInput.mockClear();
mockListIssues.mockClear();
internalOctokit = initializedOctokit;
});

describe('GithubUtils', () => {
Expand Down Expand Up @@ -200,7 +174,7 @@ describe('GithubUtils', () => {
};

describe('getCommitHistoryBetweenTags', () => {
let mockCompareCommits: jest.Mock;
let mockCompareCommits: jest.SpiedFunction<OctokitCompareCommits>;

beforeEach(() => {
jest.spyOn(core, 'getInput').mockImplementation((name) => {
Expand All @@ -211,27 +185,19 @@ describe('GithubUtils', () => {
});

// Prepare the mocked GitHub API
mockCompareCommits = jest.fn();
const mockOctokitInstance = {
rest: {
repos: {
compareCommits: mockCompareCommits,
},
},
paginate: jest.fn(),
} as unknown as InternalOctokit;
mockCompareCommits = jest.spyOn(internalOctokit.rest.repos, 'compareCommits');

// Replace the real initOctokit with our mocked one
jest.spyOn(GithubUtils, 'initOctokit').mockImplementation(() => {});
GithubUtils.internalOctokit = mockOctokitInstance;
GithubUtils.internalOctokit = internalOctokit;
});

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

test('should call GitHub API with correct parameters', async () => {
mockCompareCommits.mockResolvedValue(commitHistoryData.emptyResponse);
mockCompareCommits.mockResolvedValue(createMock<OctokitCompareCommitsResponse>(commitHistoryData.emptyResponse));

await GithubUtils.getCommitHistoryBetweenTags('v1.0.0', 'v1.0.1', CONST.APP_REPO);

Expand All @@ -246,21 +212,21 @@ describe('GithubUtils', () => {
});

test('should return empty array when no commits found', async () => {
mockCompareCommits.mockResolvedValue(commitHistoryData.emptyResponse);
mockCompareCommits.mockResolvedValue(createMock<OctokitCompareCommitsResponse>(commitHistoryData.emptyResponse));

const result = await GithubUtils.getCommitHistoryBetweenTags('1.0.0', '1.0.1', CONST.APP_REPO);
expect(result).toEqual([]);
});

test('should return formatted commit history when commits exist', async () => {
mockCompareCommits.mockResolvedValue(commitHistoryData.singleCommit);
mockCompareCommits.mockResolvedValue(createMock<OctokitCompareCommitsResponse>(commitHistoryData.singleCommit));

const result = await GithubUtils.getCommitHistoryBetweenTags('1.0.0', '1.0.1', CONST.APP_REPO);
expect(result).toEqual(commitHistoryData.expectedFormattedCommit);
});

test('should handle multiple commits correctly', async () => {
mockCompareCommits.mockResolvedValue(commitHistoryData.multipleCommitsResponse);
mockCompareCommits.mockResolvedValue(createMock<OctokitCompareCommitsResponse>(commitHistoryData.multipleCommitsResponse));

const result = await GithubUtils.getCommitHistoryBetweenTags('1.0.0', '1.0.1', CONST.APP_REPO);

Expand Down
44 changes: 16 additions & 28 deletions tests/unit/HomePage/YourSpendSection/YourSpendSectionTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ import type * as NativeNavigation from '@react-navigation/native';
import type {ReactNode} from 'react';
// eslint-disable-next-line no-restricted-imports
import type {Pressable as RNPressable, Text as RNText, View as RNView} from 'react-native';
import type {ValueOf} from 'type-fest';

import React from 'react';

import createMock from '../../../utils/createMock';

jest.mock('@libs/Navigation/Navigation', () => ({
navigate: jest.fn(),
setNavigationActionToMicrotaskQueue: jest.fn((cb: () => void) => cb()),
Expand Down Expand Up @@ -152,30 +153,11 @@ jest.mock('@pages/home/YourSpendSection/useYourSpendData', () => {
};
});

type MockCardRow = {
cardID: number;
query: string;
lastFour: string;
total: number | undefined;
currency: string | undefined;
spentFraction?: number | undefined;
kind?: 'expensify' | 'thirdParty';
bank?: CardFeedWithNumber;
fundID?: string | undefined;
};

type MockHookData = {
approvalRowState: ValueOf<typeof YOUR_SPEND_ROW_STATE>;
approvalTotals: {total: number | undefined; currency: string | undefined};
paymentRowState: ValueOf<typeof YOUR_SPEND_ROW_STATE>;
paymentTotals: {total: number | undefined; currency: string | undefined};
cardRows: MockCardRow[];
awaitingApprovalQuery: string;
repaidLast30DaysQuery: string;
};
type MockHookData = ReturnType<typeof useYourSpendData>;
type MockCardRow = MockHookData['cardRows'][number];

function mockHook(data: Partial<MockHookData>) {
(useYourSpendData as jest.Mock).mockReturnValue({
jest.mocked(useYourSpendData).mockReturnValue({
approvalRowState: YOUR_SPEND_ROW_STATE.HIDDEN,
approvalTotals: {total: undefined, currency: undefined},
paymentRowState: YOUR_SPEND_ROW_STATE.HIDDEN,
Expand Down Expand Up @@ -240,8 +222,8 @@ describe('YourSpendSection', () => {
it('renders a card row for each entry in cardRows', () => {
mockHook({
cardRows: [
{cardID: 1, query: 'type:expense cardID:1', lastFour: '1234', total: undefined, currency: undefined},
{cardID: 2, query: 'type:expense cardID:2', lastFour: '5678', total: undefined, currency: undefined},
createMock<MockCardRow>({cardID: 1, query: 'type:expense cardID:1', lastFour: '1234', total: undefined, currency: undefined}),
createMock<MockCardRow>({cardID: 2, query: 'type:expense cardID:2', lastFour: '5678', total: undefined, currency: undefined}),
],
});
render(<YourSpendSection />);
Expand Down Expand Up @@ -276,6 +258,7 @@ describe('YourSpendSection — third-party rows', () => {
kind: 'thirdParty',
bank: THIRD_PARTY_BANK,
fundID: '767578',
isPersonal: false,
...overrides,
};
}
Expand All @@ -291,6 +274,7 @@ describe('YourSpendSection — third-party rows', () => {
kind: 'expensify',
bank: 'Expensify Card' as CardFeedWithNumber,
fundID: '999',
isPersonal: false,
...overrides,
};
}
Expand All @@ -316,7 +300,7 @@ describe('YourSpendSection — third-party rows', () => {
});

it('navigates to SEARCH_ROOT with the third-party row query when tapped (R-8)', () => {
(Navigation.navigate as jest.Mock).mockClear();
jest.mocked(Navigation.navigate).mockClear();
mockHook({cardRows: [thirdPartyRow()]});
render(<YourSpendSection />);
const row = screen.getByTestId(`your-spend-card-row-${THIRD_PARTY_CARD_ID}`);
Expand Down Expand Up @@ -407,7 +391,11 @@ describe('YourSpendSection — third-party rows', () => {
// Match approval-row, payment-row, and any card-row-* element under the section,
// then assert their relative order in the rendered tree.
const allRows = within(section).getAllByTestId(/^your-spend-(approval-row|payment-row|card-row-\d+)$/);
const collectedTestIDs = allRows.map((el) => (el.props as {testID: string}).testID);
expect(collectedTestIDs).toEqual(['your-spend-approval-row', 'your-spend-payment-row', `your-spend-card-row-${expRow.cardID}`, `your-spend-card-row-${tpRow.cardID}`]);
expect(allRows).toEqual([
within(section).getByTestId('your-spend-approval-row'),
within(section).getByTestId('your-spend-payment-row'),
within(section).getByTestId(`your-spend-card-row-${expRow.cardID}`),
within(section).getByTestId(`your-spend-card-row-${tpRow.cardID}`),
]);
});
});
26 changes: 16 additions & 10 deletions tests/unit/ImageSVGCachePolicyTest.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import {render} from '@testing-library/react-native';

import type {ImageProps as ExpoImageProps} from 'expo-image';

import React from 'react';

import ImageSVGAndroid from '../../src/components/ImageSVG/index.android';
import ImageSVGiOS from '../../src/components/ImageSVG/index.ios';

type MockImageType = jest.Mock & {clearMemoryCache: jest.Mock};

const mockClearMemoryCache = jest.fn(() => Promise.resolve(true));

const mockImageComponent: MockImageType = Object.assign(
jest.fn(() => null),
const mockImageComponent = Object.assign(
jest.fn((props: ExpoImageProps) => {
Object.keys(props);
return null;
}),
{
clearMemoryCache: mockClearMemoryCache,
},
) as MockImageType;
);

jest.mock('expo-image', () => ({
get Image() {
Expand All @@ -27,18 +30,21 @@ jest.mock('@libs/getImageRecyclingKey', () =>
if (typeof source === 'number') {
return String(source);
}
if (typeof source === 'object' && source !== null && 'uri' in source) {
return (source as {uri: string}).uri;
if (typeof source === 'object' && source !== null && 'uri' in source && typeof source.uri === 'string') {
return source.uri;
}
return undefined;
}),
);

const MOCK_STATIC_SOURCE = 42;

function getFirstCallProps(): Record<string, unknown> {
const firstCall = mockImageComponent.mock.calls.at(0) as unknown[] | undefined;
return firstCall?.at(0) as Record<string, unknown>;
function getFirstCallProps(): ExpoImageProps {
const firstCall = mockImageComponent.mock.calls.at(0);
if (!firstCall) {
throw new Error('Expected Expo Image to be called');
}
return firstCall[0];
}

describe('ImageSVG cache policy', () => {
Expand Down
12 changes: 7 additions & 5 deletions tests/unit/MoneyRequestUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type Transaction from '@src/types/onyx/Transaction';
import type {TransactionCustomUnit, WaypointCollection} from '@src/types/onyx/Transaction';

import createRandomTransaction from '../utils/collections/transaction';
import createMock from '../utils/createMock';

describe('ReportActionsUtils', () => {
describe('validateAmount', () => {
Expand Down Expand Up @@ -207,7 +208,8 @@ describe('ReportActionsUtils', () => {
describe('invalid inputs', () => {
it('should return false for nullish and NaN values', () => {
expect(isValidMoneyRequestAmount(undefined, CONST.IOU.TYPE.SUBMIT)).toBe(false);
expect(isValidMoneyRequestAmount(null as unknown as number, CONST.IOU.TYPE.SUBMIT)).toBe(false);
// @ts-expect-error -- Deliberately verifies the defensive runtime behavior for null input.
expect(isValidMoneyRequestAmount(null, CONST.IOU.TYPE.SUBMIT)).toBe(false);
expect(isValidMoneyRequestAmount(NaN, CONST.IOU.TYPE.SUBMIT)).toBe(false);
});
});
Expand Down Expand Up @@ -274,15 +276,15 @@ describe('ReportActionsUtils', () => {
type: CONST.REPORT.TYPE.EXPENSE,
} as Report;

const unreportedTransaction = {
const unreportedTransaction = createMock<Transaction>({
reportID: CONST.REPORT.UNREPORTED_REPORT_ID,
amount: 0,
} as Transaction;
});

const reportedTransaction = {
const reportedTransaction = createMock<Transaction>({
reportID: '123',
amount: 0,
} as Transaction;
});

describe('empty merchants', () => {
it('should return true for empty/undefined merchant when transaction is unreported or IOU', () => {
Expand Down
10 changes: 7 additions & 3 deletions tests/unit/getChartSkiaTypefaceTest.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import type {ChartDefaultTypeface, ChartSkiaTypefaceKey} from '@components/Charts/types/chartSkiaTypefaceTypes';
import type {ChartDefaultTypeface} from '@components/Charts/types/chartSkiaTypefaceTypes';
import {CHART_SKIA_TYPEFACE_ASSETS} from '@components/Charts/utils/chartFontAssets';
import getChartSkiaTypeface from '@components/Charts/utils/getChartSkiaTypeface';

import ObjectUtils from '@src/types/utils/ObjectUtils';

import type {SkTypeface} from '@shopify/react-native-skia';

const CHART_SKIA_TYPEFACE_KEYS = Object.keys(CHART_SKIA_TYPEFACE_ASSETS) as ChartSkiaTypefaceKey[];
import createMock from '../utils/createMock';

const CHART_SKIA_TYPEFACE_KEYS = ObjectUtils.typedKeys(CHART_SKIA_TYPEFACE_ASSETS);

function makeTypefaces(): ChartDefaultTypeface {
return Object.fromEntries(CHART_SKIA_TYPEFACE_KEYS.map((key) => [key, {id: key} as unknown as SkTypeface])) as ChartDefaultTypeface;
return ObjectUtils.typedFromEntries(CHART_SKIA_TYPEFACE_KEYS.map((key) => [key, createMock<SkTypeface>({})] as const));
}

describe('getChartSkiaTypeface', () => {
Expand Down
Loading
Loading