Skip to content

Commit 95d4a21

Browse files
authored
feat(ramps): add rampsUnifiedBuyV2 feature flag support (MetaMask#24492)
## **Description** This PR adds support for the `rampsUnifiedBuyV2` LaunchDarkly feature flag to enable Phase 2 of the Unified Buy feature. **What is the reason for the change?** - As part of the Unified Buy 2026 Phase 2 initiative (TRAM-2958), we need a new feature flag to control the V2 implementation independently from V1. **What is the improvement/solution?** - Added V2 selectors for the `rampsUnifiedBuyV2` remote feature flag - Added `useRampsUnifiedV2Enabled` hook with: - Build flag override support (`MM_RAMPS_UNIFIED_BUY_V2_ENABLED`) - Remote feature flag via Redux selectors - Minimum version checking (7.63.0) - Extracted `hasMinimumRequiredVersion` to a shared utility (DRY refactor) - Updated V1 hook to use the shared utility - Added V2 flag configuration to E2E remote feature flags mock ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TRAM-3027 ## **Manual testing steps** ```gherkin Feature: rampsUnifiedBuyV2 feature flag Scenario: V2 flag is disabled by default Given the app is running with remote feature flags loaded When the rampsUnifiedBuyV2 flag has active: false Then useRampsUnifiedV2Enabled returns false Scenario: V2 flag is enabled when active and version meets requirement Given the app is running version 7.63.0 or higher When the rampsUnifiedBuyV2 flag has active: true and minimumVersion: 7.63.0 Then useRampsUnifiedV2Enabled returns true Scenario: Build flag overrides remote flag Given MM_RAMPS_UNIFIED_BUY_V2_ENABLED is set to "true" When the remote flag has active: false Then useRampsUnifiedV2Enabled returns true (build flag takes precedence) ``` ## **Screenshots/Recordings** N/A - No UI changes, this is infrastructure code for feature flagging. ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces Phase 2 flagging for Unified Buy with build-time override and version gating, plus shared utility reuse. > > - New `rampsUnifiedBuyV2` selectors and `useRampsUnifiedV2Enabled` hook (respects `MM_RAMPS_UNIFIED_BUY_V2_ENABLED` and remote flags with minimum version) > - Extracts `hasMinimumRequiredVersion` to `utils/hasMinimumRequiredVersion` and updates `useRampsUnifiedV1Enabled` to use it > - Adds comprehensive tests for V2 hook, V2 selectors, and the version-check util > - Updates `babel.config.tests.js` to avoid inlining env vars for new files > - Extends E2E remote feature flags mock to include `rampsUnifiedBuyV2` default config > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f3a487f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent ef3c8c0 commit 95d4a21

9 files changed

Lines changed: 590 additions & 14 deletions

File tree

app/components/UI/Ramp/hooks/useRampsUnifiedV1Enabled.ts

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,9 @@
11
import { useSelector } from 'react-redux';
2-
import { getVersion } from 'react-native-device-info';
3-
import compareVersions from 'compare-versions';
42
import {
53
selectRampsUnifiedBuyV1ActiveFlag,
64
selectRampsUnifiedBuyV1MinimumVersionFlag,
75
} from '../../../../selectors/featureFlagController/ramps/rampsUnifiedBuyV1';
8-
9-
function hasMinimumRequiredVersion(
10-
minRequiredVersion: string | null | undefined,
11-
isUnifiedV1Enabled: boolean,
12-
) {
13-
if (!minRequiredVersion) return false;
14-
const currentVersion = getVersion();
15-
return (
16-
isUnifiedV1Enabled &&
17-
compareVersions.compare(currentVersion, minRequiredVersion, '>=')
18-
);
19-
}
6+
import { hasMinimumRequiredVersion } from '../utils/hasMinimumRequiredVersion';
207

218
export default function useRampsUnifiedV1Enabled() {
229
const rampsUnifiedBuyV1MinimumVersionFlag = useSelector(
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import initialRootState, {
2+
backgroundState,
3+
} from '../../../../util/test/initial-root-state';
4+
import { renderHookWithProvider } from '../../../../util/test/renderWithProvider';
5+
import useRampsUnifiedV2Enabled from './useRampsUnifiedV2Enabled';
6+
import { getVersion } from 'react-native-device-info';
7+
8+
function mockInitialState({
9+
rampsUnifiedBuyV2ActiveFlag = true,
10+
rampsUnifiedBuyV2MinimumVersionFlag,
11+
}: {
12+
rampsUnifiedBuyV2ActiveFlag?: boolean;
13+
rampsUnifiedBuyV2MinimumVersionFlag?: string | null;
14+
} = {}) {
15+
return {
16+
...initialRootState,
17+
engine: {
18+
backgroundState: {
19+
...backgroundState,
20+
RemoteFeatureFlagController: {
21+
remoteFeatureFlags: {
22+
rampsUnifiedBuyV2: {
23+
active: rampsUnifiedBuyV2ActiveFlag,
24+
...(rampsUnifiedBuyV2MinimumVersionFlag !== undefined && {
25+
minimumVersion: rampsUnifiedBuyV2MinimumVersionFlag,
26+
}),
27+
},
28+
},
29+
},
30+
},
31+
},
32+
};
33+
}
34+
35+
jest.mock('react-native-device-info', () => ({
36+
getVersion: jest.fn(),
37+
}));
38+
39+
describe('useRampsUnifiedV2Enabled', () => {
40+
const mockGetVersion = jest.mocked(getVersion);
41+
const originalEnv = process.env;
42+
43+
beforeEach(() => {
44+
jest.clearAllMocks();
45+
process.env = { ...originalEnv };
46+
delete process.env.MM_RAMPS_UNIFIED_BUY_V2_ENABLED;
47+
});
48+
49+
afterAll(() => {
50+
process.env = originalEnv;
51+
});
52+
53+
describe('Build flag precedence', () => {
54+
it('returns true when build flag is set to "true" regardless of remote flags', () => {
55+
process.env.MM_RAMPS_UNIFIED_BUY_V2_ENABLED = 'true';
56+
mockGetVersion.mockReturnValue('1.0.0');
57+
58+
const { result } = renderHookWithProvider(
59+
() => useRampsUnifiedV2Enabled(),
60+
{
61+
state: mockInitialState({
62+
rampsUnifiedBuyV2ActiveFlag: false,
63+
rampsUnifiedBuyV2MinimumVersionFlag: '2.0.0',
64+
}),
65+
},
66+
);
67+
68+
expect(result.current).toBe(true);
69+
});
70+
71+
it('returns false when build flag is set to "false" regardless of remote flags', () => {
72+
process.env.MM_RAMPS_UNIFIED_BUY_V2_ENABLED = 'false';
73+
mockGetVersion.mockReturnValue('2.0.0');
74+
75+
const { result } = renderHookWithProvider(
76+
() => useRampsUnifiedV2Enabled(),
77+
{
78+
state: mockInitialState({
79+
rampsUnifiedBuyV2ActiveFlag: true,
80+
rampsUnifiedBuyV2MinimumVersionFlag: '1.0.0',
81+
}),
82+
},
83+
);
84+
85+
expect(result.current).toBe(false);
86+
});
87+
88+
it('returns true when build flag is set to "true" even with version mismatch', () => {
89+
process.env.MM_RAMPS_UNIFIED_BUY_V2_ENABLED = 'true';
90+
mockGetVersion.mockReturnValue('1.0.0');
91+
92+
const { result } = renderHookWithProvider(
93+
() => useRampsUnifiedV2Enabled(),
94+
{
95+
state: mockInitialState({
96+
rampsUnifiedBuyV2ActiveFlag: true,
97+
rampsUnifiedBuyV2MinimumVersionFlag: '2.0.0',
98+
}),
99+
},
100+
);
101+
102+
expect(result.current).toBe(true);
103+
});
104+
});
105+
106+
describe('Remote feature flag behavior when build flag is not set', () => {
107+
it('returns true when unified V2 is enabled and version meets the minimum requirement', () => {
108+
mockGetVersion.mockReturnValue('8.0.0');
109+
110+
const { result } = renderHookWithProvider(
111+
() => useRampsUnifiedV2Enabled(),
112+
{
113+
state: mockInitialState({
114+
rampsUnifiedBuyV2ActiveFlag: true,
115+
rampsUnifiedBuyV2MinimumVersionFlag: '7.63.0',
116+
}),
117+
},
118+
);
119+
120+
expect(result.current).toBe(true);
121+
});
122+
123+
it('returns false when unified V2 is disabled', () => {
124+
mockGetVersion.mockReturnValue('8.0.0');
125+
126+
const { result } = renderHookWithProvider(
127+
() => useRampsUnifiedV2Enabled(),
128+
{
129+
state: mockInitialState({
130+
rampsUnifiedBuyV2ActiveFlag: false,
131+
rampsUnifiedBuyV2MinimumVersionFlag: '7.63.0',
132+
}),
133+
},
134+
);
135+
136+
expect(result.current).toBe(false);
137+
});
138+
139+
it('returns false when version does not meet the minimum requirement', () => {
140+
mockGetVersion.mockReturnValue('7.0.0');
141+
142+
const { result } = renderHookWithProvider(
143+
() => useRampsUnifiedV2Enabled(),
144+
{
145+
state: mockInitialState({
146+
rampsUnifiedBuyV2ActiveFlag: true,
147+
rampsUnifiedBuyV2MinimumVersionFlag: '7.63.0',
148+
}),
149+
},
150+
);
151+
152+
expect(result.current).toBe(false);
153+
});
154+
155+
it('returns false when minimum version is not defined', () => {
156+
mockGetVersion.mockReturnValue('8.0.0');
157+
158+
const { result } = renderHookWithProvider(
159+
() => useRampsUnifiedV2Enabled(),
160+
{
161+
state: mockInitialState({
162+
rampsUnifiedBuyV2ActiveFlag: true,
163+
rampsUnifiedBuyV2MinimumVersionFlag: null,
164+
}),
165+
},
166+
);
167+
168+
expect(result.current).toBe(false);
169+
});
170+
171+
it('returns false when minimum version is undefined', () => {
172+
mockGetVersion.mockReturnValue('8.0.0');
173+
174+
const { result } = renderHookWithProvider(
175+
() => useRampsUnifiedV2Enabled(),
176+
{
177+
state: mockInitialState({
178+
rampsUnifiedBuyV2ActiveFlag: true,
179+
rampsUnifiedBuyV2MinimumVersionFlag: undefined,
180+
}),
181+
},
182+
);
183+
184+
expect(result.current).toBe(false);
185+
});
186+
187+
it('returns true when version equals minimum version exactly', () => {
188+
mockGetVersion.mockReturnValue('7.63.0');
189+
190+
const { result } = renderHookWithProvider(
191+
() => useRampsUnifiedV2Enabled(),
192+
{
193+
state: mockInitialState({
194+
rampsUnifiedBuyV2ActiveFlag: true,
195+
rampsUnifiedBuyV2MinimumVersionFlag: '7.63.0',
196+
}),
197+
},
198+
);
199+
200+
expect(result.current).toBe(true);
201+
});
202+
203+
it('returns false when both active flag and minimum version are not set', () => {
204+
mockGetVersion.mockReturnValue('8.0.0');
205+
206+
const { result } = renderHookWithProvider(
207+
() => useRampsUnifiedV2Enabled(),
208+
{
209+
state: mockInitialState({
210+
rampsUnifiedBuyV2ActiveFlag: false,
211+
rampsUnifiedBuyV2MinimumVersionFlag: null,
212+
}),
213+
},
214+
);
215+
216+
expect(result.current).toBe(false);
217+
});
218+
});
219+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useSelector } from 'react-redux';
2+
import {
3+
selectRampsUnifiedBuyV2ActiveFlag,
4+
selectRampsUnifiedBuyV2MinimumVersionFlag,
5+
} from '../../../../selectors/featureFlagController/ramps/rampsUnifiedBuyV2';
6+
import { hasMinimumRequiredVersion } from '../utils/hasMinimumRequiredVersion';
7+
8+
export default function useRampsUnifiedV2Enabled() {
9+
const rampsUnifiedBuyV2MinimumVersionFlag = useSelector(
10+
selectRampsUnifiedBuyV2MinimumVersionFlag,
11+
);
12+
const rampsUnifiedBuyV2ActiveFlag = useSelector(
13+
selectRampsUnifiedBuyV2ActiveFlag,
14+
);
15+
16+
const rampsUnifiedBuyV2BuildFlag =
17+
process.env.MM_RAMPS_UNIFIED_BUY_V2_ENABLED;
18+
19+
// if build flag is defined, it takes precedence over remote feature flag
20+
if (
21+
rampsUnifiedBuyV2BuildFlag === 'true' ||
22+
rampsUnifiedBuyV2BuildFlag === 'false'
23+
) {
24+
return rampsUnifiedBuyV2BuildFlag === 'true';
25+
}
26+
27+
const isRampsUnifiedV2Enabled = hasMinimumRequiredVersion(
28+
rampsUnifiedBuyV2MinimumVersionFlag,
29+
rampsUnifiedBuyV2ActiveFlag,
30+
);
31+
32+
return isRampsUnifiedV2Enabled;
33+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { getVersion } from 'react-native-device-info';
2+
import { hasMinimumRequiredVersion } from './hasMinimumRequiredVersion';
3+
4+
jest.mock('react-native-device-info', () => ({
5+
getVersion: jest.fn(),
6+
}));
7+
8+
describe('hasMinimumRequiredVersion', () => {
9+
const mockGetVersion = jest.mocked(getVersion);
10+
11+
beforeEach(() => {
12+
jest.clearAllMocks();
13+
});
14+
15+
describe('when feature is enabled', () => {
16+
it('returns true when current version exceeds minimum version', () => {
17+
mockGetVersion.mockReturnValue('8.0.0');
18+
19+
const result = hasMinimumRequiredVersion('7.63.0', true);
20+
21+
expect(result).toBe(true);
22+
});
23+
24+
it('returns true when current version equals minimum version', () => {
25+
mockGetVersion.mockReturnValue('7.63.0');
26+
27+
const result = hasMinimumRequiredVersion('7.63.0', true);
28+
29+
expect(result).toBe(true);
30+
});
31+
32+
it('returns false when current version is below minimum version', () => {
33+
mockGetVersion.mockReturnValue('7.0.0');
34+
35+
const result = hasMinimumRequiredVersion('7.63.0', true);
36+
37+
expect(result).toBe(false);
38+
});
39+
40+
it('returns false when minimum version is null', () => {
41+
mockGetVersion.mockReturnValue('8.0.0');
42+
43+
const result = hasMinimumRequiredVersion(null, true);
44+
45+
expect(result).toBe(false);
46+
});
47+
48+
it('returns false when minimum version is undefined', () => {
49+
mockGetVersion.mockReturnValue('8.0.0');
50+
51+
const result = hasMinimumRequiredVersion(undefined, true);
52+
53+
expect(result).toBe(false);
54+
});
55+
56+
it('returns false when minimum version is empty string', () => {
57+
mockGetVersion.mockReturnValue('8.0.0');
58+
59+
const result = hasMinimumRequiredVersion('', true);
60+
61+
expect(result).toBe(false);
62+
});
63+
});
64+
65+
describe('when feature is disabled', () => {
66+
it('returns false even when version meets requirement', () => {
67+
mockGetVersion.mockReturnValue('8.0.0');
68+
69+
const result = hasMinimumRequiredVersion('7.63.0', false);
70+
71+
expect(result).toBe(false);
72+
});
73+
74+
it('returns false when minimum version is null', () => {
75+
mockGetVersion.mockReturnValue('8.0.0');
76+
77+
const result = hasMinimumRequiredVersion(null, false);
78+
79+
expect(result).toBe(false);
80+
});
81+
});
82+
83+
describe('version comparison edge cases', () => {
84+
it('returns true when patch version exceeds minimum version', () => {
85+
mockGetVersion.mockReturnValue('7.63.1');
86+
87+
const result = hasMinimumRequiredVersion('7.63.0', true);
88+
89+
expect(result).toBe(true);
90+
});
91+
92+
it('returns true when minor version exceeds minimum version', () => {
93+
mockGetVersion.mockReturnValue('7.64.0');
94+
95+
const result = hasMinimumRequiredVersion('7.63.0', true);
96+
97+
expect(result).toBe(true);
98+
});
99+
100+
it('returns true when major version exceeds minimum version', () => {
101+
mockGetVersion.mockReturnValue('8.0.0');
102+
103+
const result = hasMinimumRequiredVersion('7.63.0', true);
104+
105+
expect(result).toBe(true);
106+
});
107+
});
108+
});

0 commit comments

Comments
 (0)