mirror of
https://github.com/selfxyz/self.git
synced 2026-04-27 03:01:15 -04:00
Reorganize mobile app /src and /tests folders (#1357)
* Refactor mobile app utilities into new modules * prettier * update lock, feedback from codex * fix path * keep some files in utils * fix tests * update paths * remove old docs * cr feedback * flatten inefficient paths * better structure * update test folder structure * migrate images * fix import * fix Sentry path * update ignore * save wip migration * more updates * standardize component names * rename assets * fix linting * add barrel exports. final refactor commit * fix formatting * fix nav bar * reduce bundle size * remove dupe license * fix test * fix merge issues * add refactor doc so we can track what was imporoved * cr feedback * feedback
This commit is contained in:
@@ -1,40 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('Android build.gradle Configuration', () => {
|
||||
const gradlePath = join(__dirname, '../../android/app/build.gradle');
|
||||
const rootGradlePath = join(__dirname, '../../android/build.gradle');
|
||||
let gradleContent: string;
|
||||
let rootGradleContent: string;
|
||||
|
||||
beforeAll(() => {
|
||||
gradleContent = readFileSync(gradlePath, 'utf8');
|
||||
rootGradleContent = readFileSync(rootGradlePath, 'utf8');
|
||||
});
|
||||
|
||||
it('references SDK versions from the root project', () => {
|
||||
expect(gradleContent).toMatch(
|
||||
/minSdkVersion\s+rootProject\.ext\.minSdkVersion/,
|
||||
);
|
||||
expect(gradleContent).toMatch(
|
||||
/targetSdkVersion\s+rootProject\.ext\.targetSdkVersion/,
|
||||
);
|
||||
});
|
||||
|
||||
it('sets the expected SDK version numbers', () => {
|
||||
expect(rootGradleContent).toMatch(/minSdkVersion\s*=\s*24/);
|
||||
expect(rootGradleContent).toMatch(/targetSdkVersion\s*=\s*36/);
|
||||
});
|
||||
|
||||
it('includes Firebase messaging dependency', () => {
|
||||
expect(gradleContent).toContain('com.google.firebase:firebase-messaging');
|
||||
});
|
||||
});
|
||||
@@ -1,185 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('Android Manifest Configuration', () => {
|
||||
const manifestPath = join(
|
||||
__dirname,
|
||||
'../../android/app/src/main/AndroidManifest.xml',
|
||||
);
|
||||
let manifestContent: string;
|
||||
|
||||
beforeAll(() => {
|
||||
// Read the manifest file
|
||||
manifestContent = readFileSync(manifestPath, 'utf8');
|
||||
});
|
||||
|
||||
describe('Critical Deeplink Configuration', () => {
|
||||
it('should contain the redirect.self.xyz deeplink intent filter', () => {
|
||||
// This is the configuration that was accidentally deleted
|
||||
expect(manifestContent).toContain('android:host="redirect.self.xyz"');
|
||||
expect(manifestContent).toContain('android:autoVerify="true"');
|
||||
expect(manifestContent).toContain('android.intent.action.VIEW');
|
||||
expect(manifestContent).toContain('android.intent.category.BROWSABLE');
|
||||
expect(manifestContent).toContain('android:scheme="https"');
|
||||
});
|
||||
|
||||
it('should have the deeplink intent filter in the MainActivity', () => {
|
||||
// Ensure the deeplink is properly configured in the main activity
|
||||
const mainActivityMatch = manifestContent.match(
|
||||
/<activity[^>]*android:name="\.MainActivity"[^>]*>(.*?)<\/activity>/s,
|
||||
);
|
||||
|
||||
expect(mainActivityMatch).toBeTruthy();
|
||||
expect(mainActivityMatch![1]).toContain('redirect.self.xyz');
|
||||
expect(mainActivityMatch![1]).toContain('android:autoVerify="true"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Firebase Configuration', () => {
|
||||
it('should have Firebase Messaging Service configured', () => {
|
||||
expect(manifestContent).toContain(
|
||||
'com.google.firebase.messaging.FirebaseMessagingService',
|
||||
);
|
||||
expect(manifestContent).toContain('com.google.firebase.MESSAGING_EVENT');
|
||||
});
|
||||
|
||||
it('should have Firebase metadata configurations', () => {
|
||||
const firebaseMetaConfigs = [
|
||||
'com.google.firebase.messaging.default_notification_channel_id',
|
||||
'com.google.firebase.messaging.default_notification_icon',
|
||||
'com.google.firebase.messaging.default_notification_color',
|
||||
];
|
||||
|
||||
firebaseMetaConfigs.forEach(config => {
|
||||
expect(manifestContent).toContain(`android:name="${config}"`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have Firebase service properly exported', () => {
|
||||
// Firebase service should not be exported for security
|
||||
const serviceMatch = manifestContent.match(
|
||||
/<service[^>]*android:name="com\.google\.firebase\.messaging\.FirebaseMessagingService"[^>]*>/,
|
||||
);
|
||||
expect(serviceMatch).toBeTruthy();
|
||||
expect(serviceMatch![0]).toContain('android:exported="false"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth/AppAuth Configuration', () => {
|
||||
it('should have AppAuth RedirectUriReceiverActivity configured', () => {
|
||||
expect(manifestContent).toContain(
|
||||
'net.openid.appauth.RedirectUriReceiverActivity',
|
||||
);
|
||||
expect(manifestContent).toContain('${appAuthRedirectScheme}');
|
||||
expect(manifestContent).toContain('oauth2redirect');
|
||||
});
|
||||
|
||||
it('should have OAuth activity properly exported', () => {
|
||||
const oauthActivityMatch = manifestContent.match(
|
||||
/<activity[^>]*android:name="net\.openid\.appauth\.RedirectUriReceiverActivity"[^>]*>/,
|
||||
);
|
||||
expect(oauthActivityMatch).toBeTruthy();
|
||||
expect(oauthActivityMatch![0]).toContain('android:exported="true"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NFC Configuration', () => {
|
||||
it('should have NFC permission', () => {
|
||||
expect(manifestContent).toContain('android.permission.NFC');
|
||||
});
|
||||
|
||||
it('should have NFC tech discovery metadata', () => {
|
||||
expect(manifestContent).toContain('android.nfc.action.TECH_DISCOVERED');
|
||||
expect(manifestContent).toContain('@xml/nfc_tech_filter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Required Permissions', () => {
|
||||
const criticalPermissions = [
|
||||
'android.permission.INTERNET',
|
||||
'android.permission.CAMERA',
|
||||
'android.permission.NFC',
|
||||
'android.permission.VIBRATE',
|
||||
'android.permission.POST_NOTIFICATIONS',
|
||||
'android.permission.ACCESS_SURFACE_FLINGER',
|
||||
'android.permission.RECEIVE_BOOT_COMPLETED',
|
||||
];
|
||||
|
||||
criticalPermissions.forEach(permission => {
|
||||
it(`should contain ${permission} permission`, () => {
|
||||
expect(manifestContent).toContain(`android:name="${permission}"`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Main Activity Configuration', () => {
|
||||
it('should have MainActivity properly configured', () => {
|
||||
expect(manifestContent).toContain('android:name=".MainActivity"');
|
||||
expect(manifestContent).toContain('android:exported="true"');
|
||||
expect(manifestContent).toContain('android:launchMode="singleTop"');
|
||||
// Orientation locks removed to support large screens
|
||||
expect(manifestContent).not.toContain('android:screenOrientation');
|
||||
});
|
||||
|
||||
it('should have main launcher intent filter', () => {
|
||||
expect(manifestContent).toContain('android.intent.action.MAIN');
|
||||
expect(manifestContent).toContain('android.intent.category.LAUNCHER');
|
||||
});
|
||||
|
||||
it('should have proper config changes handled', () => {
|
||||
const configChanges = [
|
||||
'keyboard',
|
||||
'keyboardHidden',
|
||||
'orientation',
|
||||
'screenLayout',
|
||||
'screenSize',
|
||||
'smallestScreenSize',
|
||||
'uiMode',
|
||||
];
|
||||
|
||||
configChanges.forEach(change => {
|
||||
expect(manifestContent).toContain(change);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Application Configuration', () => {
|
||||
it('should have MainApplication configured', () => {
|
||||
expect(manifestContent).toContain('android:name=".MainApplication"');
|
||||
expect(manifestContent).toContain('android:largeHeap="true"');
|
||||
expect(manifestContent).toContain('android:supportsRtl="true"');
|
||||
});
|
||||
|
||||
it('should have proper theme and icons configured', () => {
|
||||
expect(manifestContent).toContain('@style/AppTheme');
|
||||
expect(manifestContent).toContain('@mipmap/ic_launcher');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Manifest Structure Validation', () => {
|
||||
it('should be valid XML structure', () => {
|
||||
// Basic XML validation - ensure it has proper opening/closing tags
|
||||
expect(manifestContent).toMatch(/^<manifest[^>]*>/);
|
||||
expect(manifestContent).toContain('</manifest>');
|
||||
expect(manifestContent).toContain('<application');
|
||||
expect(manifestContent).toContain('</application>');
|
||||
});
|
||||
|
||||
it('should have required namespaces', () => {
|
||||
expect(manifestContent).toContain(
|
||||
'xmlns:android="http://schemas.android.com/apk/res/android"',
|
||||
);
|
||||
expect(manifestContent).toContain(
|
||||
'xmlns:tools="http://schemas.android.com/tools"',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,15 +6,15 @@ import type { ReactNode } from 'react';
|
||||
import { render } from '@testing-library/react-native';
|
||||
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import { captureException } from '@/Sentry';
|
||||
import { flushAllAnalytics, trackNfcEvent } from '@/utils/analytics';
|
||||
import { captureException } from '@/config/sentry';
|
||||
import { flushAllAnalytics, trackNfcEvent } from '@/services/analytics';
|
||||
|
||||
jest.mock('@/utils/analytics', () => ({
|
||||
jest.mock('@/services/analytics', () => ({
|
||||
trackNfcEvent: jest.fn(),
|
||||
flushAllAnalytics: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/Sentry', () => ({
|
||||
jest.mock('@/config/sentry', () => ({
|
||||
captureException: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -25,7 +25,6 @@ const MockText = ({
|
||||
children?: ReactNode;
|
||||
testID?: string;
|
||||
}) => <mock-text testID={testID}>{children}</mock-text>;
|
||||
|
||||
const ProblemChild = () => {
|
||||
throw new Error('boom');
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getFeatureFlag,
|
||||
getLocalOverrides,
|
||||
setLocalOverride,
|
||||
} from '@/RemoteConfig';
|
||||
} from '@/config/remoteConfig';
|
||||
|
||||
// Mock AsyncStorage with a default export
|
||||
jest.mock('@react-native-async-storage/async-storage', () => ({
|
||||
@@ -31,7 +31,7 @@ jest.mock('@/utils/modalCallbackRegistry', () => ({
|
||||
registerModalCallbacks: jest.fn().mockReturnValue(1),
|
||||
}));
|
||||
|
||||
jest.mock('@/utils/analytics', () => () => ({
|
||||
jest.mock('@/services/analytics', () => () => ({
|
||||
trackEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -9,14 +9,14 @@ import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
import { useEarnPointsFlow } from '@/hooks/useEarnPointsFlow';
|
||||
import { useRegisterReferral } from '@/hooks/useRegisterReferral';
|
||||
import useUserStore from '@/stores/userStore';
|
||||
import { getModalCallbacks } from '@/utils/modalCallbackRegistry';
|
||||
import {
|
||||
hasUserAnIdentityDocumentRegistered,
|
||||
hasUserDoneThePointsDisclosure,
|
||||
POINT_VALUES,
|
||||
pointsSelfApp,
|
||||
} from '@/utils/points';
|
||||
} from '@/services/points';
|
||||
import useUserStore from '@/stores/userStore';
|
||||
import { getModalCallbacks } from '@/utils/modalCallbackRegistry';
|
||||
|
||||
jest.mock('@react-navigation/native', () => ({
|
||||
useNavigation: jest.fn(),
|
||||
@@ -30,7 +30,7 @@ jest.mock('@/hooks/useRegisterReferral', () => ({
|
||||
useRegisterReferral: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/utils/points', () => ({
|
||||
jest.mock('@/services/points', () => ({
|
||||
hasUserAnIdentityDocumentRegistered: jest.fn(),
|
||||
hasUserDoneThePointsDisclosure: jest.fn(),
|
||||
pointsSelfApp: jest.fn(),
|
||||
|
||||
@@ -6,13 +6,17 @@ import { useNavigation } from '@react-navigation/native';
|
||||
import { act, renderHook } from '@testing-library/react-native';
|
||||
|
||||
import useHapticNavigation from '@/hooks/useHapticNavigation';
|
||||
import { impactLight, impactMedium, selectionChange } from '@/utils/haptic';
|
||||
import {
|
||||
impactLight,
|
||||
impactMedium,
|
||||
selectionChange,
|
||||
} from '@/integrations/haptics';
|
||||
|
||||
jest.mock('@react-navigation/native', () => ({
|
||||
useNavigation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/utils/haptic', () => ({
|
||||
jest.mock('@/integrations/haptics', () => ({
|
||||
impactLight: jest.fn(),
|
||||
impactMedium: jest.fn(),
|
||||
selectionChange: jest.fn(),
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react-native';
|
||||
|
||||
import { useRegisterReferral } from '@/hooks/useRegisterReferral';
|
||||
import { recordReferralPointEvent } from '@/utils/points';
|
||||
import { recordReferralPointEvent } from '@/services/points';
|
||||
|
||||
jest.mock('@/utils/points', () => ({
|
||||
jest.mock('@/services/points', () => ({
|
||||
recordReferralPointEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
|
||||
294
app/tests/src/integrations/nfc/nfcScanner.test.ts
Normal file
294
app/tests/src/integrations/nfc/nfcScanner.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
// Mock Platform without requiring react-native to avoid memory issues
|
||||
// Use a simple object that can be modified directly
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
import { parseScanResponse, scan } from '@/integrations/nfc/nfcScanner';
|
||||
import { PassportReader } from '@/integrations/nfc/passportReader';
|
||||
|
||||
const Platform = {
|
||||
OS: 'ios', // Default to iOS
|
||||
Version: 14,
|
||||
};
|
||||
|
||||
jest.mock('react-native', () => ({
|
||||
Platform,
|
||||
}));
|
||||
|
||||
// Ensure the Node Buffer implementation is available to the module under test
|
||||
global.Buffer = Buffer;
|
||||
|
||||
describe('parseScanResponse', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Reset Platform.OS to default before each test to prevent pollution
|
||||
Platform.OS = 'ios';
|
||||
});
|
||||
|
||||
it('parses iOS response', () => {
|
||||
// Platform.OS is already mocked as 'ios' by default
|
||||
const mrz =
|
||||
'P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<L898902C<3UTO6908061F9406236ZE184226B<<<<<14';
|
||||
const response = JSON.stringify({
|
||||
dataGroupHashes: JSON.stringify({
|
||||
DG1: { sodHash: 'abcd' },
|
||||
DG2: { sodHash: '1234' },
|
||||
}),
|
||||
eContentBase64: Buffer.from('ec').toString('base64'),
|
||||
signedAttributes: Buffer.from('sa').toString('base64'),
|
||||
passportMRZ: mrz,
|
||||
signatureBase64: Buffer.from([1, 2]).toString('base64'),
|
||||
dataGroupsPresent: [1, 2],
|
||||
passportPhoto: 'photo',
|
||||
documentSigningCertificate: JSON.stringify({ PEM: 'CERT' }),
|
||||
});
|
||||
|
||||
const result = parseScanResponse(response);
|
||||
expect(result.mrz).toBe(mrz);
|
||||
expect(result.documentType).toBe('passport');
|
||||
// 'abcd' in hex: ab = 171, cd = 205
|
||||
expect(result.dg1Hash).toEqual([171, 205]);
|
||||
// '1234' in hex: 12 = 18, 34 = 52
|
||||
expect(result.dg2Hash).toEqual([18, 52]);
|
||||
});
|
||||
|
||||
it('parses Android response', () => {
|
||||
// Temporarily override Platform.OS for this test
|
||||
const originalOS = Platform.OS;
|
||||
Platform.OS = 'android';
|
||||
|
||||
const mrz =
|
||||
'P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<L898902C<3UTO6908061F9406236ZE184226B<<<<<14';
|
||||
const response = {
|
||||
mrz,
|
||||
eContent: JSON.stringify([4, 5]),
|
||||
encryptedDigest: JSON.stringify([6, 7]),
|
||||
encapContent: JSON.stringify([8, 9]),
|
||||
documentSigningCertificate: 'CERT',
|
||||
// Android format: '1' and '2' are hex strings, not arrays
|
||||
dataGroupHashes: JSON.stringify({ '1': 'abcd', '2': '1234' }),
|
||||
} as any;
|
||||
|
||||
const result = parseScanResponse(response);
|
||||
expect(result.documentType).toBe('passport');
|
||||
expect(result.mrz).toBe(mrz);
|
||||
// 'abcd' in hex: ab = 171, cd = 205
|
||||
expect(result.dg1Hash).toEqual([171, 205]);
|
||||
// dg2Hash should be parsed from hex string '1234': 12 = 18, 34 = 52
|
||||
expect(result.dg2Hash).toEqual([18, 52]);
|
||||
expect(result.dgPresents).toEqual([1, 2]);
|
||||
|
||||
// Restore original value
|
||||
Platform.OS = originalOS;
|
||||
});
|
||||
|
||||
it('handles malformed iOS response', () => {
|
||||
// Platform.OS is already mocked as 'ios' by default
|
||||
const response = '{"invalid": "json"';
|
||||
|
||||
expect(() => parseScanResponse(response)).toThrow();
|
||||
});
|
||||
|
||||
it('handles malformed Android response', () => {
|
||||
const originalOS = Platform.OS;
|
||||
Platform.OS = 'android';
|
||||
|
||||
const response = {
|
||||
mrz: 'valid_mrz',
|
||||
eContent: 'invalid_json_string',
|
||||
dataGroupHashes: JSON.stringify({ '1': 'abcd' }),
|
||||
};
|
||||
|
||||
expect(() => parseScanResponse(response)).toThrow();
|
||||
|
||||
// Restore original value
|
||||
Platform.OS = originalOS;
|
||||
});
|
||||
|
||||
it('handles missing required fields', () => {
|
||||
// Platform.OS is already mocked as 'ios' by default
|
||||
const response = JSON.stringify({
|
||||
// Providing minimal data but missing critical passportMRZ field
|
||||
dataGroupHashes: JSON.stringify({
|
||||
DG1: { sodHash: '00' }, // Minimal valid hex
|
||||
DG2: { sodHash: '00' }, // Minimal valid hex
|
||||
}),
|
||||
eContentBase64: Buffer.from('').toString('base64'),
|
||||
signedAttributes: Buffer.from('').toString('base64'),
|
||||
signatureBase64: Buffer.from('').toString('base64'),
|
||||
dataGroupsPresent: [],
|
||||
documentSigningCertificate: JSON.stringify({ PEM: 'CERT' }),
|
||||
// Missing passportMRZ which should cause an error
|
||||
});
|
||||
|
||||
expect(() => parseScanResponse(response)).toThrow();
|
||||
});
|
||||
|
||||
it('handles invalid hex data in dataGroupHashes', () => {
|
||||
// Platform.OS is already mocked as 'ios' by default
|
||||
const response = JSON.stringify({
|
||||
dataGroupHashes: JSON.stringify({
|
||||
DG1: { sodHash: 'invalid_hex' },
|
||||
}),
|
||||
passportMRZ: 'valid_mrz',
|
||||
});
|
||||
|
||||
expect(() => parseScanResponse(response)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scan', () => {
|
||||
const mockInputs = {
|
||||
passportNumber: 'L898902C3',
|
||||
dateOfBirth: '640812',
|
||||
dateOfExpiry: '251031',
|
||||
canNumber: '123456',
|
||||
useCan: false,
|
||||
sessionId: 'test-session',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Reset Platform.OS to default before each test to prevent pollution
|
||||
Platform.OS = 'ios';
|
||||
// Reset PassportReader mock before each test
|
||||
// The implementation checks for scanPassport property, so we need to ensure it exists
|
||||
Object.defineProperty(PassportReader, 'scanPassport', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
describe('iOS platform', () => {
|
||||
// Platform.OS is already mocked as 'ios' by default, no additional setup needed
|
||||
|
||||
it('should call PassportReader.scanPassport with correct parameters', async () => {
|
||||
const mockScanPassport = jest.fn().mockResolvedValue({
|
||||
mrz: 'test-mrz',
|
||||
dataGroupHashes: JSON.stringify({}),
|
||||
});
|
||||
|
||||
// Set the mock function directly on PassportReader
|
||||
Object.defineProperty(PassportReader, 'scanPassport', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: mockScanPassport,
|
||||
});
|
||||
|
||||
await scan(mockInputs);
|
||||
|
||||
expect(mockScanPassport).toHaveBeenCalledWith(
|
||||
'L898902C3',
|
||||
'640812',
|
||||
'251031',
|
||||
'123456',
|
||||
false,
|
||||
false, // skipPACE
|
||||
false, // skipCA
|
||||
false, // extendedMode
|
||||
false, // usePacePolling
|
||||
'test-session',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing optional parameters', async () => {
|
||||
const mockScanPassport = jest.fn().mockResolvedValue({
|
||||
mrz: 'test-mrz',
|
||||
dataGroupHashes: JSON.stringify({}),
|
||||
});
|
||||
|
||||
Object.defineProperty(PassportReader, 'scanPassport', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: mockScanPassport,
|
||||
});
|
||||
|
||||
const minimalInputs = {
|
||||
passportNumber: 'L898902C3',
|
||||
dateOfBirth: '640812',
|
||||
dateOfExpiry: '251031',
|
||||
sessionId: 'test-session',
|
||||
};
|
||||
|
||||
await scan(minimalInputs);
|
||||
|
||||
expect(mockScanPassport).toHaveBeenCalledWith(
|
||||
'L898902C3',
|
||||
'640812',
|
||||
'251031',
|
||||
'', // canNumber default
|
||||
false, // useCan default
|
||||
false, // skipPACE default
|
||||
false, // skipCA default
|
||||
false, // extendedMode default
|
||||
false, // usePacePolling default
|
||||
'test-session',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass through all optional parameters when provided', async () => {
|
||||
const mockScanPassport = jest.fn().mockResolvedValue({
|
||||
mrz: 'test-mrz',
|
||||
dataGroupHashes: JSON.stringify({}),
|
||||
});
|
||||
|
||||
Object.defineProperty(PassportReader, 'scanPassport', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: mockScanPassport,
|
||||
});
|
||||
|
||||
const fullInputs = {
|
||||
...mockInputs,
|
||||
useCan: true,
|
||||
skipPACE: true,
|
||||
skipCA: true,
|
||||
extendedMode: true,
|
||||
usePacePolling: true,
|
||||
};
|
||||
|
||||
await scan(fullInputs);
|
||||
|
||||
expect(mockScanPassport).toHaveBeenCalledWith(
|
||||
'L898902C3',
|
||||
'640812',
|
||||
'251031',
|
||||
'123456',
|
||||
true, // useCan
|
||||
true, // skipPACE
|
||||
true, // skipCA
|
||||
true, // extendedMode
|
||||
true, // usePacePolling
|
||||
'test-session',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Note: Android testing would require mocking the imported scan function
|
||||
// which is more complex in Jest. The interface tests handle this better.
|
||||
|
||||
describe('Analytics configuration', () => {
|
||||
// Platform.OS is already mocked as 'ios' by default, no additional setup needed
|
||||
|
||||
it('should configure analytics before scanning', async () => {
|
||||
const mockScanPassport = jest.fn().mockResolvedValue({
|
||||
mrz: 'test-mrz',
|
||||
dataGroupHashes: JSON.stringify({}),
|
||||
});
|
||||
|
||||
Object.defineProperty(PassportReader, 'scanPassport', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: mockScanPassport,
|
||||
});
|
||||
|
||||
await scan(mockInputs);
|
||||
|
||||
expect(mockScanPassport).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@
|
||||
* These tests verify critical interface requirements without conditional expects
|
||||
*/
|
||||
|
||||
import { PassportReader } from '@/utils/passportReader';
|
||||
import { PassportReader } from '@/integrations/nfc/passportReader';
|
||||
|
||||
describe('PassportReader Simple Contract Tests', () => {
|
||||
describe('Critical Interface Requirements', () => {
|
||||
@@ -1,36 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('iOS Info.plist Configuration', () => {
|
||||
const plistPath = join(__dirname, '../../ios/OpenPassport/Info.plist');
|
||||
let plistContent: string;
|
||||
|
||||
beforeAll(() => {
|
||||
plistContent = readFileSync(plistPath, 'utf8');
|
||||
});
|
||||
|
||||
it('contains the proofofpassport URL scheme', () => {
|
||||
const regex =
|
||||
/<key>CFBundleURLSchemes<\/key>\s*<array>\s*<string>proofofpassport<\/string>/s;
|
||||
expect(plistContent).toMatch(regex);
|
||||
});
|
||||
|
||||
it('has NFC and camera usage descriptions', () => {
|
||||
expect(plistContent).toContain('<key>NFCReaderUsageDescription</key>');
|
||||
expect(plistContent).toContain('<key>NSCameraUsageDescription</key>');
|
||||
});
|
||||
|
||||
it('lists required fonts', () => {
|
||||
expect(plistContent).toContain('<string>Advercase-Regular.otf</string>');
|
||||
expect(plistContent).toContain('<string>DINOT-Bold.otf</string>');
|
||||
expect(plistContent).toContain('<string>DINOT-Medium.otf</string>');
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('iOS project.pbxproj Configuration', () => {
|
||||
const projectPath = join(
|
||||
__dirname,
|
||||
'../../ios/Self.xcodeproj/project.pbxproj',
|
||||
);
|
||||
let projectContent: string;
|
||||
|
||||
beforeAll(() => {
|
||||
try {
|
||||
projectContent = readFileSync(projectPath, 'utf8');
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to read iOS project file at ${projectPath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the correct bundle identifier', () => {
|
||||
expect(projectContent).toMatch(
|
||||
/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*com\.warroom\.proofofpassport;/,
|
||||
);
|
||||
});
|
||||
|
||||
it('has the expected development team set', () => {
|
||||
expect(projectContent).toMatch(/DEVELOPMENT_TEAM\s*=\s*5B29R5LYHQ;/);
|
||||
});
|
||||
|
||||
it('includes GoogleService-Info.plist in resources', () => {
|
||||
expect(projectContent).toContain('GoogleService-Info.plist in Resources');
|
||||
});
|
||||
});
|
||||
600
app/tests/src/navigation/deeplinks.test.ts
Normal file
600
app/tests/src/navigation/deeplinks.test.ts
Normal file
@@ -0,0 +1,600 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type { SelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
import {
|
||||
handleUrl,
|
||||
parseAndValidateUrlParams,
|
||||
setupUniversalLinkListenerInNavigation,
|
||||
} from '@/navigation/deeplinks';
|
||||
|
||||
jest.mock('react-native', () => {
|
||||
const mockLinking = {
|
||||
addEventListener: jest.fn(),
|
||||
getInitialURL: jest.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
Linking: mockLinking,
|
||||
Platform: { OS: 'ios' },
|
||||
};
|
||||
});
|
||||
|
||||
const mockLinking = jest.requireMock('react-native').Linking as jest.Mocked<{
|
||||
addEventListener: jest.Mock;
|
||||
getInitialURL: jest.Mock;
|
||||
}>;
|
||||
|
||||
const mockPlatform = jest.requireMock('react-native').Platform as {
|
||||
OS: string;
|
||||
};
|
||||
|
||||
jest.mock('@/navigation', () => ({
|
||||
navigationRef: {
|
||||
navigate: jest.fn(),
|
||||
isReady: jest.fn(() => true),
|
||||
reset: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@/stores/userStore', () => {
|
||||
const mockUserStore = { default: { getState: jest.fn() } };
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
...mockUserStore,
|
||||
};
|
||||
});
|
||||
|
||||
const mockUserStore = jest.requireMock('@/stores/userStore') as {
|
||||
default: { getState: jest.Mock };
|
||||
};
|
||||
|
||||
let setDeepLinkUserDetails: jest.Mock;
|
||||
|
||||
describe('deeplinks', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
setDeepLinkUserDetails = jest.fn();
|
||||
mockLinking.getInitialURL.mockReset();
|
||||
mockLinking.addEventListener.mockReset();
|
||||
mockLinking.getInitialURL.mockResolvedValue(null as any);
|
||||
mockLinking.addEventListener.mockReturnValue({ remove: jest.fn() } as any);
|
||||
mockUserStore.default.getState.mockReturnValue({
|
||||
setDeepLinkUserDetails,
|
||||
});
|
||||
mockPlatform.OS = 'ios';
|
||||
});
|
||||
|
||||
describe('handleUrl', () => {
|
||||
it('handles selfApp parameter', () => {
|
||||
const selfApp = { sessionId: 'abc' };
|
||||
const url = `scheme://open?selfApp=${encodeURIComponent(JSON.stringify(selfApp))}`;
|
||||
|
||||
const mockSetSelfApp = jest.fn();
|
||||
const mockStartAppListener = jest.fn();
|
||||
|
||||
handleUrl(
|
||||
{
|
||||
getSelfAppState: () => ({
|
||||
setSelfApp: mockSetSelfApp,
|
||||
startAppListener: mockStartAppListener,
|
||||
}),
|
||||
} as unknown as SelfClient,
|
||||
url,
|
||||
);
|
||||
|
||||
expect(mockSetSelfApp).toHaveBeenCalledWith(selfApp);
|
||||
expect(mockStartAppListener).toHaveBeenCalledWith('abc');
|
||||
|
||||
const { navigationRef } = require('@/navigation');
|
||||
expect(navigationRef.navigate).toHaveBeenCalledWith('Prove');
|
||||
});
|
||||
|
||||
it('handles sessionId parameter', () => {
|
||||
const url = 'scheme://open?sessionId=123';
|
||||
const mockCleanSelfApp = jest.fn();
|
||||
const mockStartAppListener = jest.fn();
|
||||
|
||||
handleUrl(
|
||||
{
|
||||
getSelfAppState: () => ({
|
||||
setSelfApp: jest.fn(),
|
||||
startAppListener: mockStartAppListener,
|
||||
cleanSelfApp: mockCleanSelfApp,
|
||||
}),
|
||||
} as unknown as SelfClient,
|
||||
url,
|
||||
);
|
||||
|
||||
expect(mockCleanSelfApp).toHaveBeenCalledWith();
|
||||
expect(mockStartAppListener).toHaveBeenCalledWith('123');
|
||||
|
||||
const { navigationRef } = require('@/navigation');
|
||||
expect(navigationRef.navigate).toHaveBeenCalledWith('Prove');
|
||||
});
|
||||
|
||||
it('handles mock_passport parameter', () => {
|
||||
const mockData = { name: 'John', surname: 'Doe' };
|
||||
const url = `scheme://open?mock_passport=${encodeURIComponent(JSON.stringify(mockData))}`;
|
||||
handleUrl({} as SelfClient, url);
|
||||
|
||||
expect(setDeepLinkUserDetails).toHaveBeenCalledWith({
|
||||
name: 'John',
|
||||
surname: 'Doe',
|
||||
nationality: undefined,
|
||||
birthDate: undefined,
|
||||
gender: undefined,
|
||||
});
|
||||
const { navigationRef } = require('@/navigation');
|
||||
expect(navigationRef.reset).toHaveBeenCalledWith({
|
||||
index: 1,
|
||||
routes: [{ name: 'Home' }, { name: 'MockDataDeepLink' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('handles referrer parameter and navigates to HomeScreen for confirmation', () => {
|
||||
const referrer = '0x1234567890123456789012345678901234567890';
|
||||
const url = `scheme://open?referrer=${referrer}`;
|
||||
|
||||
const mockSetDeepLinkReferrer = jest.fn();
|
||||
mockUserStore.default.getState.mockReturnValue({
|
||||
setDeepLinkReferrer: mockSetDeepLinkReferrer,
|
||||
});
|
||||
|
||||
handleUrl({} as SelfClient, url);
|
||||
|
||||
expect(mockSetDeepLinkReferrer).toHaveBeenCalledWith(referrer);
|
||||
|
||||
const { navigationRef } = require('@/navigation');
|
||||
// Should navigate to HomeScreen, which will show confirmation modal
|
||||
expect(navigationRef.reset).toHaveBeenCalledWith({
|
||||
index: 0,
|
||||
routes: [{ name: 'Home' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates to QRCodeTrouble for invalid data', () => {
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const url = 'scheme://open?selfApp=%7Binvalid';
|
||||
handleUrl({} as SelfClient, url);
|
||||
|
||||
const { navigationRef } = require('@/navigation');
|
||||
expect(navigationRef.reset).toHaveBeenCalledWith({
|
||||
index: 1,
|
||||
routes: [{ name: 'Home' }, { name: 'QRCodeTrouble' }],
|
||||
});
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error parsing selfApp:',
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles sessionId with invalid characters', () => {
|
||||
const consoleWarnSpy = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {});
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const url = 'scheme://open?sessionId=abc<script>alert("xss")</script>';
|
||||
handleUrl({} as SelfClient, url);
|
||||
|
||||
const { navigationRef } = require('@/navigation');
|
||||
expect(navigationRef.reset).toHaveBeenCalledWith({
|
||||
index: 1,
|
||||
routes: [{ name: 'Home' }, { name: 'QRCodeTrouble' }],
|
||||
});
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Parameter sessionId failed validation:',
|
||||
'abc<script>alert("xss")</script>',
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'No sessionId, selfApp or valid OAuth parameters found in the data',
|
||||
);
|
||||
|
||||
consoleWarnSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects URLs with malformed parameters', () => {
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const url = 'scheme://open?sessionId=%ZZ'; // Invalid URL encoding
|
||||
handleUrl({} as SelfClient, url);
|
||||
|
||||
const { navigationRef } = require('@/navigation');
|
||||
expect(navigationRef.reset).toHaveBeenCalledWith({
|
||||
index: 1,
|
||||
routes: [{ name: 'Home' }, { name: 'QRCodeTrouble' }],
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles valid Turnkey OAuth redirect with code and state', () => {
|
||||
const consoleLogSpy = jest
|
||||
.spyOn(console, 'log')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const url =
|
||||
'https://redirect.self.xyz?scheme=https#code=4/0Ab32j93MfuUU-vJKJth_t0fnnPkg1O7&id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature';
|
||||
handleUrl({} as SelfClient, url);
|
||||
|
||||
const { navigationRef } = require('@/navigation');
|
||||
// Turnkey OAuth should return silently without navigation
|
||||
expect(navigationRef.navigate).not.toHaveBeenCalled();
|
||||
expect(navigationRef.reset).not.toHaveBeenCalled();
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'[Deeplinks] Turnkey OAuth redirect received with valid parameters',
|
||||
);
|
||||
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('navigates to QRCodeTrouble when only code is present (missing id_token)', () => {
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const url =
|
||||
'https://redirect.self.xyz?scheme=https#code=4/0Ab32j93MfuUU-vJKJth_t0fnnPkg1O7';
|
||||
handleUrl({} as SelfClient, url);
|
||||
|
||||
const { navigationRef } = require('@/navigation');
|
||||
// With just code and id_token validation removed, this should be accepted as valid OAuth
|
||||
expect(navigationRef.navigate).not.toHaveBeenCalled();
|
||||
expect(navigationRef.reset).not.toHaveBeenCalled();
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles valid Turnkey OAuth with only id_token (implicit flow)', () => {
|
||||
const consoleLogSpy = jest
|
||||
.spyOn(console, 'log')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const url =
|
||||
'https://redirect.self.xyz?scheme=https#id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature&scope=email%20profile';
|
||||
handleUrl({} as SelfClient, url);
|
||||
|
||||
const { navigationRef } = require('@/navigation');
|
||||
expect(navigationRef.navigate).not.toHaveBeenCalled();
|
||||
expect(navigationRef.reset).not.toHaveBeenCalled();
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'[Deeplinks] Turnkey OAuth redirect received with valid parameters',
|
||||
);
|
||||
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('navigates to QRCodeTrouble when neither code nor id_token is present', () => {
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const url =
|
||||
'https://redirect.self.xyz?scheme=https#scope=email%20profile';
|
||||
handleUrl({} as SelfClient, url);
|
||||
|
||||
const { navigationRef } = require('@/navigation');
|
||||
expect(navigationRef.reset).toHaveBeenCalledWith({
|
||||
index: 1,
|
||||
routes: [{ name: 'Home' }, { name: 'QRCodeTrouble' }],
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects Turnkey OAuth with invalid id_token format', () => {
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
// id_token with invalid characters (XSS attempt) - should be rejected
|
||||
// code is valid, but since id_token is invalid and rejected, code alone shouldn't trigger OAuth
|
||||
const url =
|
||||
'https://redirect.self.xyz?scheme=https#code=4/0Ab32j93&id_token=<script>alert("xss")</script>';
|
||||
handleUrl({} as SelfClient, url);
|
||||
|
||||
const { navigationRef } = require('@/navigation');
|
||||
// Code without valid id_token should still be accepted as valid OAuth (authorization code flow)
|
||||
// So this should NOT navigate to QRCodeTrouble
|
||||
expect(navigationRef.navigate).not.toHaveBeenCalled();
|
||||
expect(navigationRef.reset).not.toHaveBeenCalled();
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAndValidateUrlParams', () => {
|
||||
it('returns valid sessionId parameter', () => {
|
||||
const url = 'scheme://open?sessionId=abc123';
|
||||
const result = parseAndValidateUrlParams(url);
|
||||
expect(result).toEqual({ sessionId: 'abc123' });
|
||||
});
|
||||
|
||||
it('returns valid selfApp parameter', () => {
|
||||
const selfApp = { sessionId: 'abc' };
|
||||
const url = `scheme://open?selfApp=${encodeURIComponent(JSON.stringify(selfApp))}`;
|
||||
const result = parseAndValidateUrlParams(url);
|
||||
expect(result).toEqual({ selfApp: JSON.stringify(selfApp) });
|
||||
});
|
||||
|
||||
it('returns valid mock_passport parameter', () => {
|
||||
const mockData = { name: 'John', surname: 'Doe' };
|
||||
const url = `scheme://open?mock_passport=${encodeURIComponent(JSON.stringify(mockData))}`;
|
||||
const result = parseAndValidateUrlParams(url);
|
||||
expect(result).toEqual({ mock_passport: JSON.stringify(mockData) });
|
||||
});
|
||||
|
||||
it('filters out unexpected parameters', () => {
|
||||
const consoleWarnSpy = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const url =
|
||||
'scheme://open?sessionId=abc123&maliciousParam=evil&anotherBad=param';
|
||||
const result = parseAndValidateUrlParams(url);
|
||||
|
||||
expect(result).toEqual({ sessionId: 'abc123' });
|
||||
// Check both warnings were called, regardless of order
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(2);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Unexpected or invalid parameter ignored: maliciousParam',
|
||||
);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Unexpected or invalid parameter ignored: anotherBad',
|
||||
);
|
||||
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects sessionId with invalid characters', () => {
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const url = 'scheme://open?sessionId=abc<script>alert("xss")</script>';
|
||||
const result = parseAndValidateUrlParams(url);
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Parameter sessionId failed validation:',
|
||||
'abc<script>alert("xss")</script>',
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles URL-encoded characters correctly', () => {
|
||||
const sessionId = 'abc-123_TEST';
|
||||
const url = `scheme://open?sessionId=${encodeURIComponent(sessionId)}`;
|
||||
const result = parseAndValidateUrlParams(url);
|
||||
expect(result).toEqual({ sessionId });
|
||||
});
|
||||
|
||||
it('handles complex JSON in selfApp parameter', () => {
|
||||
const complexSelfApp = {
|
||||
sessionId: 'abc123',
|
||||
nested: { data: 'value', numbers: [1, 2, 3] },
|
||||
special: 'chars with spaces and symbols',
|
||||
};
|
||||
const url = `scheme://open?selfApp=${encodeURIComponent(JSON.stringify(complexSelfApp))}`;
|
||||
const result = parseAndValidateUrlParams(url);
|
||||
expect(result).toEqual({ selfApp: JSON.stringify(complexSelfApp) });
|
||||
});
|
||||
|
||||
it('handles malformed URL encoding gracefully', () => {
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const url = 'scheme://open?sessionId=%ZZ'; // Invalid URL encoding
|
||||
const result = parseAndValidateUrlParams(url);
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error decoding parameter sessionId:',
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('ignores empty parameter values', () => {
|
||||
const url = 'scheme://open?sessionId=&selfApp=validValue';
|
||||
const result = parseAndValidateUrlParams(url);
|
||||
expect(result).toEqual({ selfApp: 'validValue' });
|
||||
});
|
||||
|
||||
it('handles duplicate keys correctly', () => {
|
||||
// Test what actually happens with duplicate keys in query-string library
|
||||
const url = 'scheme://open?sessionId=valid1&sessionId=valid2';
|
||||
const result = parseAndValidateUrlParams(url);
|
||||
// query-string typically handles duplicates by taking the last value or creating an array
|
||||
// We'll accept either a valid sessionId or empty object if it creates an array
|
||||
expect(
|
||||
result.sessionId === undefined || typeof result.sessionId === 'string',
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('handles completely malformed URLs', () => {
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const url = 'not-a-valid-url-at-all';
|
||||
const result = parseAndValidateUrlParams(url);
|
||||
|
||||
expect(result).toEqual({});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles URLs with no query parameters', () => {
|
||||
const url = 'scheme://open';
|
||||
const result = parseAndValidateUrlParams(url);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('handles URLs with empty query string', () => {
|
||||
const url = 'scheme://open?';
|
||||
const result = parseAndValidateUrlParams(url);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('validates sessionId with allowed characters', () => {
|
||||
const validSessionIds = [
|
||||
'abc123',
|
||||
'ABC_123',
|
||||
'test-value',
|
||||
'123456789',
|
||||
'a_b-c_123',
|
||||
];
|
||||
|
||||
validSessionIds.forEach(sessionId => {
|
||||
const url = `scheme://open?sessionId=${sessionId}`;
|
||||
const result = parseAndValidateUrlParams(url);
|
||||
expect(result).toEqual({ sessionId });
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects sessionId with disallowed characters', () => {
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const invalidSessionIds = [
|
||||
'abc@123',
|
||||
'test value',
|
||||
'test#value',
|
||||
'test$%^&*()',
|
||||
];
|
||||
|
||||
invalidSessionIds.forEach(sessionId => {
|
||||
const url = `scheme://open?sessionId=${encodeURIComponent(sessionId)}`;
|
||||
const result = parseAndValidateUrlParams(url);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles non-string parameter values', () => {
|
||||
const consoleWarnSpy = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
// This might happen if query-string returns an array for duplicate keys
|
||||
const mockParseUrl = jest.fn().mockReturnValue({
|
||||
query: { sessionId: ['value1', 'value2'] },
|
||||
});
|
||||
|
||||
// Temporarily mock the parseUrl import
|
||||
jest.doMock('query-string', () => ({ parseUrl: mockParseUrl }));
|
||||
|
||||
// Re-require to get the mocked version
|
||||
jest.resetModules();
|
||||
const {
|
||||
parseAndValidateUrlParams: mockedParser,
|
||||
} = require('@/navigation/deeplinks');
|
||||
|
||||
const url = 'scheme://open?sessionId=duplicate&sessionId=values';
|
||||
const result = mockedParser(url);
|
||||
|
||||
expect(result).toEqual({});
|
||||
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Turnkey OAuth parameter validation', () => {
|
||||
it('returns valid code and state parameters', () => {
|
||||
const url =
|
||||
'https://redirect.self.xyz?scheme=https#code=4/0Ab32j93MfuUU-vJKJth_t0fnnPkg1O7&state=state_abc';
|
||||
const result = parseAndValidateUrlParams(url);
|
||||
expect(result.code).toBe('4/0Ab32j93MfuUU-vJKJth_t0fnnPkg1O7');
|
||||
expect(result.state).toBe('state_abc');
|
||||
});
|
||||
|
||||
it('returns id_token and scope parameters', () => {
|
||||
const url =
|
||||
'https://redirect.self.xyz?scheme=https#id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature&scope=email%20profile';
|
||||
const result = parseAndValidateUrlParams(url);
|
||||
expect(result.id_token).toBeTruthy();
|
||||
expect(result.scope).toBe('email profile');
|
||||
});
|
||||
|
||||
it('handles code with forward slashes (Google OAuth format)', () => {
|
||||
const url =
|
||||
'https://redirect.self.xyz?scheme=https#code=4/0Ab32j93MfuUU-vJKJth_t0fnnPkg1O7CMFt3YS0RKh9yreKIqdMg4qZh6MaIkfonjNlJFw';
|
||||
const result = parseAndValidateUrlParams(url);
|
||||
expect(result.code).toBe(
|
||||
'4/0Ab32j93MfuUU-vJKJth_t0fnnPkg1O7CMFt3YS0RKh9yreKIqdMg4qZh6MaIkfonjNlJFw',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects id_token with invalid characters (XSS attempt)', () => {
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
// URL with only an invalid id_token - this should reject the id_token
|
||||
const url =
|
||||
'https://redirect.self.xyz#id_token=<script>alert("xss")</script>';
|
||||
const result = parseAndValidateUrlParams(url);
|
||||
|
||||
// The invalid id_token should be rejected
|
||||
expect(result.id_token).toBeUndefined();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Parameter id_token failed validation:',
|
||||
expect.any(String),
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('filters out unexpected OAuth-related parameters', () => {
|
||||
const consoleWarnSpy = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const url =
|
||||
'https://redirect.self.xyz?scheme=https#code=4/0Ab32j93&state=state_abc&error=access_denied&error_description=user_denied';
|
||||
const result = parseAndValidateUrlParams(url);
|
||||
|
||||
expect(result.code).toBe('4/0Ab32j93');
|
||||
expect(result.state).toBe('state_abc');
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Unexpected or invalid parameter ignored: error',
|
||||
);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Unexpected or invalid parameter ignored: error_description',
|
||||
);
|
||||
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
it('setup listener registers and cleans up', () => {
|
||||
const remove = jest.fn();
|
||||
mockLinking.getInitialURL.mockResolvedValue(undefined as any);
|
||||
mockLinking.addEventListener.mockReturnValue({ remove });
|
||||
|
||||
const cleanup = setupUniversalLinkListenerInNavigation();
|
||||
expect(mockLinking.addEventListener).toHaveBeenCalled();
|
||||
cleanup();
|
||||
expect(remove).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -11,15 +11,15 @@ import { useEffect } from 'react';
|
||||
import { render, screen } from '@testing-library/react-native';
|
||||
|
||||
import { LoggerProvider, useLogger } from '@/providers/loggerProvider';
|
||||
import { AppLogger, NfcLogger } from '@/utils/logger';
|
||||
import { AppLogger, NfcLogger } from '@/services/logging';
|
||||
|
||||
// Mock the native logger bridge
|
||||
jest.mock('@/utils/logger/nativeLoggerBridge', () => ({
|
||||
jest.mock('@/services/logging/logger/nativeLoggerBridge', () => ({
|
||||
cleanup: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the logger utilities
|
||||
jest.mock('@/utils/logger', () => ({
|
||||
jest.mock('@/services/logging', () => ({
|
||||
AppLogger: {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
usePassport,
|
||||
} from '@/providers/passportDataProvider';
|
||||
|
||||
import { mockAdapters } from '../../utils/selfClientProvider';
|
||||
import { mockAdapters } from '../../__setup__/selfClientProvider';
|
||||
|
||||
const listeners = new Map();
|
||||
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { render, waitFor } from '@testing-library/react-native';
|
||||
|
||||
import { initRemoteConfig } from '@/config/remoteConfig';
|
||||
import {
|
||||
RemoteConfigProvider,
|
||||
useRemoteConfig,
|
||||
} from '@/providers/remoteConfigProvider';
|
||||
import { initRemoteConfig } from '@/RemoteConfig';
|
||||
|
||||
// Mock the RemoteConfig module
|
||||
jest.mock('@/RemoteConfig', () => ({
|
||||
jest.mock('@/config/remoteConfig', () => ({
|
||||
initRemoteConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { renderHook } from '@testing-library/react-native';
|
||||
|
||||
jest.mock('@/images/512w.png', () => 'mock-512w-image');
|
||||
jest.mock('@/images/nfc.png', () => 'mock-nfc-image');
|
||||
jest.mock('@/assets/images/512w.png', () => 'mock-512w-image');
|
||||
jest.mock('@/assets/images/nfc.png', () => 'mock-nfc-image');
|
||||
jest.mock('react-native-localize', () => {
|
||||
const getLocales = jest.fn(() => [
|
||||
{
|
||||
|
||||
231
app/tests/src/proving/loadingScreenStateText.test.ts
Normal file
231
app/tests/src/proving/loadingScreenStateText.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type { ProvingStateType } from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
import {
|
||||
getLoadingScreenText,
|
||||
getProvingTimeEstimate,
|
||||
} from '@/proving/loadingScreenStateText';
|
||||
|
||||
describe('stateLoadingScreenText', () => {
|
||||
// Default values for basic tests
|
||||
const defaultSignatureAlgorithm = 'RSA';
|
||||
const defaultCurveOrExponent = '';
|
||||
const defaultType = 'register' as const;
|
||||
|
||||
// Helper function to test a state has a response
|
||||
const testStateHasResponse = (state: ProvingStateType) => {
|
||||
it(`should return a response for ${state} state`, () => {
|
||||
const result = getLoadingScreenText(
|
||||
state,
|
||||
defaultSignatureAlgorithm,
|
||||
defaultCurveOrExponent,
|
||||
defaultType,
|
||||
);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.actionText).toBeDefined();
|
||||
expect(result.actionText.length).toBeGreaterThan(0);
|
||||
expect(result.estimatedTime).toBeDefined();
|
||||
expect(result.estimatedTime.length).toBeGreaterThan(0);
|
||||
});
|
||||
};
|
||||
|
||||
// Test all possible states
|
||||
const states: ProvingStateType[] = [
|
||||
'account_recovery_choice',
|
||||
'completed',
|
||||
'error',
|
||||
'failure',
|
||||
'fetching_data',
|
||||
'idle',
|
||||
'init_tee_connexion',
|
||||
'listening_for_status',
|
||||
'passport_data_not_found',
|
||||
'passport_not_supported',
|
||||
'post_proving',
|
||||
'proving',
|
||||
'ready_to_prove',
|
||||
'validating_document',
|
||||
];
|
||||
|
||||
describe('All states should have a response', () => {
|
||||
states.forEach(state => {
|
||||
testStateHasResponse(state);
|
||||
});
|
||||
});
|
||||
|
||||
// Test edge cases
|
||||
describe('Edge cases', () => {
|
||||
it('should handle undefined state', () => {
|
||||
const result = getLoadingScreenText(
|
||||
undefined as ProvingStateType,
|
||||
defaultSignatureAlgorithm,
|
||||
defaultCurveOrExponent,
|
||||
defaultType,
|
||||
);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.actionText).toBeDefined();
|
||||
expect(result.estimatedTime).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle unknown state', () => {
|
||||
const result = getLoadingScreenText(
|
||||
'unknown' as ProvingStateType,
|
||||
defaultSignatureAlgorithm,
|
||||
defaultCurveOrExponent,
|
||||
defaultType,
|
||||
);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.actionText).toBeDefined();
|
||||
expect(result.estimatedTime).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle undefined metadata', () => {
|
||||
const result = getLoadingScreenText('proving', '', '', defaultType);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.actionText).toBeDefined();
|
||||
expect(result.estimatedTime).toBe('30 - 90 SECONDS'); // Should use default time estimate
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLoadingScreenText with passport metadata', () => {
|
||||
const rsaSignatureAlgorithm = 'RSA';
|
||||
const rsaCurveOrExponent = '65537';
|
||||
|
||||
it('should use algorithm information to estimate proving time', () => {
|
||||
const result = getLoadingScreenText(
|
||||
'proving',
|
||||
rsaSignatureAlgorithm,
|
||||
rsaCurveOrExponent,
|
||||
defaultType,
|
||||
);
|
||||
|
||||
// Should use RSA (4 SECONDS)
|
||||
expect(result.estimatedTime).toBe('4 SECONDS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProvingTimeEstimate', () => {
|
||||
it('should return default time when parameters are undefined', () => {
|
||||
const result = getProvingTimeEstimate('', '', 'register');
|
||||
expect(result).toBe('30 - 90 SECONDS');
|
||||
});
|
||||
|
||||
describe('RSA algorithms', () => {
|
||||
it.each([
|
||||
['RSA', '65537', 'register', '4 SECONDS'], // Common RSA exponent
|
||||
['RSA', '3', 'register', '4 SECONDS'], // Another common RSA exponent
|
||||
['RSA', '65537', 'dsc', '2 SECONDS'], // DSC proof
|
||||
['RSA', '3', 'dsc', '2 SECONDS'], // DSC proof
|
||||
])(
|
||||
'should return correct time for %s with exponent %s and type %s',
|
||||
(algorithm, exponent, type, expectedTime) => {
|
||||
const result = getProvingTimeEstimate(
|
||||
algorithm,
|
||||
exponent,
|
||||
type as 'dsc' | 'register',
|
||||
);
|
||||
expect(result).toBe(expectedTime);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['RSAPSS', '65537', 'register', '6 SECONDS'],
|
||||
['RSAPSS', '3', 'register', '6 SECONDS'],
|
||||
['RSAPSS', '65537', 'dsc', '3 SECONDS'],
|
||||
['RSAPSS', '3', 'dsc', '3 SECONDS'],
|
||||
])(
|
||||
'should return correct time for %s with exponent %s and type %s',
|
||||
(algorithm, exponent, type, expectedTime) => {
|
||||
const result = getProvingTimeEstimate(
|
||||
algorithm,
|
||||
exponent,
|
||||
type as 'dsc' | 'register',
|
||||
);
|
||||
expect(result).toBe(expectedTime);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('ECDSA curves', () => {
|
||||
it.each([
|
||||
['secp224r1', 'register', '50 SECONDS'],
|
||||
['brainpoolP224r1', 'register', '50 SECONDS'],
|
||||
['secp224r1', 'dsc', '25 SECONDS'],
|
||||
['brainpoolP224r1', 'dsc', '25 SECONDS'],
|
||||
])(
|
||||
'should return correct time for 224-bit curve %s with type %s',
|
||||
(curve, type, expectedTime) => {
|
||||
const result = getProvingTimeEstimate(
|
||||
'ECDSA',
|
||||
curve,
|
||||
type as 'dsc' | 'register',
|
||||
);
|
||||
expect(result).toBe(expectedTime);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['secp256r1', 'register', '50 SECONDS'],
|
||||
['brainpoolP256r1', 'register', '50 SECONDS'],
|
||||
['secp256r1', 'dsc', '25 SECONDS'],
|
||||
['brainpoolP256r1', 'dsc', '25 SECONDS'],
|
||||
])(
|
||||
'should return correct time for 256-bit curve %s with type %s',
|
||||
(curve, type, expectedTime) => {
|
||||
const result = getProvingTimeEstimate(
|
||||
'ECDSA',
|
||||
curve,
|
||||
type as 'dsc' | 'register',
|
||||
);
|
||||
expect(result).toBe(expectedTime);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['secp384r1', 'register', '90 SECONDS'],
|
||||
['brainpoolP384r1', 'register', '90 SECONDS'],
|
||||
['secp384r1', 'dsc', '45 SECONDS'],
|
||||
['brainpoolP384r1', 'dsc', '45 SECONDS'],
|
||||
])(
|
||||
'should return correct time for 384-bit curve %s with type %s',
|
||||
(curve, type, expectedTime) => {
|
||||
const result = getProvingTimeEstimate(
|
||||
'ECDSA',
|
||||
curve,
|
||||
type as 'dsc' | 'register',
|
||||
);
|
||||
expect(result).toBe(expectedTime);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['secp521r1', 'register', '200 SECONDS'],
|
||||
['brainpoolP512r1', 'register', '200 SECONDS'],
|
||||
['secp521r1', 'dsc', '100 SECONDS'],
|
||||
['brainpoolP512r1', 'dsc', '100 SECONDS'],
|
||||
])(
|
||||
'should return correct time for 512/521-bit curve %s with type %s',
|
||||
(curve, type, expectedTime) => {
|
||||
const result = getProvingTimeEstimate(
|
||||
'ECDSA',
|
||||
curve,
|
||||
type as 'dsc' | 'register',
|
||||
);
|
||||
expect(result).toBe(expectedTime);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should return default time when algorithm is not recognized', () => {
|
||||
const result = getProvingTimeEstimate(
|
||||
'UNKNOWN_ALGORITHM',
|
||||
'',
|
||||
'register',
|
||||
);
|
||||
expect(result).toBe('30 - 90 SECONDS');
|
||||
});
|
||||
});
|
||||
});
|
||||
86
app/tests/src/proving/provingUtils.test.ts
Normal file
86
app/tests/src/proving/provingUtils.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import forge from 'node-forge';
|
||||
|
||||
import { encryptAES256GCM, getPayload, getWSDbRelayerUrl } from '@/proving';
|
||||
|
||||
describe('provingUtils', () => {
|
||||
it('encryptAES256GCM encrypts and decrypts correctly', () => {
|
||||
const key = forge.random.getBytesSync(32);
|
||||
const plaintext = 'hello world';
|
||||
const encrypted = encryptAES256GCM(plaintext, forge.util.createBuffer(key));
|
||||
|
||||
const decipher = forge.cipher.createDecipher(
|
||||
'AES-GCM',
|
||||
forge.util.createBuffer(key),
|
||||
);
|
||||
decipher.start({
|
||||
iv: forge.util.createBuffer(
|
||||
Buffer.from(encrypted.nonce).toString('binary'),
|
||||
),
|
||||
tagLength: 128,
|
||||
tag: forge.util.createBuffer(
|
||||
Buffer.from(encrypted.auth_tag).toString('binary'),
|
||||
),
|
||||
});
|
||||
decipher.update(
|
||||
forge.util.createBuffer(
|
||||
Buffer.from(encrypted.cipher_text).toString('binary'),
|
||||
),
|
||||
);
|
||||
const success = decipher.finish();
|
||||
const decrypted = decipher.output.toString();
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('getPayload returns disclose payload', () => {
|
||||
const inputs = { foo: 'bar' };
|
||||
const payload = getPayload(
|
||||
inputs,
|
||||
'disclose',
|
||||
'vc_and_disclose',
|
||||
'https',
|
||||
'https://example.com',
|
||||
2,
|
||||
'0xabc',
|
||||
);
|
||||
expect(payload).toEqual({
|
||||
type: 'disclose',
|
||||
endpointType: 'https',
|
||||
endpoint: 'https://example.com',
|
||||
onchain: false,
|
||||
circuit: { name: 'vc_and_disclose', inputs: JSON.stringify(inputs) },
|
||||
version: 2,
|
||||
userDefinedData: '0xabc',
|
||||
selfDefinedData: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('getPayload returns register payload', () => {
|
||||
const payload = getPayload(
|
||||
{ a: 1 },
|
||||
'register',
|
||||
'register_circuit',
|
||||
'celo',
|
||||
'https://self.xyz',
|
||||
);
|
||||
expect(payload).toEqual({
|
||||
type: 'register',
|
||||
onchain: true,
|
||||
endpointType: 'celo',
|
||||
circuit: { name: 'register_circuit', inputs: JSON.stringify({ a: 1 }) },
|
||||
});
|
||||
});
|
||||
|
||||
it('getWSDbRelayerUrl handles endpoint types', () => {
|
||||
expect(getWSDbRelayerUrl('celo')).toContain('websocket.self.xyz');
|
||||
expect(getWSDbRelayerUrl('https')).toContain('websocket.self.xyz');
|
||||
expect(getWSDbRelayerUrl('staging_celo')).toContain(
|
||||
'websocket.staging.self.xyz',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,20 +10,18 @@ import { DocumentEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
|
||||
import {
|
||||
checkAndUpdateRegistrationStates,
|
||||
getAlternativeCSCA,
|
||||
} from '@/utils/proving/validateDocument';
|
||||
} from '@/proving/validateDocument';
|
||||
import analytics from '@/services/analytics';
|
||||
|
||||
// Mock the analytics module to avoid side effects in tests
|
||||
jest.mock('@/utils/analytics', () => {
|
||||
jest.mock('@/services/analytics', () => {
|
||||
// Create mock inside factory to avoid temporal dead zone
|
||||
const mockTrackEvent = jest.fn();
|
||||
|
||||
return () => ({
|
||||
return jest.fn(() => ({
|
||||
trackEvent: mockTrackEvent,
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
const mockTrackEvent = jest.requireMock('@/utils/analytics')()
|
||||
.trackEvent as jest.Mock;
|
||||
|
||||
// Mock the passport data provider to avoid database operations
|
||||
const mockGetAllDocumentsDirectlyFromKeychain = jest.fn();
|
||||
const mockLoadSelectedDocumentDirectlyFromKeychain = jest.fn();
|
||||
@@ -148,9 +146,15 @@ jest.mock('@selfxyz/common/utils/passports/validate', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// Get reference to the mocked trackEvent function
|
||||
let mockTrackEvent: jest.Mock;
|
||||
|
||||
describe('getAlternativeCSCA', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Get the mocked trackEvent from the analytics module
|
||||
const mockAnalytics = jest.mocked(analytics);
|
||||
mockTrackEvent = mockAnalytics().trackEvent as jest.Mock;
|
||||
});
|
||||
|
||||
it('should return public keys in Record format for Aadhaar with valid public keys', () => {
|
||||
@@ -240,6 +244,10 @@ describe('checkAndUpdateRegistrationStates', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Get the mocked trackEvent from the analytics module
|
||||
const mockAnalytics = jest.mocked(analytics);
|
||||
mockTrackEvent = mockAnalytics().trackEvent as jest.Mock;
|
||||
|
||||
mockGetState.mockReturnValue(
|
||||
buildState({
|
||||
passportAlt: { csca1: 'cert1' },
|
||||
@@ -96,8 +96,8 @@ jest.mock('@selfxyz/mobile-sdk-alpha/components', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/images/icons/arrow_left.svg', () => 'ArrowLeft');
|
||||
jest.mock('@/images/icons/logo_white.svg', () => 'LogoWhite');
|
||||
jest.mock('@/assets/icons/arrow_left.svg', () => 'ArrowLeft');
|
||||
jest.mock('@/assets/icons/logo_white.svg', () => 'LogoWhite');
|
||||
|
||||
const mockUseNavigation = useNavigation as jest.MockedFunction<
|
||||
typeof useNavigation
|
||||
|
||||
@@ -48,7 +48,7 @@ jest.mock('@react-navigation/native', () => ({
|
||||
useFocusEffect: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/NavBar/WebViewNavBar', () => ({
|
||||
jest.mock('@/components/navbar/WebViewNavBar', () => ({
|
||||
WebViewNavBar: ({ children, onBackPress, ...props }: any) => (
|
||||
<mock-webview-navbar {...props}>
|
||||
<mock-pressable testID="icon-x" onPress={onBackPress} />
|
||||
|
||||
234
app/tests/src/services/analytics.test.ts
Normal file
234
app/tests/src/services/analytics.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import analytics from '@/services/analytics';
|
||||
|
||||
// Mock the Segment client
|
||||
jest.mock('@/config/segment', () => ({
|
||||
createSegmentClient: jest.fn(() => ({
|
||||
track: jest.fn(),
|
||||
screen: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('analytics', () => {
|
||||
const { trackEvent, trackScreenView } = analytics();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('trackEvent', () => {
|
||||
it('should handle basic event tracking without properties', () => {
|
||||
expect(() => trackEvent('test_event')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle event tracking with valid properties', () => {
|
||||
const properties = {
|
||||
reason: 'test_reason',
|
||||
duration_seconds: 1.5,
|
||||
attempt_count: 3,
|
||||
string_prop: 'test',
|
||||
number_prop: 42,
|
||||
boolean_prop: true,
|
||||
};
|
||||
|
||||
expect(() => trackEvent('test_event', properties)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle event tracking with null properties', () => {
|
||||
expect(() => trackEvent('test_event', null)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle event tracking with undefined properties', () => {
|
||||
expect(() => trackEvent('test_event', undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should filter out non-JSON-compatible values', () => {
|
||||
const properties = {
|
||||
valid_string: 'test',
|
||||
valid_number: 42,
|
||||
valid_boolean: true,
|
||||
valid_null: null,
|
||||
function_prop: () => {},
|
||||
undefined_prop: undefined,
|
||||
symbol_prop: Symbol('test'),
|
||||
};
|
||||
|
||||
expect(() => trackEvent('test_event', properties)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle nested objects and arrays', () => {
|
||||
const properties = {
|
||||
nested_object: {
|
||||
string: 'test',
|
||||
number: 42,
|
||||
boolean: true,
|
||||
null_value: null,
|
||||
},
|
||||
array_prop: ['string', 42, true, null],
|
||||
nested_array: [
|
||||
{ id: 1, name: 'test' },
|
||||
{ id: 2, name: 'test2' },
|
||||
],
|
||||
};
|
||||
|
||||
expect(() => trackEvent('test_event', properties)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle duration formatting correctly', () => {
|
||||
const properties = {
|
||||
duration_seconds: 1.23456789,
|
||||
};
|
||||
|
||||
expect(() => trackEvent('test_event', properties)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle invalid duration values gracefully', () => {
|
||||
const properties = {
|
||||
duration_seconds: 'not_a_number',
|
||||
};
|
||||
|
||||
expect(() => trackEvent('test_event', properties)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle complex nested structures', () => {
|
||||
const properties = {
|
||||
user: {
|
||||
id: 123,
|
||||
name: 'John Doe',
|
||||
preferences: {
|
||||
theme: 'dark',
|
||||
notifications: true,
|
||||
settings: {
|
||||
language: 'en',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
timestamp: Date.now(),
|
||||
version: '1.0.0',
|
||||
tags: ['test', 'analytics'],
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => trackEvent('test_event', properties)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle arrays with mixed types', () => {
|
||||
const properties = {
|
||||
mixed_array: [
|
||||
'string',
|
||||
42,
|
||||
true,
|
||||
null,
|
||||
{ nested: 'object' },
|
||||
[1, 2, 3],
|
||||
],
|
||||
};
|
||||
|
||||
expect(() => trackEvent('test_event', properties)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle empty objects and arrays', () => {
|
||||
const properties = {
|
||||
empty_object: {},
|
||||
empty_array: [],
|
||||
nested_empty: {
|
||||
empty_obj: {},
|
||||
empty_arr: [],
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => trackEvent('test_event', properties)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackScreenView', () => {
|
||||
it('should handle screen tracking without properties', () => {
|
||||
expect(() => trackScreenView('test_screen')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle screen tracking with properties', () => {
|
||||
const properties = {
|
||||
reason: 'navigation',
|
||||
duration_seconds: 5.2,
|
||||
user_id: 123,
|
||||
};
|
||||
|
||||
expect(() => trackScreenView('test_screen', properties)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle screen tracking with complex properties', () => {
|
||||
const properties = {
|
||||
navigation: {
|
||||
from: 'home',
|
||||
to: 'settings',
|
||||
method: 'button_click',
|
||||
},
|
||||
user_context: {
|
||||
is_logged_in: true,
|
||||
subscription_tier: 'premium',
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => trackScreenView('test_screen', properties)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle circular references gracefully', () => {
|
||||
const circularObj: any = { name: 'test' };
|
||||
circularObj.self = circularObj;
|
||||
|
||||
const properties = {
|
||||
circular_reference: circularObj,
|
||||
valid_prop: 'test',
|
||||
};
|
||||
|
||||
expect(() => trackEvent('test_event', properties)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle very large numbers', () => {
|
||||
const properties = {
|
||||
large_number: Number.MAX_SAFE_INTEGER,
|
||||
small_number: Number.MIN_SAFE_INTEGER,
|
||||
float_number: 3.14159265359,
|
||||
};
|
||||
|
||||
expect(() => trackEvent('test_event', properties)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle special string values', () => {
|
||||
const properties = {
|
||||
empty_string: '',
|
||||
unicode_string: '🚀🌟💫',
|
||||
special_chars: '!@#$%^&*()',
|
||||
newlines: 'line1\nline2\r\nline3',
|
||||
tabs: 'col1\tcol2\tcol3',
|
||||
};
|
||||
|
||||
expect(() => trackEvent('test_event', properties)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle deeply nested structures', () => {
|
||||
const deepObj: any = {};
|
||||
let current = deepObj;
|
||||
|
||||
// Create a deeply nested object
|
||||
for (let i = 0; i < 10; i++) {
|
||||
current.nested = { level: i };
|
||||
current = current.nested;
|
||||
}
|
||||
|
||||
const properties = {
|
||||
deep_structure: deepObj,
|
||||
simple_prop: 'test',
|
||||
};
|
||||
|
||||
expect(() => trackEvent('test_event', properties)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
497
app/tests/src/services/cloud-backup.test.ts
Normal file
497
app/tests/src/services/cloud-backup.test.ts
Normal file
@@ -0,0 +1,497 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
import { CloudStorage } from 'react-native-cloud-storage';
|
||||
// Import after mocks
|
||||
import { GDrive } from '@robinbobin/react-native-google-drive-api-wrapper';
|
||||
import { renderHook } from '@testing-library/react-native';
|
||||
|
||||
import { useBackupMnemonic } from '@/services/cloud-backup';
|
||||
import { createGDrive } from '@/services/cloud-backup/google';
|
||||
|
||||
type SupportedPlatforms = 'ios' | 'android';
|
||||
|
||||
jest.mock('react-native', () => {
|
||||
const mockPlatform: { OS: SupportedPlatforms; select: jest.Mock } = {
|
||||
OS: 'ios',
|
||||
select: jest.fn(() => 'ios'),
|
||||
};
|
||||
|
||||
const mockAppState = {
|
||||
addEventListener: jest.fn(() => ({ remove: jest.fn() })),
|
||||
removeEventListener: jest.fn(),
|
||||
};
|
||||
|
||||
const mockNativeModules = {
|
||||
NativeLoggerBridge: {},
|
||||
};
|
||||
|
||||
const MockNativeEventEmitter = jest.fn(() => ({
|
||||
addListener: jest.fn(),
|
||||
removeAllListeners: jest.fn(),
|
||||
}));
|
||||
|
||||
return {
|
||||
Platform: mockPlatform,
|
||||
AppState: mockAppState,
|
||||
NativeModules: mockNativeModules,
|
||||
NativeEventEmitter: MockNativeEventEmitter,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-native-biometrics', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({
|
||||
simplePrompt: jest.fn(async () => ({ success: true })),
|
||||
isSensorAvailable: jest.fn(async () => ({
|
||||
available: true,
|
||||
biometryType: 'TouchID',
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockPlatform = jest.requireMock('react-native').Platform as {
|
||||
OS: SupportedPlatforms;
|
||||
select: jest.Mock;
|
||||
};
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('react-native-cloud-storage', () => ({
|
||||
CloudStorage: {
|
||||
setProviderOptions: jest.fn(),
|
||||
mkdir: jest.fn(),
|
||||
writeFile: jest.fn(),
|
||||
exists: jest.fn(),
|
||||
readFile: jest.fn(),
|
||||
rmdir: jest.fn(),
|
||||
},
|
||||
CloudStorageScope: {
|
||||
AppData: 'AppData',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@robinbobin/react-native-google-drive-api-wrapper', () => ({
|
||||
GDrive: jest.fn(),
|
||||
APP_DATA_FOLDER_ID: 'mock-app-data-folder',
|
||||
MIME_TYPES: {
|
||||
application: {
|
||||
json: 'application/json',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@/services/cloud-backup/google', () => ({
|
||||
createGDrive: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('ethers', () => ({
|
||||
ethers: {
|
||||
Mnemonic: {
|
||||
isValidMnemonic: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock implementations
|
||||
const mockGDriveInstance = {
|
||||
accessToken: '',
|
||||
files: {
|
||||
newMultipartUploader: jest.fn().mockReturnValue({
|
||||
setData: jest.fn().mockReturnThis(),
|
||||
setDataMimeType: jest.fn().mockReturnThis(),
|
||||
setRequestBody: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn(),
|
||||
}),
|
||||
list: jest.fn(),
|
||||
getText: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockMnemonic = {
|
||||
phrase:
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||
password: '',
|
||||
wordlist: { locale: 'en' },
|
||||
entropy: '0x00000000000000000000000000000000',
|
||||
};
|
||||
|
||||
describe('cloudBackup', () => {
|
||||
let originalPlatform: SupportedPlatforms;
|
||||
let consoleSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
originalPlatform = mockPlatform.OS;
|
||||
// Suppress console.error during tests to avoid cluttering output
|
||||
consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
(GDrive as jest.Mock).mockImplementation(() => mockGDriveInstance);
|
||||
(ethers.Mnemonic.isValidMnemonic as jest.Mock).mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockPlatform.OS = originalPlatform;
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('useBackupMnemonic hook', () => {
|
||||
it('should return upload, download, and disableBackup functions', () => {
|
||||
const { result } = renderHook(() => useBackupMnemonic());
|
||||
|
||||
expect(result.current).toHaveProperty('upload');
|
||||
expect(result.current).toHaveProperty('download');
|
||||
expect(result.current).toHaveProperty('disableBackup');
|
||||
expect(typeof result.current.upload).toBe('function');
|
||||
expect(typeof result.current.download).toBe('function');
|
||||
expect(typeof result.current.disableBackup).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('upload function - iOS', () => {
|
||||
beforeEach(() => {
|
||||
mockPlatform.OS = 'ios';
|
||||
});
|
||||
|
||||
it('should upload mnemonic to iCloud successfully', async () => {
|
||||
(CloudStorage.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||
(CloudStorage.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useBackupMnemonic());
|
||||
|
||||
await expect(
|
||||
result.current.upload(mockMnemonic),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(CloudStorage.mkdir).toHaveBeenCalledWith('/@selfxyz/mobile-app');
|
||||
expect(CloudStorage.writeFile).toHaveBeenCalledWith(
|
||||
'//@selfxyz/mobile-app/encrypted-private-key',
|
||||
JSON.stringify(mockMnemonic),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle folder already exists error gracefully', async () => {
|
||||
const folderExistsError = new Error('folder already exists');
|
||||
(CloudStorage.mkdir as jest.Mock).mockRejectedValue(folderExistsError);
|
||||
(CloudStorage.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useBackupMnemonic());
|
||||
|
||||
await expect(
|
||||
result.current.upload(mockMnemonic),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(CloudStorage.writeFile).toHaveBeenCalledWith(
|
||||
'//@selfxyz/mobile-app/encrypted-private-key',
|
||||
JSON.stringify(mockMnemonic),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for empty mnemonic', async () => {
|
||||
const { result } = renderHook(() => useBackupMnemonic());
|
||||
|
||||
await expect(
|
||||
result.current.upload({
|
||||
phrase: '',
|
||||
password: '',
|
||||
wordlist: { locale: 'en' },
|
||||
entropy: '',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'Mnemonic not set yet. Did the user see the recovery phrase?',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for null mnemonic', async () => {
|
||||
const { result } = renderHook(() => useBackupMnemonic());
|
||||
|
||||
await expect(result.current.upload(null as any)).rejects.toThrow(
|
||||
'Mnemonic not set yet. Did the user see the recovery phrase?',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when mkdir fails with non-existing folder error', async () => {
|
||||
const permissionError = new Error('permission denied');
|
||||
(CloudStorage.mkdir as jest.Mock).mockRejectedValue(permissionError);
|
||||
|
||||
const { result } = renderHook(() => useBackupMnemonic());
|
||||
|
||||
await expect(result.current.upload(mockMnemonic)).rejects.toThrow(
|
||||
'permission denied',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upload function - Android', () => {
|
||||
beforeEach(() => {
|
||||
mockPlatform.OS = 'android';
|
||||
});
|
||||
|
||||
it('should upload mnemonic to Google Drive successfully', async () => {
|
||||
(createGDrive as jest.Mock).mockResolvedValue(mockGDriveInstance);
|
||||
mockGDriveInstance.files
|
||||
.newMultipartUploader()
|
||||
.execute.mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(() => useBackupMnemonic());
|
||||
|
||||
await expect(
|
||||
result.current.upload(mockMnemonic),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(createGDrive).toHaveBeenCalled();
|
||||
expect(
|
||||
mockGDriveInstance.files.newMultipartUploader().setData,
|
||||
).toHaveBeenCalledWith(JSON.stringify(mockMnemonic));
|
||||
expect(
|
||||
mockGDriveInstance.files.newMultipartUploader().execute,
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error when user cancels Google sign-in', async () => {
|
||||
(createGDrive as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
const { result } = renderHook(() => useBackupMnemonic());
|
||||
|
||||
await expect(result.current.upload(mockMnemonic)).rejects.toThrow(
|
||||
'User canceled Google sign-in',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('download function - iOS', () => {
|
||||
beforeEach(() => {
|
||||
mockPlatform.OS = 'ios';
|
||||
});
|
||||
|
||||
it('should download and parse mnemonic from iCloud successfully', async () => {
|
||||
(CloudStorage.exists as jest.Mock).mockResolvedValue(true);
|
||||
(CloudStorage.readFile as jest.Mock).mockResolvedValue(
|
||||
JSON.stringify(mockMnemonic),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useBackupMnemonic());
|
||||
|
||||
const downloaded = await result.current.download();
|
||||
|
||||
expect(CloudStorage.exists).toHaveBeenCalledWith(
|
||||
'//@selfxyz/mobile-app/encrypted-private-key',
|
||||
);
|
||||
expect(CloudStorage.readFile).toHaveBeenCalledWith(
|
||||
'//@selfxyz/mobile-app/encrypted-private-key',
|
||||
);
|
||||
expect(downloaded).toEqual(mockMnemonic);
|
||||
expect(ethers.Mnemonic.isValidMnemonic).toHaveBeenCalledWith(
|
||||
mockMnemonic.phrase,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when backup file does not exist', async () => {
|
||||
(CloudStorage.exists as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
const { result } = renderHook(() => useBackupMnemonic());
|
||||
|
||||
await expect(result.current.download()).rejects.toThrow(
|
||||
'Couldnt find the encrypted backup, did you back it up previously?',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for malformed mnemonic JSON', async () => {
|
||||
(CloudStorage.exists as jest.Mock).mockResolvedValue(true);
|
||||
(CloudStorage.readFile as jest.Mock).mockResolvedValue('invalid json');
|
||||
|
||||
const { result } = renderHook(() => useBackupMnemonic());
|
||||
|
||||
await expect(result.current.download()).rejects.toThrow(
|
||||
'Failed to parse mnemonic backup: Invalid JSON format in mnemonic backup',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid mnemonic phrase', async () => {
|
||||
const invalidMnemonic = { ...mockMnemonic, phrase: 'invalid phrase' };
|
||||
(CloudStorage.exists as jest.Mock).mockResolvedValue(true);
|
||||
(CloudStorage.readFile as jest.Mock).mockResolvedValue(
|
||||
JSON.stringify(invalidMnemonic),
|
||||
);
|
||||
(ethers.Mnemonic.isValidMnemonic as jest.Mock).mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useBackupMnemonic());
|
||||
|
||||
await expect(result.current.download()).rejects.toThrow(
|
||||
'Failed to parse mnemonic backup: Invalid mnemonic phrase: not a valid BIP39 mnemonic',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for missing mnemonic properties', async () => {
|
||||
const incompleteMnemonic = { phrase: 'valid phrase', password: '' }; // missing wordlist and entropy
|
||||
(CloudStorage.exists as jest.Mock).mockResolvedValue(true);
|
||||
(CloudStorage.readFile as jest.Mock).mockResolvedValue(
|
||||
JSON.stringify(incompleteMnemonic),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useBackupMnemonic());
|
||||
|
||||
await expect(result.current.download()).rejects.toThrow(
|
||||
'Failed to parse mnemonic backup: Invalid mnemonic structure: missing required properties (phrase, password, wordlist, entropy)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('download function - Android', () => {
|
||||
beforeEach(() => {
|
||||
mockPlatform.OS = 'android';
|
||||
});
|
||||
|
||||
it('should download and parse mnemonic from Google Drive successfully', async () => {
|
||||
(createGDrive as jest.Mock).mockResolvedValue(mockGDriveInstance);
|
||||
mockGDriveInstance.files.list.mockResolvedValue({
|
||||
files: [{ id: 'file-id', name: 'encrypted-private-key' }],
|
||||
});
|
||||
mockGDriveInstance.files.getText.mockResolvedValue(
|
||||
JSON.stringify(mockMnemonic),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useBackupMnemonic());
|
||||
|
||||
const downloaded = await result.current.download();
|
||||
|
||||
expect(createGDrive).toHaveBeenCalled();
|
||||
expect(mockGDriveInstance.files.list).toHaveBeenCalledWith({
|
||||
spaces: 'mock-app-data-folder',
|
||||
q: "name = 'encrypted-private-key'",
|
||||
});
|
||||
expect(mockGDriveInstance.files.getText).toHaveBeenCalledWith('file-id');
|
||||
expect(downloaded).toEqual(mockMnemonic);
|
||||
expect(ethers.Mnemonic.isValidMnemonic).toHaveBeenCalledWith(
|
||||
mockMnemonic.phrase,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when user cancels Google sign-in', async () => {
|
||||
(createGDrive as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
const { result } = renderHook(() => useBackupMnemonic());
|
||||
|
||||
await expect(result.current.download()).rejects.toThrow(
|
||||
'User canceled Google sign-in',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when backup file does not exist', async () => {
|
||||
(createGDrive as jest.Mock).mockResolvedValue(mockGDriveInstance);
|
||||
mockGDriveInstance.files.list.mockResolvedValue({
|
||||
files: [],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useBackupMnemonic());
|
||||
|
||||
await expect(result.current.download()).rejects.toThrow(
|
||||
'Couldnt find the encrypted backup, did you back it up previously?',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for malformed mnemonic JSON', async () => {
|
||||
(createGDrive as jest.Mock).mockResolvedValue(mockGDriveInstance);
|
||||
mockGDriveInstance.files.list.mockResolvedValue({
|
||||
files: [{ id: 'file-id', name: 'encrypted-private-key' }],
|
||||
});
|
||||
mockGDriveInstance.files.getText.mockResolvedValue('invalid json');
|
||||
|
||||
const { result } = renderHook(() => useBackupMnemonic());
|
||||
|
||||
await expect(result.current.download()).rejects.toThrow(
|
||||
'Failed to parse mnemonic backup: Invalid JSON format in mnemonic backup',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid mnemonic phrase', async () => {
|
||||
(createGDrive as jest.Mock).mockResolvedValue(mockGDriveInstance);
|
||||
mockGDriveInstance.files.list.mockResolvedValue({
|
||||
files: [{ id: 'file-id', name: 'encrypted-private-key' }],
|
||||
});
|
||||
mockGDriveInstance.files.getText.mockResolvedValue(
|
||||
JSON.stringify({ ...mockMnemonic, phrase: 'invalid phrase' }),
|
||||
);
|
||||
(ethers.Mnemonic.isValidMnemonic as jest.Mock).mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useBackupMnemonic());
|
||||
|
||||
await expect(result.current.download()).rejects.toThrow(
|
||||
'Failed to parse mnemonic backup: Invalid mnemonic phrase: not a valid BIP39 mnemonic',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for missing mnemonic properties', async () => {
|
||||
const incompleteMnemonic = { phrase: 'valid phrase', password: '' }; // missing wordlist and entropy
|
||||
(createGDrive as jest.Mock).mockResolvedValue(mockGDriveInstance);
|
||||
mockGDriveInstance.files.list.mockResolvedValue({
|
||||
files: [{ id: 'file-id', name: 'encrypted-private-key' }],
|
||||
});
|
||||
mockGDriveInstance.files.getText.mockResolvedValue(
|
||||
JSON.stringify(incompleteMnemonic),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useBackupMnemonic());
|
||||
|
||||
await expect(result.current.download()).rejects.toThrow(
|
||||
'Failed to parse mnemonic backup: Invalid mnemonic structure: missing required properties (phrase, password, wordlist, entropy)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableBackup function - iOS', () => {
|
||||
beforeEach(() => {
|
||||
mockPlatform.OS = 'ios';
|
||||
});
|
||||
|
||||
it('should remove backup folder from iCloud', async () => {
|
||||
(CloudStorage.rmdir as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useBackupMnemonic());
|
||||
|
||||
await expect(result.current.disableBackup()).resolves.toBeUndefined();
|
||||
expect(CloudStorage.rmdir).toHaveBeenCalledWith('/@selfxyz/mobile-app', {
|
||||
recursive: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableBackup function - Android', () => {
|
||||
beforeEach(() => {
|
||||
mockPlatform.OS = 'android';
|
||||
});
|
||||
|
||||
it('should delete backup files from Google Drive', async () => {
|
||||
(createGDrive as jest.Mock).mockResolvedValue(mockGDriveInstance);
|
||||
mockGDriveInstance.files.list.mockResolvedValue({
|
||||
files: [{ id: 'file-id' }, { id: 'file-id2' }],
|
||||
});
|
||||
mockGDriveInstance.files.delete.mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useBackupMnemonic());
|
||||
|
||||
await expect(result.current.disableBackup()).resolves.toBeUndefined();
|
||||
expect(mockGDriveInstance.files.list).toHaveBeenCalledWith({
|
||||
spaces: 'mock-app-data-folder',
|
||||
q: "name = 'encrypted-private-key'",
|
||||
});
|
||||
expect(mockGDriveInstance.files.delete).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'file-id',
|
||||
);
|
||||
expect(mockGDriveInstance.files.delete).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'file-id2',
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve when user cancels Google sign-in', async () => {
|
||||
(createGDrive as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
const { result } = renderHook(() => useBackupMnemonic());
|
||||
|
||||
await expect(result.current.disableBackup()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
132
app/tests/src/services/notifications/notificationService.test.ts
Normal file
132
app/tests/src/services/notifications/notificationService.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
jest.unmock('@/services/notifications/notificationService');
|
||||
|
||||
// Mock Platform and PermissionsAndroid without requiring react-native to avoid memory issues
|
||||
// Prefix with 'mock' so Jest allows referencing them in the mock factory
|
||||
const mockPlatform = {
|
||||
OS: 'ios',
|
||||
Version: 14,
|
||||
};
|
||||
|
||||
const mockPermissionsAndroid = {
|
||||
request: jest.fn(),
|
||||
PERMISSIONS: {
|
||||
POST_NOTIFICATIONS: 'android.permission.POST_NOTIFICATIONS',
|
||||
},
|
||||
RESULTS: {
|
||||
GRANTED: 'granted',
|
||||
DENIED: 'denied',
|
||||
NEVER_ASK_AGAIN: 'never_ask_again',
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('react-native', () => ({
|
||||
Platform: mockPlatform,
|
||||
PermissionsAndroid: mockPermissionsAndroid,
|
||||
}));
|
||||
|
||||
jest.mock('@react-native-firebase/messaging', () => {
|
||||
const instance = {
|
||||
requestPermission: jest.fn(),
|
||||
getToken: jest.fn(),
|
||||
};
|
||||
const mockFn = () => instance;
|
||||
mockFn._instance = instance;
|
||||
mockFn.AuthorizationStatus = { AUTHORIZED: 1, PROVISIONAL: 2 };
|
||||
return { __esModule: true, default: mockFn };
|
||||
});
|
||||
|
||||
let messagingMock: {
|
||||
requestPermission: jest.Mock;
|
||||
getToken: jest.Mock;
|
||||
};
|
||||
|
||||
global.fetch = jest.fn();
|
||||
|
||||
describe('notificationService', () => {
|
||||
let service: any; // Using any here since we're dynamically requiring the module in tests
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Reset Platform to default iOS values (using the mock objects directly)
|
||||
mockPlatform.OS = 'ios';
|
||||
mockPlatform.Version = 14;
|
||||
|
||||
messagingMock = require('@react-native-firebase/messaging').default
|
||||
._instance;
|
||||
messagingMock.requestPermission.mockReset();
|
||||
messagingMock.getToken.mockReset();
|
||||
service = require('@/services/notifications/notificationService');
|
||||
(fetch as jest.Mock).mockResolvedValue({ ok: true, text: jest.fn() });
|
||||
messagingMock.requestPermission.mockResolvedValue(1);
|
||||
messagingMock.getToken.mockResolvedValue('token');
|
||||
mockPermissionsAndroid.request.mockClear();
|
||||
});
|
||||
|
||||
describe('requestNotificationPermission', () => {
|
||||
it('grants permission on Android', async () => {
|
||||
mockPlatform.OS = 'android';
|
||||
mockPlatform.Version = 34;
|
||||
mockPermissionsAndroid.request.mockResolvedValue('granted');
|
||||
|
||||
const result = await service.requestNotificationPermission();
|
||||
expect(result).toBe(true);
|
||||
expect(messagingMock.requestPermission).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles denied permission on Android', async () => {
|
||||
mockPlatform.OS = 'android';
|
||||
mockPlatform.Version = 34;
|
||||
mockPermissionsAndroid.request.mockResolvedValue('denied');
|
||||
|
||||
const result = await service.requestNotificationPermission();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('handles never_ask_again permission on Android', async () => {
|
||||
mockPlatform.OS = 'android';
|
||||
mockPlatform.Version = 34;
|
||||
mockPermissionsAndroid.request.mockResolvedValue('never_ask_again');
|
||||
|
||||
const result = await service.requestNotificationPermission();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false on error', async () => {
|
||||
mockPlatform.OS = 'ios';
|
||||
messagingMock.requestPermission.mockRejectedValueOnce(new Error('fail'));
|
||||
const result = await service.requestNotificationPermission();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFCMToken', () => {
|
||||
it('returns token', async () => {
|
||||
messagingMock.getToken.mockResolvedValueOnce('abc');
|
||||
const token = await service.getFCMToken();
|
||||
expect(token).toBe('abc');
|
||||
});
|
||||
|
||||
it('returns null when error', async () => {
|
||||
messagingMock.getToken.mockRejectedValueOnce(new Error('err'));
|
||||
const token = await service.getFCMToken();
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerDeviceToken', () => {
|
||||
it('posts token', async () => {
|
||||
mockPlatform.OS = 'ios';
|
||||
const response = { ok: true, text: jest.fn() };
|
||||
(fetch as jest.Mock).mockResolvedValue(response);
|
||||
await service.registerDeviceToken('123', 'tok', true);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://notification.staging.self.xyz/register-token',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
57
app/tests/src/utils/crypto/ethers.test.ts
Normal file
57
app/tests/src/utils/crypto/ethers.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
// Register crypto polyfills
|
||||
import { ethers } from 'ethers';
|
||||
|
||||
import '@/utils/crypto/ethers';
|
||||
|
||||
describe('ethers crypto polyfills', () => {
|
||||
it('randomBytes returns requested length and unique values', () => {
|
||||
const a = ethers.randomBytes(16);
|
||||
const b = ethers.randomBytes(16);
|
||||
|
||||
expect(a).toHaveLength(16);
|
||||
expect(b).toHaveLength(16);
|
||||
expect(ethers.hexlify(a)).not.toBe(ethers.hexlify(b));
|
||||
});
|
||||
|
||||
it('computeHmac matches known vector', () => {
|
||||
const result = ethers.computeHmac(
|
||||
'sha256',
|
||||
ethers.toUtf8Bytes('key'),
|
||||
ethers.toUtf8Bytes('data'),
|
||||
);
|
||||
expect(ethers.hexlify(result)).toBe(
|
||||
'0x5031fe3d989c6d1537a013fa6e739da23463fdaec3b70137d828e36ace221bd0',
|
||||
);
|
||||
});
|
||||
|
||||
it('pbkdf2 derives expected key', () => {
|
||||
const derived = ethers.pbkdf2(
|
||||
ethers.toUtf8Bytes('password'),
|
||||
ethers.toUtf8Bytes('salt'),
|
||||
1000,
|
||||
32,
|
||||
'sha256',
|
||||
);
|
||||
expect(ethers.hexlify(derived)).toBe(
|
||||
'0x632c2812e46d4604102ba7618e9d6d7d2f8128f6266b4a03264d2a0460b7dcb3',
|
||||
);
|
||||
});
|
||||
|
||||
it('sha256 hashes data correctly', () => {
|
||||
const digest = ethers.sha256(ethers.toUtf8Bytes('hello'));
|
||||
expect(ethers.hexlify(digest)).toBe(
|
||||
'0x2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824',
|
||||
);
|
||||
});
|
||||
|
||||
it('sha512 hashes data correctly', () => {
|
||||
const digest = ethers.sha512(ethers.toUtf8Bytes('hello'));
|
||||
expect(ethers.hexlify(digest)).toBe(
|
||||
'0x9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043',
|
||||
);
|
||||
});
|
||||
});
|
||||
48
app/tests/src/utils/modalCallbackRegistry.test.ts
Normal file
48
app/tests/src/utils/modalCallbackRegistry.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import {
|
||||
getModalCallbacks,
|
||||
registerModalCallbacks,
|
||||
unregisterModalCallbacks,
|
||||
} from '@/utils/modalCallbackRegistry';
|
||||
|
||||
describe('modalCallbackRegistry', () => {
|
||||
const registeredIds: number[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
registeredIds.forEach(id => unregisterModalCallbacks(id));
|
||||
registeredIds.length = 0;
|
||||
});
|
||||
|
||||
it('should register and retrieve callbacks', () => {
|
||||
const callbacks = { onButtonPress: jest.fn(), onModalDismiss: jest.fn() };
|
||||
const id = registerModalCallbacks(callbacks);
|
||||
registeredIds.push(id);
|
||||
|
||||
expect(getModalCallbacks(id)).toBe(callbacks);
|
||||
});
|
||||
|
||||
it('should unregister callbacks', () => {
|
||||
const callbacks = { onButtonPress: jest.fn(), onModalDismiss: jest.fn() };
|
||||
const id = registerModalCallbacks(callbacks);
|
||||
unregisterModalCallbacks(id);
|
||||
|
||||
expect(getModalCallbacks(id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should generate unique ids', () => {
|
||||
const id1 = registerModalCallbacks({
|
||||
onButtonPress: jest.fn(),
|
||||
onModalDismiss: jest.fn(),
|
||||
});
|
||||
const id2 = registerModalCallbacks({
|
||||
onButtonPress: jest.fn(),
|
||||
onModalDismiss: jest.fn(),
|
||||
});
|
||||
registeredIds.push(id1, id2);
|
||||
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
});
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
unsafe_getPointsPrivateKey,
|
||||
unsafe_getPrivateKey,
|
||||
} from '@/providers/authProvider';
|
||||
import { isSuccessfulStatus, makeApiRequest } from '@/utils/points/api';
|
||||
import { POINTS_API_BASE_URL } from '@/utils/points/constants';
|
||||
import { getPointsAddress } from '@/utils/points/utils';
|
||||
import { isSuccessfulStatus, makeApiRequest } from '@/services/points/api';
|
||||
import { POINTS_API_BASE_URL } from '@/services/points/constants';
|
||||
import { getPointsAddress } from '@/services/points/utils';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('axios');
|
||||
@@ -20,7 +20,7 @@ jest.mock('@/providers/authProvider', () => ({
|
||||
unsafe_getPrivateKey: jest.fn(),
|
||||
unsafe_getPointsPrivateKey: jest.fn(),
|
||||
}));
|
||||
jest.mock('@/utils/points/utils', () => ({
|
||||
jest.mock('@/services/points/utils', () => ({
|
||||
getPointsAddress: jest.fn(),
|
||||
}));
|
||||
jest.mock('ethers', () => ({
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { recordReferralPointEvent } from '@/utils/points/recordEvents';
|
||||
import { registerReferralPoints } from '@/utils/points/registerEvents';
|
||||
import { getPointsAddress } from '@/utils/points/utils';
|
||||
import { recordReferralPointEvent } from '@/services/points/recordEvents';
|
||||
import { registerReferralPoints } from '@/services/points/registerEvents';
|
||||
import { getPointsAddress } from '@/services/points/utils';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@/utils/points/registerEvents', () => ({
|
||||
jest.mock('@/services/points/registerEvents', () => ({
|
||||
registerReferralPoints: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/utils/points/utils', () => ({
|
||||
jest.mock('@/services/points/utils', () => ({
|
||||
getPointsAddress: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -30,11 +30,11 @@ jest.mock('@/stores/pointEventStore', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@/utils/points/eventPolling', () => ({
|
||||
jest.mock('@/services/points/eventPolling', () => ({
|
||||
pollEventProcessingStatus: jest.fn(() => Promise.resolve('completed')),
|
||||
}));
|
||||
|
||||
jest.mock('@/utils/points/api', () => ({
|
||||
jest.mock('@/services/points/api', () => ({
|
||||
isSuccessfulStatus: jest.fn(
|
||||
(status: number) => status === 200 || status === 202,
|
||||
),
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { makeApiRequest } from '@/utils/points/api';
|
||||
import { registerReferralPoints } from '@/utils/points/registerEvents';
|
||||
import { makeApiRequest } from '@/services/points/api';
|
||||
import { registerReferralPoints } from '@/services/points/registerEvents';
|
||||
|
||||
// Mock the API module
|
||||
jest.mock('@/utils/points/api', () => ({
|
||||
jest.mock('@/services/points/api', () => ({
|
||||
makeApiRequest: jest.fn(),
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user