mirror of
https://github.com/selfxyz/self.git
synced 2026-04-05 03:00:53 -04:00
SEL-496: Add Firebase Remote Config and dev feature flag screen (#735)
* feat: add remote config support * update lock * tweak config logic. add feature flag viewing screen * add tests * allow for local overriding of feature flags * save local override work * save wip * clean up ui * update screen to handle multi value types * fix tests * cr feedback and fix tests * remote config upates. fix tests, codex feedback
This commit is contained in:
21
app/App.tsx
21
app/App.tsx
@@ -12,6 +12,7 @@ import { AuthProvider } from './src/providers/authProvider';
|
||||
import { DatabaseProvider } from './src/providers/databaseProvider';
|
||||
import { NotificationTrackingProvider } from './src/providers/notificationTrackingProvider';
|
||||
import { PassportProvider } from './src/providers/passportDataProvider';
|
||||
import { RemoteConfigProvider } from './src/providers/remoteConfigProvider';
|
||||
import { initSentry, wrapWithSentry } from './src/Sentry';
|
||||
|
||||
initSentry();
|
||||
@@ -22,15 +23,17 @@ function App(): React.JSX.Element {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<YStack f={1} h="100%" w="100%">
|
||||
<AuthProvider>
|
||||
<PassportProvider>
|
||||
<DatabaseProvider>
|
||||
<NotificationTrackingProvider>
|
||||
<AppNavigation />
|
||||
</NotificationTrackingProvider>
|
||||
</DatabaseProvider>
|
||||
</PassportProvider>
|
||||
</AuthProvider>
|
||||
<RemoteConfigProvider>
|
||||
<AuthProvider>
|
||||
<PassportProvider>
|
||||
<DatabaseProvider>
|
||||
<NotificationTrackingProvider>
|
||||
<AppNavigation />
|
||||
</NotificationTrackingProvider>
|
||||
</DatabaseProvider>
|
||||
</PassportProvider>
|
||||
</AuthProvider>
|
||||
</RemoteConfigProvider>
|
||||
</YStack>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,11 @@ PODS:
|
||||
- Firebase/Messaging (10.24.0):
|
||||
- Firebase/CoreOnly
|
||||
- FirebaseMessaging (~> 10.24.0)
|
||||
- Firebase/RemoteConfig (10.24.0):
|
||||
- Firebase/CoreOnly
|
||||
- FirebaseRemoteConfig (~> 10.24.0)
|
||||
- FirebaseABTesting (10.29.0):
|
||||
- FirebaseCore (~> 10.0)
|
||||
- FirebaseAnalytics (10.24.0):
|
||||
- FirebaseAnalytics/AdIdSupport (= 10.24.0)
|
||||
- FirebaseCore (~> 10.0)
|
||||
@@ -58,6 +63,16 @@ PODS:
|
||||
- GoogleUtilities/Reachability (~> 7.8)
|
||||
- GoogleUtilities/UserDefaults (~> 7.8)
|
||||
- nanopb (< 2.30911.0, >= 2.30908.0)
|
||||
- FirebaseRemoteConfig (10.24.0):
|
||||
- FirebaseABTesting (~> 10.0)
|
||||
- FirebaseCore (~> 10.0)
|
||||
- FirebaseInstallations (~> 10.0)
|
||||
- FirebaseRemoteConfigInterop (~> 10.23)
|
||||
- FirebaseSharedSwift (~> 10.0)
|
||||
- GoogleUtilities/Environment (~> 7.8)
|
||||
- "GoogleUtilities/NSData+zlib (~> 7.8)"
|
||||
- FirebaseRemoteConfigInterop (10.29.0)
|
||||
- FirebaseSharedSwift (10.29.0)
|
||||
- fmt (9.1.0)
|
||||
- glog (0.3.5)
|
||||
- GoogleAppMeasurement (10.24.0):
|
||||
@@ -1689,6 +1704,10 @@ PODS:
|
||||
- FirebaseCoreExtension
|
||||
- React-Core
|
||||
- RNFBApp
|
||||
- RNFBRemoteConfig (19.3.0):
|
||||
- Firebase/RemoteConfig (= 10.24.0)
|
||||
- React-Core
|
||||
- RNFBApp
|
||||
- RNGestureHandler (2.24.0):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
@@ -1901,6 +1920,7 @@ DEPENDENCIES:
|
||||
- RNDeviceInfo (from `../../node_modules/react-native-device-info`)
|
||||
- "RNFBApp (from `../../node_modules/@react-native-firebase/app`)"
|
||||
- "RNFBMessaging (from `../../node_modules/@react-native-firebase/messaging`)"
|
||||
- "RNFBRemoteConfig (from `../../node_modules/@react-native-firebase/remote-config`)"
|
||||
- RNGestureHandler (from `../../node_modules/react-native-gesture-handler`)
|
||||
- RNKeychain (from `../../node_modules/react-native-keychain`)
|
||||
- RNLocalize (from `../../node_modules/react-native-localize`)
|
||||
@@ -1919,12 +1939,16 @@ SPEC REPOS:
|
||||
trunk:
|
||||
- AppAuth
|
||||
- Firebase
|
||||
- FirebaseABTesting
|
||||
- FirebaseAnalytics
|
||||
- FirebaseCore
|
||||
- FirebaseCoreExtension
|
||||
- FirebaseCoreInternal
|
||||
- FirebaseInstallations
|
||||
- FirebaseMessaging
|
||||
- FirebaseRemoteConfig
|
||||
- FirebaseRemoteConfigInterop
|
||||
- FirebaseSharedSwift
|
||||
- GoogleAppMeasurement
|
||||
- GoogleDataTransport
|
||||
- GoogleUtilities
|
||||
@@ -2092,6 +2116,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../../node_modules/@react-native-firebase/app"
|
||||
RNFBMessaging:
|
||||
:path: "../../node_modules/@react-native-firebase/messaging"
|
||||
RNFBRemoteConfig:
|
||||
:path: "../../node_modules/@react-native-firebase/remote-config"
|
||||
RNGestureHandler:
|
||||
:path: "../../node_modules/react-native-gesture-handler"
|
||||
RNKeychain:
|
||||
@@ -2129,12 +2155,16 @@ SPEC CHECKSUMS:
|
||||
DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5
|
||||
FBLazyVector: 430e10366de01d1e3d57374500b1b150fe482e6d
|
||||
Firebase: 91fefd38712feb9186ea8996af6cbdef41473442
|
||||
FirebaseABTesting: d87f56707159bae64e269757a6e963d490f2eebe
|
||||
FirebaseAnalytics: b5efc493eb0f40ec560b04a472e3e1a15d39ca13
|
||||
FirebaseCore: 11dc8a16dfb7c5e3c3f45ba0e191a33ac4f50894
|
||||
FirebaseCoreExtension: 705ca5b14bf71d2564a0ddc677df1fc86ffa600f
|
||||
FirebaseCoreInternal: df84dd300b561c27d5571684f389bf60b0a5c934
|
||||
FirebaseInstallations: 913cf60d0400ebd5d6b63a28b290372ab44590dd
|
||||
FirebaseMessaging: 4d52717dd820707cc4eadec5eb981b4832ec8d5d
|
||||
FirebaseRemoteConfig: 95dddc50496b37eef199dadce850d5652b534b43
|
||||
FirebaseRemoteConfigInterop: 6efda51fb5e2f15b16585197e26eaa09574e8a4d
|
||||
FirebaseSharedSwift: 20530f495084b8d840f78a100d8c5ee613375f6e
|
||||
fmt: 4c2741a687cc09f0634a2e2c72a838b99f1ff120
|
||||
glog: 69ef571f3de08433d766d614c73a9838a06bf7eb
|
||||
GoogleAppMeasurement: f3abf08495ef2cba7829f15318c373b8d9226491
|
||||
@@ -2217,6 +2247,7 @@ SPEC CHECKSUMS:
|
||||
RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047
|
||||
RNFBApp: 4097f75673f8b42a7cd1ba17e6ea85a94b45e4d1
|
||||
RNFBMessaging: 92325b0d5619ac90ef023a23cfd16fd3b91d0a88
|
||||
RNFBRemoteConfig: a569bacaa410acfcaba769370e53a787f80fd13b
|
||||
RNGestureHandler: 9c3877d98d4584891b69d16ebca855ac46507f4d
|
||||
RNKeychain: 4990d9be2916c60f9ed4f8c484fcd7ced4828b86
|
||||
RNLocalize: 15463c4d79c7da45230064b4adcf5e9bb984667e
|
||||
|
||||
@@ -32,6 +32,17 @@ jest.mock('@react-native-firebase/messaging', () => {
|
||||
});
|
||||
});
|
||||
|
||||
jest.mock('@react-native-firebase/remote-config', () => {
|
||||
const mockValue = { asBoolean: jest.fn(() => false) };
|
||||
const mockConfig = {
|
||||
setDefaults: jest.fn(),
|
||||
setConfigSettings: jest.fn(),
|
||||
fetchAndActivate: jest.fn(() => Promise.resolve(true)),
|
||||
getValue: jest.fn(() => mockValue),
|
||||
};
|
||||
return () => mockConfig;
|
||||
});
|
||||
|
||||
// Mock react-native-haptic-feedback
|
||||
jest.mock('react-native-haptic-feedback', () => ({
|
||||
trigger: jest.fn(),
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"@react-native-community/netinfo": "^11.4.1",
|
||||
"@react-native-firebase/app": "^19.0.1",
|
||||
"@react-native-firebase/messaging": "^19.0.1",
|
||||
"@react-native-firebase/remote-config": "^19.0.1",
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"@react-navigation/native-stack": "^7.2.0",
|
||||
"@robinbobin/react-native-google-drive-api-wrapper": "^2.2.3",
|
||||
|
||||
230
app/src/RemoteConfig.ts
Normal file
230
app/src/RemoteConfig.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import remoteConfig from '@react-native-firebase/remote-config';
|
||||
|
||||
export type FeatureFlagValue = string | boolean | number;
|
||||
|
||||
interface LocalOverride {
|
||||
[key: string]: FeatureFlagValue;
|
||||
}
|
||||
|
||||
const LOCAL_OVERRIDES_KEY = 'feature_flag_overrides';
|
||||
|
||||
const defaultFlags: Record<string, FeatureFlagValue> = {
|
||||
aesop: false,
|
||||
};
|
||||
|
||||
// Helper function to detect and parse remote config values
|
||||
const getRemoteConfigValue = (
|
||||
key: string,
|
||||
defaultValue: FeatureFlagValue,
|
||||
): FeatureFlagValue => {
|
||||
const configValue = remoteConfig().getValue(key);
|
||||
|
||||
if (typeof defaultValue === 'boolean') {
|
||||
return configValue.asBoolean();
|
||||
} else if (typeof defaultValue === 'number') {
|
||||
return configValue.asNumber();
|
||||
} else if (typeof defaultValue === 'string') {
|
||||
return configValue.asString();
|
||||
}
|
||||
|
||||
// Fallback: try to infer type from the remote config value
|
||||
const stringValue = configValue.asString();
|
||||
if (stringValue === 'true' || stringValue === 'false') {
|
||||
return configValue.asBoolean();
|
||||
}
|
||||
if (!Number.isNaN(Number(stringValue)) && stringValue !== '') {
|
||||
return configValue.asNumber();
|
||||
}
|
||||
return stringValue;
|
||||
};
|
||||
|
||||
// Local override management
|
||||
export const getLocalOverrides = async (): Promise<LocalOverride> => {
|
||||
try {
|
||||
const overrides = await AsyncStorage.getItem(LOCAL_OVERRIDES_KEY);
|
||||
if (!overrides) {
|
||||
return {};
|
||||
}
|
||||
return JSON.parse(overrides);
|
||||
} catch (error) {
|
||||
console.error('Failed to get local overrides:', error);
|
||||
|
||||
// If JSON parsing fails, clear the corrupt data
|
||||
if (error instanceof SyntaxError) {
|
||||
try {
|
||||
await AsyncStorage.removeItem(LOCAL_OVERRIDES_KEY);
|
||||
} catch (removeError) {
|
||||
console.error('Failed to clear corrupt local overrides:', removeError);
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const setLocalOverride = async (
|
||||
flag: string,
|
||||
value: FeatureFlagValue,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const overrides = await getLocalOverrides();
|
||||
overrides[flag] = value;
|
||||
await AsyncStorage.setItem(LOCAL_OVERRIDES_KEY, JSON.stringify(overrides));
|
||||
} catch (error) {
|
||||
console.error('Failed to set local override:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const clearLocalOverride = async (flag: string): Promise<void> => {
|
||||
try {
|
||||
const overrides = await getLocalOverrides();
|
||||
delete overrides[flag];
|
||||
await AsyncStorage.setItem(LOCAL_OVERRIDES_KEY, JSON.stringify(overrides));
|
||||
} catch (error) {
|
||||
console.error('Failed to clear local override:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const clearAllLocalOverrides = async (): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.removeItem(LOCAL_OVERRIDES_KEY);
|
||||
} catch (error) {
|
||||
console.error('Failed to clear all local overrides:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const initRemoteConfig = async () => {
|
||||
await remoteConfig().setDefaults(defaultFlags);
|
||||
await remoteConfig().setConfigSettings({
|
||||
minimumFetchIntervalMillis: __DEV__ ? 0 : 3600000,
|
||||
});
|
||||
try {
|
||||
await remoteConfig().fetchAndActivate();
|
||||
} catch (err) {
|
||||
console.log('Remote config fetch failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
export const getFeatureFlag = async <T extends FeatureFlagValue>(
|
||||
flag: string,
|
||||
defaultValue: T,
|
||||
): Promise<T> => {
|
||||
try {
|
||||
// Check local overrides first
|
||||
const localOverrides = await getLocalOverrides();
|
||||
if (Object.prototype.hasOwnProperty.call(localOverrides, flag)) {
|
||||
return localOverrides[flag] as T;
|
||||
}
|
||||
|
||||
// Return default value for string flags
|
||||
if (typeof defaultValue === 'string') {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// Fall back to remote config for number and boolean flags
|
||||
return getRemoteConfigValue(flag, defaultValue) as T;
|
||||
} catch (error) {
|
||||
console.error('Failed to get feature flag:', error);
|
||||
return defaultValue;
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllFeatureFlags = async (): Promise<
|
||||
Array<{
|
||||
key: string;
|
||||
remoteValue?: FeatureFlagValue;
|
||||
overrideValue?: FeatureFlagValue;
|
||||
value: FeatureFlagValue;
|
||||
source: string;
|
||||
type: 'boolean' | 'string' | 'number';
|
||||
}>
|
||||
> => {
|
||||
try {
|
||||
const keys = remoteConfig().getAll();
|
||||
const localOverrides = await getLocalOverrides();
|
||||
|
||||
// Get all remote/default flags
|
||||
const remoteFlags = Object.keys(keys).map(key => {
|
||||
const configValue = keys[key];
|
||||
|
||||
// Try to determine the type from default flags or infer from value
|
||||
const defaultValue = defaultFlags[key];
|
||||
const remoteVal =
|
||||
defaultValue !== undefined
|
||||
? getRemoteConfigValue(key, defaultValue)
|
||||
: configValue.asString(); // Default to string if no default defined
|
||||
|
||||
const hasLocalOverride = Object.prototype.hasOwnProperty.call(
|
||||
localOverrides,
|
||||
key,
|
||||
);
|
||||
const overrideVal = hasLocalOverride ? localOverrides[key] : undefined;
|
||||
const effectiveVal = hasLocalOverride ? overrideVal! : remoteVal;
|
||||
|
||||
// Determine type
|
||||
const type =
|
||||
typeof effectiveVal === 'boolean'
|
||||
? 'boolean'
|
||||
: typeof effectiveVal === 'number'
|
||||
? 'number'
|
||||
: 'string';
|
||||
|
||||
return {
|
||||
key,
|
||||
remoteValue: remoteVal,
|
||||
overrideValue: overrideVal,
|
||||
value: effectiveVal,
|
||||
type: type as 'boolean' | 'string' | 'number',
|
||||
source: hasLocalOverride
|
||||
? 'Local Override'
|
||||
: configValue.getSource() === 'remote'
|
||||
? 'Remote Config'
|
||||
: configValue.getSource() === 'default'
|
||||
? 'Default'
|
||||
: configValue.getSource() === 'static'
|
||||
? 'Static'
|
||||
: 'Unknown',
|
||||
};
|
||||
});
|
||||
|
||||
// Add any local overrides that don't exist in remote config
|
||||
const localOnlyFlags = Object.keys(localOverrides)
|
||||
.filter(key => !Object.prototype.hasOwnProperty.call(keys, key))
|
||||
.map(key => {
|
||||
const value = localOverrides[key];
|
||||
const type =
|
||||
typeof value === 'boolean'
|
||||
? 'boolean'
|
||||
: typeof value === 'number'
|
||||
? 'number'
|
||||
: 'string';
|
||||
|
||||
return {
|
||||
key,
|
||||
remoteValue: undefined,
|
||||
overrideValue: value,
|
||||
value: value,
|
||||
type: type as 'boolean' | 'string' | 'number',
|
||||
source: 'Local Override',
|
||||
};
|
||||
});
|
||||
|
||||
return [...remoteFlags, ...localOnlyFlags].sort((a, b) =>
|
||||
a.key.localeCompare(b.key),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to get all feature flags:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const refreshRemoteConfig = async () => {
|
||||
try {
|
||||
await remoteConfig().fetchAndActivate();
|
||||
} catch (err) {
|
||||
console.log('Remote config refresh failed', err);
|
||||
}
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { NativeStackNavigationOptions } from '@react-navigation/native-stack';
|
||||
|
||||
import DevFeatureFlagsScreen from '../screens/dev/DevFeatureFlagsScreen';
|
||||
import DevHapticFeedbackScreen from '../screens/dev/DevHapticFeedback';
|
||||
import DevSettingsScreen from '../screens/dev/DevSettingsScreen';
|
||||
import MockDataScreen from '../screens/dev/MockDataScreen';
|
||||
@@ -36,6 +37,15 @@ const devScreens = {
|
||||
},
|
||||
} as NativeStackNavigationOptions,
|
||||
},
|
||||
DevFeatureFlags: {
|
||||
screen: DevFeatureFlagsScreen,
|
||||
options: {
|
||||
title: 'Feature Flags',
|
||||
headerStyle: {
|
||||
backgroundColor: white,
|
||||
},
|
||||
} as NativeStackNavigationOptions,
|
||||
},
|
||||
};
|
||||
|
||||
export default devScreens;
|
||||
|
||||
46
app/src/providers/remoteConfigProvider.tsx
Normal file
46
app/src/providers/remoteConfigProvider.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { initRemoteConfig } from '../RemoteConfig';
|
||||
|
||||
interface RemoteConfigContextValue {
|
||||
isInitialized: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const RemoteConfigContext = createContext<RemoteConfigContextValue>({
|
||||
isInitialized: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
export const useRemoteConfig = () => useContext(RemoteConfigContext);
|
||||
|
||||
export const RemoteConfigProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
await initRemoteConfig();
|
||||
setIsInitialized(true);
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize remote config:', err);
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
// Still set as initialized to not block the app
|
||||
setIsInitialized(true);
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<RemoteConfigContext.Provider value={{ isInitialized, error }}>
|
||||
{children}
|
||||
</RemoteConfigContext.Provider>
|
||||
);
|
||||
};
|
||||
370
app/src/screens/dev/DevFeatureFlagsScreen.tsx
Normal file
370
app/src/screens/dev/DevFeatureFlagsScreen.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
ScrollView,
|
||||
Switch,
|
||||
Text,
|
||||
XStack,
|
||||
YStack,
|
||||
} from 'tamagui';
|
||||
|
||||
import {
|
||||
clearAllLocalOverrides,
|
||||
FeatureFlagValue,
|
||||
getAllFeatureFlags,
|
||||
refreshRemoteConfig,
|
||||
setLocalOverride,
|
||||
} from '../../RemoteConfig';
|
||||
import { textBlack } from '../../utils/colors';
|
||||
|
||||
interface FeatureFlag {
|
||||
key: string;
|
||||
value: FeatureFlagValue;
|
||||
source: string;
|
||||
type: 'boolean' | 'string' | 'number';
|
||||
remoteValue?: FeatureFlagValue;
|
||||
overrideValue?: FeatureFlagValue;
|
||||
}
|
||||
|
||||
const DevFeatureFlagsScreen: React.FC = () => {
|
||||
const [featureFlags, setFeatureFlags] = useState<FeatureFlag[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isTogglingFlag, setIsTogglingFlag] = useState<string | null>(null);
|
||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
||||
const [textInputValues, setTextInputValues] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [errorState, setErrorState] = useState<string | null>(null);
|
||||
const [inputErrors, setInputErrors] = useState<Record<string, string>>({});
|
||||
const [debounceTimers, setDebounceTimers] = useState<
|
||||
Record<string, NodeJS.Timeout>
|
||||
>({});
|
||||
|
||||
const loadFeatureFlags = useCallback(async () => {
|
||||
try {
|
||||
setErrorState(null);
|
||||
const flags = await getAllFeatureFlags();
|
||||
setFeatureFlags(flags);
|
||||
setLastRefresh(new Date());
|
||||
|
||||
// Initialize text input values for non-boolean flags
|
||||
const initialTextValues: Record<string, string> = {};
|
||||
flags.forEach(flag => {
|
||||
if (flag.type !== 'boolean') {
|
||||
initialTextValues[flag.key] = String(flag.value);
|
||||
}
|
||||
});
|
||||
setTextInputValues(initialTextValues);
|
||||
} catch (error) {
|
||||
console.error('Failed to load feature flags:', error);
|
||||
setErrorState('Failed to load feature flags. Please try refreshing.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await refreshRemoteConfig();
|
||||
await loadFeatureFlags();
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh feature flags:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [loadFeatureFlags]);
|
||||
|
||||
const handleToggleFlag = useCallback(
|
||||
async (flagKey: string, currentValue: boolean) => {
|
||||
setIsTogglingFlag(flagKey);
|
||||
try {
|
||||
await setLocalOverride(flagKey, !currentValue);
|
||||
await loadFeatureFlags();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle flag:', error);
|
||||
} finally {
|
||||
setIsTogglingFlag(null);
|
||||
}
|
||||
},
|
||||
[loadFeatureFlags],
|
||||
);
|
||||
|
||||
const handleSaveTextFlag = useCallback(
|
||||
async (flagKey: string, type: 'string' | 'number') => {
|
||||
setIsTogglingFlag(flagKey);
|
||||
try {
|
||||
const rawValue = textInputValues[flagKey] || '';
|
||||
let value: FeatureFlagValue;
|
||||
|
||||
if (type === 'number') {
|
||||
value = Number(rawValue);
|
||||
if (Number.isNaN(value)) {
|
||||
setInputErrors(prev => ({
|
||||
...prev,
|
||||
[flagKey]: 'Please enter a valid number',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
value = rawValue;
|
||||
}
|
||||
|
||||
// Clear any previous error for this field
|
||||
setInputErrors(prev => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[flagKey];
|
||||
return newErrors;
|
||||
});
|
||||
|
||||
await setLocalOverride(flagKey, value);
|
||||
await loadFeatureFlags();
|
||||
} catch (error) {
|
||||
console.error('Failed to save flag:', error);
|
||||
setInputErrors(prev => ({
|
||||
...prev,
|
||||
[flagKey]: 'Failed to save value',
|
||||
}));
|
||||
} finally {
|
||||
setIsTogglingFlag(null);
|
||||
}
|
||||
},
|
||||
[textInputValues, loadFeatureFlags],
|
||||
);
|
||||
|
||||
const handleTextInputChange = useCallback(
|
||||
(flagKey: string, value: string) => {
|
||||
setTextInputValues(prev => ({
|
||||
...prev,
|
||||
[flagKey]: value,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const debouncedSave = useCallback(
|
||||
(flagKey: string, type: 'string' | 'number') => {
|
||||
// Clear existing timer for this flag if it exists
|
||||
if (debounceTimers[flagKey]) {
|
||||
clearTimeout(debounceTimers[flagKey]);
|
||||
}
|
||||
|
||||
// Set a new timer
|
||||
const timer = setTimeout(() => {
|
||||
handleSaveTextFlag(flagKey, type);
|
||||
setDebounceTimers(prev => {
|
||||
const newTimers = { ...prev };
|
||||
delete newTimers[flagKey];
|
||||
return newTimers;
|
||||
});
|
||||
}, 500); // 500ms debounce delay
|
||||
|
||||
setDebounceTimers(prev => ({
|
||||
...prev,
|
||||
[flagKey]: timer,
|
||||
}));
|
||||
},
|
||||
[debounceTimers, handleSaveTextFlag],
|
||||
);
|
||||
|
||||
const handleClearAllOverrides = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await clearAllLocalOverrides();
|
||||
await loadFeatureFlags();
|
||||
} catch (error) {
|
||||
console.error('Failed to clear all overrides:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [loadFeatureFlags]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFeatureFlags();
|
||||
}, [loadFeatureFlags]);
|
||||
|
||||
// Cleanup debounce timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Object.values(debounceTimers).forEach(timer => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
};
|
||||
// only clean up on unmount
|
||||
}, []);
|
||||
|
||||
const hasLocalOverrides = featureFlags.some(
|
||||
flag => flag.source === 'Local Override',
|
||||
);
|
||||
|
||||
const formatDisplayValue = (
|
||||
value: FeatureFlagValue,
|
||||
type: string,
|
||||
): string => {
|
||||
if (type === 'boolean') {
|
||||
return value ? 'Enabled' : 'Disabled';
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const renderFlagInput = (flag: FeatureFlag) => {
|
||||
switch (flag.type) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<Switch
|
||||
size="$4"
|
||||
checked={flag.value as boolean}
|
||||
onCheckedChange={() =>
|
||||
handleToggleFlag(flag.key, flag.value as boolean)
|
||||
}
|
||||
disabled={isTogglingFlag === flag.key}
|
||||
bg={flag.value ? '$green7Light' : '$gray4'}
|
||||
style={{ minWidth: 48, minHeight: 36, alignSelf: 'flex-end' }}
|
||||
>
|
||||
<Switch.Thumb animation="quick" bc="$white" />
|
||||
</Switch>
|
||||
);
|
||||
case 'string':
|
||||
case 'number':
|
||||
return (
|
||||
<YStack f={1} gap="$1">
|
||||
<Input
|
||||
value={textInputValues[flag.key] || ''}
|
||||
onChangeText={value => {
|
||||
handleTextInputChange(flag.key, value);
|
||||
// Debounced autosave
|
||||
debouncedSave(flag.key, flag.type as 'string' | 'number');
|
||||
}}
|
||||
placeholder={
|
||||
flag.type === 'number'
|
||||
? 'Enter number value'
|
||||
: 'Enter text value'
|
||||
}
|
||||
keyboardType={flag.type === 'number' ? 'numeric' : 'default'}
|
||||
disabled={isTogglingFlag === flag.key}
|
||||
borderRadius={12}
|
||||
borderWidth={1}
|
||||
borderColor={inputErrors[flag.key] ? '$red6' : '$gray6'}
|
||||
bg="$gray2"
|
||||
px="$3"
|
||||
py="$2"
|
||||
fontSize="$4"
|
||||
style={{ minHeight: 36 }}
|
||||
/>
|
||||
{inputErrors[flag.key] && (
|
||||
<Text color="$red11" fontSize="$2" pl="$2">
|
||||
{inputErrors[flag.key]}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<YStack f={1} bg="white" px="$4" pt="$4">
|
||||
<YStack mb="$4">
|
||||
<XStack justifyContent="space-between" alignItems="center">
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Button size="$3" onPress={handleRefresh} disabled={isLoading}>
|
||||
{isLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</Button>
|
||||
{hasLocalOverrides && (
|
||||
<Button
|
||||
size="$3"
|
||||
onPress={handleClearAllOverrides}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</XStack>
|
||||
{lastRefresh && (
|
||||
<Text fontSize="$2" color="$gray9">
|
||||
Last updated: {lastRefresh.toLocaleTimeString()}
|
||||
</Text>
|
||||
)}
|
||||
</XStack>
|
||||
</YStack>
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false} mt="$4">
|
||||
<YStack gap="$3" pb="$8">
|
||||
{errorState && (
|
||||
<YStack
|
||||
p="$4"
|
||||
borderWidth={1}
|
||||
borderColor="$red6"
|
||||
borderRadius="$4"
|
||||
bg="$red2"
|
||||
alignItems="center"
|
||||
gap="$2"
|
||||
>
|
||||
<Text color="$red11" fontSize="$4" textAlign="center">
|
||||
{errorState}
|
||||
</Text>
|
||||
</YStack>
|
||||
)}
|
||||
{featureFlags.length === 0 ? (
|
||||
<YStack
|
||||
p="$4"
|
||||
borderWidth={1}
|
||||
borderColor="$gray6"
|
||||
borderRadius="$4"
|
||||
bg="$gray2"
|
||||
alignItems="center"
|
||||
gap="$2"
|
||||
>
|
||||
<Text color={textBlack} fontSize="$4" textAlign="center">
|
||||
No feature flags found
|
||||
</Text>
|
||||
<Text
|
||||
color={textBlack}
|
||||
fontSize="$3"
|
||||
textAlign="center"
|
||||
opacity={0.7}
|
||||
>
|
||||
Feature flags will appear here once they are configured in
|
||||
Firebase Remote Config
|
||||
</Text>
|
||||
</YStack>
|
||||
) : (
|
||||
featureFlags.map(flag => (
|
||||
<YStack
|
||||
key={flag.key}
|
||||
p="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$gray6"
|
||||
borderRadius="$4"
|
||||
mb="$2"
|
||||
>
|
||||
<XStack justifyContent="space-between" alignItems="center">
|
||||
<YStack f={1} mr="$4">
|
||||
<Text fontSize="$4" fontWeight="500">
|
||||
{flag.key}
|
||||
</Text>
|
||||
{flag.remoteValue !== undefined && (
|
||||
<Text fontSize="$2" color="$gray9" mt="$1">
|
||||
Default:{' '}
|
||||
{formatDisplayValue(flag.remoteValue, flag.type)}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
<XStack alignItems="center" gap="$3" f={1} jc="flex-end">
|
||||
{renderFlagInput(flag)}
|
||||
</XStack>
|
||||
</XStack>
|
||||
</YStack>
|
||||
))
|
||||
)}
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
</YStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default DevFeatureFlagsScreen;
|
||||
@@ -52,6 +52,7 @@ function SelectableText({ children, ...props }: DevSettingsScreenProps) {
|
||||
|
||||
const items = [
|
||||
'DevSettings',
|
||||
'DevFeatureFlags',
|
||||
'DevHapticFeedback',
|
||||
'Splash',
|
||||
'Launch',
|
||||
|
||||
388
app/tests/src/RemoteConfig.test.ts
Normal file
388
app/tests/src/RemoteConfig.test.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
// Mock AsyncStorage with a default export
|
||||
jest.mock('@react-native-async-storage/async-storage', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock Firebase Remote Config with proper setup
|
||||
const mockRemoteConfigInstance = {
|
||||
setDefaults: jest.fn(),
|
||||
setConfigSettings: jest.fn(),
|
||||
fetchAndActivate: jest.fn(),
|
||||
getValue: jest.fn(),
|
||||
getAll: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('@react-native-firebase/remote-config', () => ({
|
||||
__esModule: true,
|
||||
default: () => mockRemoteConfigInstance,
|
||||
}));
|
||||
|
||||
// Import the mocked AsyncStorage for test controls
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
// Get the mock instances
|
||||
const mockAsyncStorage = AsyncStorage as jest.Mocked<typeof AsyncStorage>;
|
||||
const mockRemoteConfig = mockRemoteConfigInstance as jest.Mocked<
|
||||
typeof mockRemoteConfigInstance
|
||||
>;
|
||||
|
||||
// Now import the module under test
|
||||
import {
|
||||
clearAllLocalOverrides,
|
||||
clearLocalOverride,
|
||||
getAllFeatureFlags,
|
||||
getFeatureFlag,
|
||||
getLocalOverrides,
|
||||
setLocalOverride,
|
||||
} from '../../src/RemoteConfig';
|
||||
|
||||
describe('RemoteConfig', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAsyncStorage.getItem.mockResolvedValue('{}');
|
||||
mockAsyncStorage.setItem.mockResolvedValue();
|
||||
mockAsyncStorage.removeItem.mockResolvedValue();
|
||||
});
|
||||
|
||||
// Suppress console errors during testing
|
||||
beforeAll(() => {
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getFeatureFlag', () => {
|
||||
it('should return default value when Firebase getValue fails', async () => {
|
||||
mockRemoteConfig.getValue.mockImplementation(() => {
|
||||
throw new Error('Firebase error');
|
||||
});
|
||||
|
||||
const result = await getFeatureFlag('test_feature', true);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return local override value when present', async () => {
|
||||
const mockOverrides = {
|
||||
testFlag: 'override value',
|
||||
};
|
||||
|
||||
mockAsyncStorage.getItem.mockResolvedValue(JSON.stringify(mockOverrides));
|
||||
|
||||
const result = await getFeatureFlag('testFlag', 'default value');
|
||||
expect(result).toBe('override value');
|
||||
});
|
||||
|
||||
it('should return default value when no override exists', async () => {
|
||||
mockAsyncStorage.getItem.mockResolvedValue('{}');
|
||||
mockRemoteConfig.getValue.mockReturnValue({
|
||||
asString: () => 'remote value',
|
||||
asBoolean: () => false,
|
||||
asNumber: () => 0,
|
||||
getSource: () => 'remote',
|
||||
});
|
||||
|
||||
const result = await getFeatureFlag('testFlag', 'default value');
|
||||
expect(result).toBe('default value');
|
||||
});
|
||||
|
||||
it('should preserve type for number flags', async () => {
|
||||
mockAsyncStorage.getItem.mockResolvedValue('{}');
|
||||
mockRemoteConfig.getValue.mockReturnValue({
|
||||
asString: () => '42',
|
||||
asBoolean: () => false,
|
||||
asNumber: () => 42,
|
||||
getSource: () => 'remote',
|
||||
});
|
||||
|
||||
const result = await getFeatureFlag('testFlag', 42);
|
||||
expect(result).toBe(42);
|
||||
expect(typeof result).toBe('number');
|
||||
});
|
||||
|
||||
it('should preserve type for boolean flags', async () => {
|
||||
mockAsyncStorage.getItem.mockResolvedValue('{}');
|
||||
mockRemoteConfig.getValue.mockReturnValue({
|
||||
asString: () => 'true',
|
||||
asBoolean: () => true,
|
||||
asNumber: () => 1,
|
||||
getSource: () => 'remote',
|
||||
});
|
||||
|
||||
const result = await getFeatureFlag('testFlag', true);
|
||||
expect(result).toBe(true);
|
||||
expect(typeof result).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should prioritize local overrides over remote config', async () => {
|
||||
const mockOverrides = {
|
||||
testFlag: 'local override',
|
||||
};
|
||||
|
||||
mockAsyncStorage.getItem.mockResolvedValue(JSON.stringify(mockOverrides));
|
||||
mockRemoteConfig.getValue.mockReturnValue({
|
||||
asString: () => 'remote value',
|
||||
asBoolean: () => false,
|
||||
asNumber: () => 0,
|
||||
getSource: () => 'remote',
|
||||
});
|
||||
|
||||
const result = await getFeatureFlag('testFlag', 'default value');
|
||||
expect(result).toBe('local override');
|
||||
|
||||
// Remote config should not be called when local override exists
|
||||
expect(mockRemoteConfig.getValue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllFeatureFlags', () => {
|
||||
it('should return empty array when Firebase getAll fails', async () => {
|
||||
mockRemoteConfig.getAll.mockImplementation(() => {
|
||||
throw new Error('Firebase error');
|
||||
});
|
||||
|
||||
const result = await getAllFeatureFlags();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return complete feature flag structure', async () => {
|
||||
// Reset all mocks to clean state
|
||||
jest.clearAllMocks();
|
||||
|
||||
const mockRemoteFlags = {
|
||||
testFlag: {
|
||||
asString: () => 'test value',
|
||||
asBoolean: () => false,
|
||||
asNumber: () => 0,
|
||||
getSource: () => 'remote' as const,
|
||||
},
|
||||
};
|
||||
|
||||
const mockLocalOverrides = {
|
||||
testFlag: 'overridden value',
|
||||
localOnlyFlag: 'local only',
|
||||
};
|
||||
|
||||
// Configure mocks
|
||||
mockRemoteConfig.getAll.mockReturnValue(mockRemoteFlags);
|
||||
mockAsyncStorage.getItem.mockResolvedValue(
|
||||
JSON.stringify(mockLocalOverrides),
|
||||
);
|
||||
|
||||
const result = await getAllFeatureFlags();
|
||||
|
||||
// Check that the function returns an array
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
|
||||
// Check that each flag has the expected structure
|
||||
result.forEach(flag => {
|
||||
expect(flag).toHaveProperty('key');
|
||||
expect(flag).toHaveProperty('value');
|
||||
expect(flag).toHaveProperty('type');
|
||||
expect(flag).toHaveProperty('source');
|
||||
expect(flag).toHaveProperty('remoteValue');
|
||||
expect(flag).toHaveProperty('overrideValue');
|
||||
expect(['boolean', 'string', 'number']).toContain(flag.type);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return correct flag values with overrides', async () => {
|
||||
const mockRemoteFlags = {
|
||||
test_flag: {
|
||||
asString: () => 'test value',
|
||||
asBoolean: () => false,
|
||||
asNumber: () => 0,
|
||||
getSource: () => 'remote' as const,
|
||||
},
|
||||
};
|
||||
|
||||
const mockLocalOverrides = {};
|
||||
|
||||
// Configure mocks
|
||||
mockRemoteConfig.getAll.mockReturnValue(mockRemoteFlags);
|
||||
mockRemoteConfig.getValue.mockReturnValue(mockRemoteFlags.test_flag);
|
||||
mockAsyncStorage.getItem.mockResolvedValue(
|
||||
JSON.stringify(mockLocalOverrides),
|
||||
);
|
||||
|
||||
const result = await getAllFeatureFlags();
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
key: 'test_flag',
|
||||
value: 'test value',
|
||||
source: 'Remote Config',
|
||||
type: 'string',
|
||||
remoteValue: 'test value',
|
||||
overrideValue: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return correct flag values with local overrides', async () => {
|
||||
const mockRemoteFlags = {
|
||||
test_flag: {
|
||||
asString: () => 'true',
|
||||
asBoolean: () => true,
|
||||
asNumber: () => 1,
|
||||
getSource: () => 'remote' as const,
|
||||
},
|
||||
};
|
||||
|
||||
const mockLocalOverrides = {
|
||||
test_flag: false,
|
||||
};
|
||||
|
||||
// Configure mocks
|
||||
mockRemoteConfig.getAll.mockReturnValue(mockRemoteFlags);
|
||||
mockRemoteConfig.getValue.mockReturnValue(mockRemoteFlags.test_flag);
|
||||
mockAsyncStorage.getItem.mockResolvedValue(
|
||||
JSON.stringify(mockLocalOverrides),
|
||||
);
|
||||
|
||||
const result = await getAllFeatureFlags();
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
key: 'test_flag',
|
||||
value: false,
|
||||
source: 'Local Override',
|
||||
type: 'boolean',
|
||||
remoteValue: 'true',
|
||||
overrideValue: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle local-only flags correctly', async () => {
|
||||
const mockRemoteFlags = {};
|
||||
|
||||
const mockLocalOverrides = {
|
||||
local_only_flag: 'local value',
|
||||
};
|
||||
|
||||
// Configure mocks
|
||||
mockRemoteConfig.getAll.mockReturnValue(mockRemoteFlags);
|
||||
mockAsyncStorage.getItem.mockResolvedValue(
|
||||
JSON.stringify(mockLocalOverrides),
|
||||
);
|
||||
|
||||
const result = await getAllFeatureFlags();
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
key: 'local_only_flag',
|
||||
value: 'local value',
|
||||
source: 'Local Override',
|
||||
type: 'string',
|
||||
remoteValue: undefined,
|
||||
overrideValue: 'local value',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Local Override Management', () => {
|
||||
it('should store and retrieve mixed types correctly', async () => {
|
||||
const mockOverrides = {
|
||||
stringFlag: 'hello world',
|
||||
booleanFlag: true,
|
||||
numberFlag: 42,
|
||||
};
|
||||
|
||||
mockAsyncStorage.getItem.mockResolvedValue(JSON.stringify(mockOverrides));
|
||||
|
||||
const result = await getLocalOverrides();
|
||||
expect(result).toEqual(mockOverrides);
|
||||
});
|
||||
|
||||
it('should set local override for string values', async () => {
|
||||
mockAsyncStorage.getItem.mockResolvedValue('{}');
|
||||
|
||||
await setLocalOverride('testString', 'hello world');
|
||||
|
||||
expect(mockAsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
'feature_flag_overrides',
|
||||
JSON.stringify({ testString: 'hello world' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set local override for number values', async () => {
|
||||
mockAsyncStorage.getItem.mockResolvedValue('{}');
|
||||
|
||||
await setLocalOverride('testNumber', 123);
|
||||
|
||||
expect(mockAsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
'feature_flag_overrides',
|
||||
JSON.stringify({ testNumber: 123 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set local override for boolean values', async () => {
|
||||
mockAsyncStorage.getItem.mockResolvedValue('{}');
|
||||
|
||||
await setLocalOverride('testBoolean', true);
|
||||
|
||||
expect(mockAsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
'feature_flag_overrides',
|
||||
JSON.stringify({ testBoolean: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should clear specific local override', async () => {
|
||||
const mockOverrides = {
|
||||
flag1: 'value1',
|
||||
flag2: 'value2',
|
||||
};
|
||||
|
||||
mockAsyncStorage.getItem.mockResolvedValue(JSON.stringify(mockOverrides));
|
||||
|
||||
await clearLocalOverride('flag1');
|
||||
|
||||
expect(mockAsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
'feature_flag_overrides',
|
||||
JSON.stringify({ flag2: 'value2' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should clear all local overrides', async () => {
|
||||
await clearAllLocalOverrides();
|
||||
|
||||
expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith(
|
||||
'feature_flag_overrides',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle AsyncStorage errors gracefully', async () => {
|
||||
mockAsyncStorage.getItem.mockRejectedValue(new Error('Storage error'));
|
||||
|
||||
const result = await getLocalOverrides();
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should clear AsyncStorage entry when JSON parsing fails', async () => {
|
||||
// Mock AsyncStorage.getItem to return invalid JSON
|
||||
mockAsyncStorage.getItem.mockResolvedValue('invalid JSON {');
|
||||
|
||||
const result = await getLocalOverrides();
|
||||
|
||||
// Should call removeItem to clear the corrupt data
|
||||
expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith(
|
||||
'feature_flag_overrides',
|
||||
);
|
||||
|
||||
// Should return empty object
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ describe('navigation', () => {
|
||||
'CloudBackupSettings',
|
||||
'ConfirmBelongingScreen',
|
||||
'CreateMock',
|
||||
'DevFeatureFlags',
|
||||
'DevHapticFeedback',
|
||||
'DevSettings',
|
||||
'Disclaimer',
|
||||
|
||||
123
app/tests/src/providers/remoteConfigProvider.test.tsx
Normal file
123
app/tests/src/providers/remoteConfigProvider.test.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||
|
||||
import { render, waitFor } from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
import { Text } from 'react-native';
|
||||
|
||||
import {
|
||||
RemoteConfigProvider,
|
||||
useRemoteConfig,
|
||||
} from '../../../src/providers/remoteConfigProvider';
|
||||
|
||||
// Mock the RemoteConfig module
|
||||
jest.mock('../../../src/RemoteConfig', () => ({
|
||||
initRemoteConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
import { initRemoteConfig } from '../../../src/RemoteConfig';
|
||||
|
||||
const mockInitRemoteConfig = initRemoteConfig as jest.MockedFunction<
|
||||
typeof initRemoteConfig
|
||||
>;
|
||||
|
||||
// Test component that uses the hook
|
||||
const TestComponent = () => {
|
||||
const { isInitialized, error } = useRemoteConfig();
|
||||
return (
|
||||
<>
|
||||
<Text testID="initialized">{isInitialized ? 'true' : 'false'}</Text>
|
||||
<Text testID="error">{error || 'none'}</Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
describe('RemoteConfigProvider', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
console.error = jest.fn();
|
||||
});
|
||||
|
||||
it('should initialize successfully and set isInitialized to true', async () => {
|
||||
mockInitRemoteConfig.mockResolvedValue(undefined);
|
||||
|
||||
const { getByTestId } = render(
|
||||
<RemoteConfigProvider>
|
||||
<TestComponent />
|
||||
</RemoteConfigProvider>,
|
||||
);
|
||||
|
||||
// Initially should be false
|
||||
expect(getByTestId('initialized')).toHaveTextContent('false');
|
||||
expect(getByTestId('error')).toHaveTextContent('none');
|
||||
|
||||
// Wait for initialization to complete
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('initialized')).toHaveTextContent('true');
|
||||
});
|
||||
|
||||
expect(getByTestId('error')).toHaveTextContent('none');
|
||||
expect(mockInitRemoteConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle initialization errors gracefully', async () => {
|
||||
const errorMessage = 'Firebase initialization failed';
|
||||
mockInitRemoteConfig.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
const { getByTestId } = render(
|
||||
<RemoteConfigProvider>
|
||||
<TestComponent />
|
||||
</RemoteConfigProvider>,
|
||||
);
|
||||
|
||||
// Wait for initialization to complete (with error)
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('initialized')).toHaveTextContent('true');
|
||||
});
|
||||
|
||||
expect(getByTestId('error')).toHaveTextContent(errorMessage);
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Failed to initialize remote config:',
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle non-Error rejection gracefully', async () => {
|
||||
mockInitRemoteConfig.mockRejectedValue('String error');
|
||||
|
||||
const { getByTestId } = render(
|
||||
<RemoteConfigProvider>
|
||||
<TestComponent />
|
||||
</RemoteConfigProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('initialized')).toHaveTextContent('true');
|
||||
});
|
||||
|
||||
expect(getByTestId('error')).toHaveTextContent('Unknown error');
|
||||
});
|
||||
|
||||
it('should only initialize once', async () => {
|
||||
mockInitRemoteConfig.mockResolvedValue(undefined);
|
||||
|
||||
const { rerender } = render(
|
||||
<RemoteConfigProvider>
|
||||
<TestComponent />
|
||||
</RemoteConfigProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInitRemoteConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Re-render the provider
|
||||
rerender(
|
||||
<RemoteConfigProvider>
|
||||
<TestComponent />
|
||||
</RemoteConfigProvider>,
|
||||
);
|
||||
|
||||
// Should still only be called once
|
||||
expect(mockInitRemoteConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user