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:
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