diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 856b26f1b506..618891a1a916 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -5485,6 +5485,17 @@ "reward": { "message": "Reward" }, + "rewardsOptIn": { + "message": "Sign up for Rewards" + }, + "rewardsPointsBalance": { + "message": "$1 points", + "description": "$1 is the formatted number of rewards points" + }, + "rewardsPointsIcon": { + "message": "Rewards Points", + "description": "Alt text for the rewards points icon" + }, "rpcNameOptional": { "message": "RPC Name (Optional)" }, diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json index 856b26f1b506..618891a1a916 100644 --- a/app/_locales/en_GB/messages.json +++ b/app/_locales/en_GB/messages.json @@ -5485,6 +5485,17 @@ "reward": { "message": "Reward" }, + "rewardsOptIn": { + "message": "Sign up for Rewards" + }, + "rewardsPointsBalance": { + "message": "$1 points", + "description": "$1 is the formatted number of rewards points" + }, + "rewardsPointsIcon": { + "message": "Rewards Points", + "description": "Alt text for the rewards points icon" + }, "rpcNameOptional": { "message": "RPC Name (Optional)" }, diff --git a/app/images/metamask-rewards-points.svg b/app/images/metamask-rewards-points.svg new file mode 100644 index 000000000000..18f9dba2822c --- /dev/null +++ b/app/images/metamask-rewards-points.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/scripts/controllers/rewards/rewards-controller.test.ts b/app/scripts/controllers/rewards/rewards-controller.test.ts index a1c444027e58..73b30503864e 100644 --- a/app/scripts/controllers/rewards/rewards-controller.test.ts +++ b/app/scripts/controllers/rewards/rewards-controller.test.ts @@ -29,6 +29,7 @@ import { RewardsControllerMessenger, } from '../../controller-init/messengers'; import { getRootMessenger } from '../../lib/messenger'; +import { SeasonDtoState } from '../../../../shared/types/rewards'; import { RewardsController, getRewardsControllerDefaultState, @@ -42,7 +43,6 @@ import type { SubscriptionDto, EstimatePointsDto, EstimatedPointsDto, - SeasonDtoState, DiscoverSeasonsDto, SeasonMetadataDto, SeasonStateDto, @@ -1075,6 +1075,34 @@ describe('RewardsController', () => { ); }); }); + + it('should throw AuthorizationFailedError when subscription token is missing', async () => { + const state: Partial = { + rewardsSeasons: { + [MOCK_SEASON_ID]: { + id: MOCK_SEASON_ID, + name: 'Season 1', + startDate: new Date('2024-01-01').getTime(), + endDate: new Date('2024-12-31').getTime(), + tiers: MOCK_SEASON_TIERS, + }, + }, + }; + + await withController( + { state, isDisabled: false }, + async ({ controller }) => { + await expect( + controller.getSeasonStatus(MOCK_SUBSCRIPTION_ID, MOCK_SEASON_ID), + ).rejects.toThrow( + `No subscription token found for subscription ID: ${MOCK_SUBSCRIPTION_ID}`, + ); + await expect( + controller.getSeasonStatus(MOCK_SUBSCRIPTION_ID, MOCK_SEASON_ID), + ).rejects.toBeInstanceOf(AuthorizationFailedError); + }, + ); + }); }); describe('optIn', () => { @@ -1676,6 +1704,102 @@ describe('RewardsController', () => { expect(result.addressesNeedingFresh).toEqual([MOCK_ACCOUNT_ADDRESS]); }); }); + + it('should force fresh check for not-opted-in accounts checked more than 5 minutes ago', async () => { + const state: Partial = { + rewardsAccounts: { + [MOCK_CAIP_ACCOUNT]: { + account: MOCK_CAIP_ACCOUNT, + hasOptedIn: false, + subscriptionId: null, + perpsFeeDiscount: null, + lastPerpsDiscountRateFetched: null, + lastFreshOptInStatusCheck: Date.now() - 1000 * 60 * 6, // 6 minutes ago (exceeds 5 minute threshold) + }, + }, + }; + + await withController({ state, isDisabled: false }, ({ controller }) => { + const addressToAccountMap = new Map(); + addressToAccountMap.set( + MOCK_ACCOUNT_ADDRESS.toLowerCase(), + MOCK_INTERNAL_ACCOUNT, + ); + + const result = controller.checkOptInStatusAgainstCache( + [MOCK_ACCOUNT_ADDRESS], + addressToAccountMap, + ); + + expect(result.cachedOptInResults).toEqual([null]); + expect(result.cachedSubscriptionIds).toEqual([null]); + expect(result.addressesNeedingFresh).toEqual([MOCK_ACCOUNT_ADDRESS]); + }); + }); + + it('should use cached data for not-opted-in accounts checked within 5 minutes', async () => { + const state: Partial = { + rewardsAccounts: { + [MOCK_CAIP_ACCOUNT]: { + account: MOCK_CAIP_ACCOUNT, + hasOptedIn: false, + subscriptionId: null, + perpsFeeDiscount: null, + lastPerpsDiscountRateFetched: null, + lastFreshOptInStatusCheck: Date.now() - 1000 * 60 * 2, // 2 minutes ago (within 5 minute threshold) + }, + }, + }; + + await withController({ state, isDisabled: false }, ({ controller }) => { + const addressToAccountMap = new Map(); + addressToAccountMap.set( + MOCK_ACCOUNT_ADDRESS.toLowerCase(), + MOCK_INTERNAL_ACCOUNT, + ); + + const result = controller.checkOptInStatusAgainstCache( + [MOCK_ACCOUNT_ADDRESS], + addressToAccountMap, + ); + + expect(result.cachedOptInResults).toEqual([false]); + expect(result.cachedSubscriptionIds).toEqual([null]); + expect(result.addressesNeedingFresh).toEqual([]); + }); + }); + + it('should force fresh check for not-opted-in accounts without lastFreshOptInStatusCheck', async () => { + const state: Partial = { + rewardsAccounts: { + [MOCK_CAIP_ACCOUNT]: { + account: MOCK_CAIP_ACCOUNT, + hasOptedIn: false, + subscriptionId: null, + perpsFeeDiscount: null, + lastPerpsDiscountRateFetched: null, + lastFreshOptInStatusCheck: undefined, + }, + }, + }; + + await withController({ state, isDisabled: false }, ({ controller }) => { + const addressToAccountMap = new Map(); + addressToAccountMap.set( + MOCK_ACCOUNT_ADDRESS.toLowerCase(), + MOCK_INTERNAL_ACCOUNT, + ); + + const result = controller.checkOptInStatusAgainstCache( + [MOCK_ACCOUNT_ADDRESS], + addressToAccountMap, + ); + + expect(result.cachedOptInResults).toEqual([null]); + expect(result.cachedSubscriptionIds).toEqual([null]); + expect(result.addressesNeedingFresh).toEqual([MOCK_ACCOUNT_ADDRESS]); + }); + }); }); describe('shouldSkipSilentAuth', () => { @@ -1724,6 +1848,30 @@ describe('RewardsController', () => { }); }); + it('should skip for not-opted-in accounts checked within 5 minutes', async () => { + const state: Partial = { + rewardsAccounts: { + [MOCK_CAIP_ACCOUNT]: { + account: MOCK_CAIP_ACCOUNT, + hasOptedIn: false, + subscriptionId: null, + perpsFeeDiscount: null, + lastPerpsDiscountRateFetched: null, + lastFreshOptInStatusCheck: Date.now() - 1000 * 60 * 2, // 2 minutes ago (within 5 minute threshold) + }, + }, + }; + + await withController({ state, isDisabled: false }, ({ controller }) => { + const result = controller.shouldSkipSilentAuth( + MOCK_CAIP_ACCOUNT, + MOCK_INTERNAL_ACCOUNT, + ); + + expect(result).toBe(true); + }); + }); + it('should not skip for stale not-opted-in accounts', async () => { const state: Partial = { rewardsAccounts: { @@ -1733,7 +1881,7 @@ describe('RewardsController', () => { subscriptionId: null, perpsFeeDiscount: null, lastPerpsDiscountRateFetched: null, - lastFreshOptInStatusCheck: Date.now() - 1000 * 60 * 60 * 24 * 2, // 2 days ago + lastFreshOptInStatusCheck: Date.now() - 1000 * 60 * 6, // 6 minutes ago (exceeds 5 minute threshold) }, }, }; @@ -2532,11 +2680,14 @@ describe('Additional RewardsController edge cases', () => { }); describe('getCandidateSubscriptionId - error scenarios', () => { - it('should return subscription ID from cache if session token exists', async () => { + it('should return subscription ID from cache if session token and subscription exist', async () => { const state: Partial = { rewardsSubscriptionTokens: { [MOCK_SUBSCRIPTION_ID]: MOCK_SESSION_TOKEN, }, + rewardsSubscriptions: { + [MOCK_SUBSCRIPTION_ID]: MOCK_SUBSCRIPTION, + }, }; await withController( @@ -2579,6 +2730,42 @@ describe('Additional RewardsController edge cases', () => { }, ); }); + + it('should continue to silent auth when subscription exists but session token is missing', async () => { + const state: Partial = { + rewardsSubscriptions: { + [MOCK_SUBSCRIPTION_ID]: MOCK_SUBSCRIPTION, + }, + }; + + await withController( + { state, isDisabled: false }, + async ({ controller, mockMessengerCall }) => { + mockMessengerCall.mockImplementation((actionType) => { + if (actionType === 'AccountsController:listMultichainAccounts') { + return [MOCK_INTERNAL_ACCOUNT]; + } + if (actionType === 'RewardsDataService:getOptInStatus') { + return Promise.resolve({ + ois: [true], + sids: [MOCK_SUBSCRIPTION_ID], + }); + } + if (actionType === 'KeyringController:signPersonalMessage') { + return Promise.resolve('0xmocksignature'); + } + if (actionType === 'RewardsDataService:login') { + return Promise.resolve(MOCK_LOGIN_RESPONSE); + } + return undefined; + }); + + const result = await controller.getCandidateSubscriptionId(); + + expect(result).toBe(MOCK_SUBSCRIPTION_ID); + }, + ); + }); }); describe('linkAccountsToSubscriptionCandidate - error handling', () => { diff --git a/app/scripts/controllers/rewards/rewards-controller.ts b/app/scripts/controllers/rewards/rewards-controller.ts index c4ad633a89a5..4dfb6b629320 100644 --- a/app/scripts/controllers/rewards/rewards-controller.ts +++ b/app/scripts/controllers/rewards/rewards-controller.ts @@ -12,15 +12,17 @@ import { base58, isAddress as isEvmAddress } from 'ethers/lib/utils'; import { HandleSnapRequest } from '@metamask/snaps-controllers'; import { RewardsControllerMessenger } from '../../controller-init/messengers/rewards-controller-messenger'; import { isHardwareAccount } from '../../../../shared/lib/accounts'; +import { + SeasonDtoState, + SeasonStatusState, + SeasonTierState, +} from '../../../../shared/types/rewards'; import { type RewardsControllerState, type RewardsAccountState, type LoginResponseDto, type EstimatePointsDto, type EstimatedPointsDto, - type SeasonDtoState, - type SeasonStatusState, - type SeasonTierState, type SeasonTierDto, type SeasonStatusDto, type SubscriptionDto, @@ -49,8 +51,8 @@ const SEASON_STATUS_CACHE_THRESHOLD_MS = 1000 * 60 * 1; // 1 minute // Season metadata cache threshold const SEASON_METADATA_CACHE_THRESHOLD_MS = 1000 * 60 * 10; // 10 minutes -// Opt-in status stale threshold for not opted-in accounts to force a fresh check -const NOT_OPTED_IN_OIS_STALE_CACHE_THRESHOLD_MS = 1000 * 60 * 60 * 24; // 24 hours +// Opt-in status stale threshold for not opted-in accounts to force a fresh check (less strict than in mobile for now) +const NOT_OPTED_IN_OIS_STALE_CACHE_THRESHOLD_MS = 1000 * 60 * 5; // 5 minutes /** * State metadata for the RewardsController @@ -845,7 +847,6 @@ export class RewardsController extends BaseController< // Update state with successful authentication subscription = loginResponse.subscription; - // TODO: Re-enable multi-subscription token vault when implemented // Store the session token for this subscription this.#storeSubscriptionToken(subscription.id, loginResponse.sessionId); @@ -1263,11 +1264,6 @@ export class RewardsController extends BaseController< if (!cached) { return undefined; } - log.info( - 'RewardsController: Using cached season status data for', - subscriptionId, - seasonId, - ); return { payload: cached, lastFetched: cached.lastFetched }; }, fetchFresh: async () => { @@ -1279,7 +1275,7 @@ export class RewardsController extends BaseController< ); const subscriptionToken = this.#getSubscriptionToken(subscriptionId); if (!subscriptionToken) { - throw new Error( + throw new AuthorizationFailedError( `No subscription token found for subscription ID: ${subscriptionId}`, ); } @@ -1406,6 +1402,7 @@ export class RewardsController extends BaseController< } state.rewardsAccounts = {}; state.rewardsSubscriptions = {}; + state.rewardsSubscriptionTokens = {}; }); log.info('RewardsController: Invalidated accounts and subscriptions'); } @@ -1804,7 +1801,11 @@ export class RewardsController extends BaseController< const sessionToken = subscriptionId ? this.state.rewardsSubscriptionTokens[subscriptionId] : undefined; - if (subscriptionId && Boolean(sessionToken)) { + if ( + subscriptionId && + Boolean(sessionToken) && + this.state.rewardsSubscriptions[subscriptionId] + ) { return subscriptionId; } try { diff --git a/app/scripts/controllers/rewards/rewards-controller.types.ts b/app/scripts/controllers/rewards/rewards-controller.types.ts index 8b6d9bf72874..1c6d98b817f5 100644 --- a/app/scripts/controllers/rewards/rewards-controller.types.ts +++ b/app/scripts/controllers/rewards/rewards-controller.types.ts @@ -1,5 +1,10 @@ import { CaipAccountId, CaipAssetType } from '@metamask/utils'; import { InternalAccount } from '@metamask/keyring-internal-api'; +import { + SeasonDtoState, + SeasonRewardType, + SeasonStatusState, +} from '../../../../shared/types/rewards'; export type LoginResponseDto = { sessionId: string; @@ -410,13 +415,6 @@ export type SeasonRewardDto = { rewardType: SeasonRewardType; }; -export enum SeasonRewardType { - Generic = 'Generic', - PerpsDiscount = 'PerpsDiscount', - PointsBoost = 'PointsBoost', - AlphaFoxInvite = 'AlphaFoxInvite', -} - export type SeasonDto = { id: string; name: string; @@ -607,68 +605,6 @@ export type ThemeImage = { darkModeUrl: string; }; -export type ClaimRewardDto = { - data?: Record; -}; - -// Serializable versions for state storage (Date objects converted to timestamps) -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type SeasonRewardDtoState = { - id: string; - name: string; - shortDescription: string; - longDescription: string; - shortUnlockedDescription: string; - longUnlockedDescription: string; - claimUrl?: string; - iconName: string; - rewardType: SeasonRewardType; -}; - -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type SeasonTierDtoState = { - id: string; - name: string; - pointsNeeded: number; - image: { - lightModeUrl: string; - darkModeUrl: string; - }; - levelNumber: string; - rewards: SeasonRewardDtoState[]; -}; - -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type SeasonDtoState = { - id: string; - name: string; - startDate: number; // timestamp - endDate: number; // timestamp - tiers: SeasonTierDtoState[]; - lastFetched?: number; -}; - -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type SeasonStatusBalanceDtoState = { - total: number; - updatedAt?: number; // timestamp -}; - -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type SeasonTierState = { - currentTier: SeasonTierDtoState; - nextTier: SeasonTierDtoState | null; - nextTierPointsNeeded: number | null; -}; - -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type SeasonStatusState = { - season: SeasonDtoState; - balance: SeasonStatusBalanceDtoState; - tier: SeasonTierState; - lastFetched?: number; -}; - // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type RewardsAccountState = { account: CaipAccountId; diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index cc59691aba58..c0e4a38a85ce 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -2444,6 +2444,20 @@ export default class MetamaskController extends EventEmitter { this.subscriptionController, ), + // rewards + getCandidateSubscriptionId: this.controllerMessenger.call.bind( + this.controllerMessenger, + 'RewardsController:getCandidateSubscriptionId', + ), + getRewardsSeasonMetadata: this.controllerMessenger.call.bind( + this.controllerMessenger, + 'RewardsController:getSeasonMetadata', + ), + getRewardsSeasonStatus: this.controllerMessenger.call.bind( + this.controllerMessenger, + 'RewardsController:getSeasonStatus', + ), + // hardware wallets connectHardware: this.connectHardware.bind(this), forgetDevice: this.forgetDevice.bind(this), diff --git a/shared/types/background.ts b/shared/types/background.ts index baadeb4e289a..f618a63835ef 100644 --- a/shared/types/background.ts +++ b/shared/types/background.ts @@ -70,6 +70,7 @@ import type { OnboardingControllerState } from '../../app/scripts/controllers/on import type { MetaMetricsControllerState } from '../../app/scripts/controllers/metametrics-controller'; import type { AppMetadataControllerState } from '../../app/scripts/controllers/app-metadata'; import type { SwapsControllerState } from '../../app/scripts/controllers/swaps/swaps.types'; +import type { RewardsControllerState } from '../../app/scripts/controllers/rewards/rewards-controller.types'; export type ControllerStatePropertiesEnumerated = { internalAccounts: AccountsControllerState['internalAccounts']; @@ -310,6 +311,12 @@ export type ControllerStatePropertiesEnumerated = { isAccountSyncingEnabled: UserStorageController.UserStorageControllerState['isAccountSyncingEnabled']; isContactSyncingEnabled: UserStorageController.UserStorageControllerState['isContactSyncingEnabled']; isContactSyncingInProgress: UserStorageController.UserStorageControllerState['isContactSyncingInProgress']; + rewardsActiveAccount: RewardsControllerState['rewardsActiveAccount']; + rewardsAccounts: RewardsControllerState['rewardsAccounts']; + rewardsSubscriptions: RewardsControllerState['rewardsSubscriptions']; + rewardsSeasons: RewardsControllerState['rewardsSeasons']; + rewardsSeasonStatuses: RewardsControllerState['rewardsSeasonStatuses']; + rewardsSubscriptionTokens: RewardsControllerState['rewardsSubscriptionTokens']; }; type ControllerStateTypesMerged = AccountsControllerState & @@ -370,7 +377,8 @@ type ControllerStateTypesMerged = AccountsControllerState & TokenRatesControllerState & TransactionControllerState & UserOperationControllerState & - UserStorageController.UserStorageControllerState; + UserStorageController.UserStorageControllerState & + RewardsControllerState; // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/shared/types/rewards.ts b/shared/types/rewards.ts new file mode 100644 index 000000000000..4be5c1d4d082 --- /dev/null +++ b/shared/types/rewards.ts @@ -0,0 +1,62 @@ +/** + * Shared types for rewards functionality + * These types are used across UI and background to avoid import restrictions + */ + +export enum SeasonRewardType { + Generic = 'Generic', + PerpsDiscount = 'PerpsDiscount', + PointsBoost = 'PointsBoost', + AlphaFoxInvite = 'AlphaFoxInvite', +} + +export type SeasonRewardDtoState = { + id: string; + name: string; + shortDescription: string; + longDescription: string; + shortUnlockedDescription: string; + longUnlockedDescription: string; + claimUrl?: string; + iconName: string; + rewardType: SeasonRewardType; +}; + +export type SeasonTierDtoState = { + id: string; + name: string; + pointsNeeded: number; + image: { + lightModeUrl: string; + darkModeUrl: string; + }; + levelNumber: string; + rewards: SeasonRewardDtoState[]; +}; + +export type SeasonDtoState = { + id: string; + name: string; + startDate: number; // timestamp + endDate: number; // timestamp + tiers: SeasonTierDtoState[]; + lastFetched?: number; +}; + +export type SeasonStatusBalanceDtoState = { + total: number; + updatedAt?: number; // timestamp +}; + +export type SeasonTierState = { + currentTier: SeasonTierDtoState; + nextTier: SeasonTierDtoState | null; + nextTierPointsNeeded: number | null; +}; + +export type SeasonStatusState = { + season: SeasonDtoState; + balance: SeasonStatusBalanceDtoState; + tier: SeasonTierState; + lastFetched?: number; +}; diff --git a/ui/components/app/assets/account-group-balance-change/account-group-balance-change.test.tsx b/ui/components/app/assets/account-group-balance-change/account-group-balance-change.test.tsx index 55a82fc2e6f5..8e703681d33d 100644 --- a/ui/components/app/assets/account-group-balance-change/account-group-balance-change.test.tsx +++ b/ui/components/app/assets/account-group-balance-change/account-group-balance-change.test.tsx @@ -38,7 +38,7 @@ describe('AccountGroupBalanceChange', () => { const renderComponent = () => renderWithProvider( - null} />, + null} />, mockStore, ); diff --git a/ui/components/app/assets/account-group-balance-change/account-group-balance-change.tsx b/ui/components/app/assets/account-group-balance-change/account-group-balance-change.tsx index 9c00e5b50eea..b642c1747df8 100644 --- a/ui/components/app/assets/account-group-balance-change/account-group-balance-change.tsx +++ b/ui/components/app/assets/account-group-balance-change/account-group-balance-change.tsx @@ -18,14 +18,14 @@ import { useAccountGroupBalanceDisplay } from './useAccountGroupBalanceDisplay'; export type AccountGroupBalanceChangeProps = { period: BalanceChangePeriod; - portfolioButton: () => JSX.Element | null; + trailingChild: () => JSX.Element | null; }; const balanceAmountSpanStyle = { whiteSpace: 'pre' } as const; const AccountGroupBalanceChangeComponent: React.FC< AccountGroupBalanceChangeProps -> = ({ period, portfolioButton }) => { +> = ({ period, trailingChild }) => { const { privacyMode, color, amountChange, percentChange } = useAccountGroupBalanceDisplay(period); const { formatCurrency, formatPercentWithMinThreshold } = useFormatters(); @@ -61,7 +61,7 @@ const AccountGroupBalanceChangeComponent: React.FC< {`(${formatPercentWithMinThreshold(percentChange, { signDisplay: 'always' })})`} - {portfolioButton()} + {trailingChild()} ); }; diff --git a/ui/components/app/rewards/RewardsPointsBalance.test.tsx b/ui/components/app/rewards/RewardsPointsBalance.test.tsx new file mode 100644 index 000000000000..351d9933b8a4 --- /dev/null +++ b/ui/components/app/rewards/RewardsPointsBalance.test.tsx @@ -0,0 +1,269 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { useSelector } from 'react-redux'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { useRewardsContext } from '../../../contexts/rewards'; +import type { RewardsContextValue } from '../../../contexts/rewards'; +import { RewardsPointsBalance } from './RewardsPointsBalance'; + +// Mock dependencies +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../hooks/useI18nContext', () => ({ + useI18nContext: jest.fn(), +})); + +jest.mock('../../../contexts/rewards', () => ({ + useRewardsContext: jest.fn(), +})); + +jest.mock('../../component-library/skeleton', () => ({ + Skeleton: ({ width }: { width: string }) => ( +
+ Loading... +
+ ), +})); + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseI18nContext = useI18nContext as jest.MockedFunction< + typeof useI18nContext +>; +const mockUseRewardsContext = useRewardsContext as jest.MockedFunction< + typeof useRewardsContext +>; + +describe('RewardsPointsBalance', () => { + const mockT = jest.fn((key: string, values?: string[]) => { + if (key === 'rewardsOptIn') { + return 'Opt In'; + } + if (key === 'rewardsPointsBalance' && values) { + return `${values[0]} points`; + } + if (key === 'rewardsPointsIcon') { + return 'Rewards Points Icon'; + } + return key; + }); + + // Mock season status with complete structure + const mockSeasonStatus = { + season: { + id: 'test-season', + name: 'Test Season', + startDate: Date.now() - 86400000, // 1 day ago + endDate: Date.now() + 86400000, // 1 day from now + tiers: [], + }, + balance: { + total: 1000, + }, + tier: { + currentTier: { + id: 'tier-1', + name: 'Bronze', + pointsNeeded: 0, + image: { + lightModeUrl: 'light.png', + darkModeUrl: 'dark.png', + }, + levelNumber: '1', + rewards: [], + }, + nextTier: null, + nextTierPointsNeeded: null, + }, + }; + + // Mock rewards context value with complete structure + const mockRewardsContextValue: RewardsContextValue = { + rewardsEnabled: true, + candidateSubscriptionId: 'test-subscription-id', + candidateSubscriptionIdError: false, + seasonStatus: mockSeasonStatus, + seasonStatusError: null, + seasonStatusLoading: false, + refetchSeasonStatus: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseI18nContext.mockReturnValue(mockT); + mockUseSelector.mockReturnValue('en-US'); // Default locale + }); + + it('should render null when rewards are not enabled', () => { + mockUseRewardsContext.mockReturnValue({ + ...mockRewardsContextValue, + rewardsEnabled: false, + seasonStatus: null, + candidateSubscriptionId: null, + }); + + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('should render null when candidateSubscriptionId is null', () => { + mockUseRewardsContext.mockReturnValue({ + ...mockRewardsContextValue, + seasonStatus: null, + candidateSubscriptionId: null, + }); + + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('should render skeleton when loading and no balance exists', () => { + mockUseRewardsContext.mockReturnValue({ + ...mockRewardsContextValue, + seasonStatus: null, + seasonStatusLoading: true, + }); + + render(); + + expect(screen.getByTestId('skeleton')).toBeInTheDocument(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('should not render skeleton when loading but balance exists', () => { + mockUseRewardsContext.mockReturnValue({ + ...mockRewardsContextValue, + seasonStatusLoading: true, + }); + + render(); + + expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument(); + expect(screen.getByTestId('rewards-points-balance')).toBeInTheDocument(); + expect( + screen.getByTestId('rewards-points-balance-value'), + ).toHaveTextContent('1,000 points'); + }); + + it('should render formatted points balance with default locale', () => { + mockUseRewardsContext.mockReturnValue({ + ...mockRewardsContextValue, + seasonStatus: { + ...mockSeasonStatus, + balance: { + total: 12345, + }, + }, + }); + + render(); + + expect(screen.getByTestId('rewards-points-balance')).toBeInTheDocument(); + expect( + screen.getByTestId('rewards-points-balance-value'), + ).toHaveTextContent('12,345 points'); + expect(screen.getByAltText('Rewards Points Icon')).toBeInTheDocument(); + }); + + it('should render formatted points balance with German locale', () => { + mockUseSelector.mockReturnValue('de-DE'); + mockUseRewardsContext.mockReturnValue({ + ...mockRewardsContextValue, + seasonStatus: { + ...mockSeasonStatus, + balance: { + total: 12345, + }, + }, + }); + + render(); + + expect(screen.getByTestId('rewards-points-balance')).toBeInTheDocument(); + expect( + screen.getByTestId('rewards-points-balance-value'), + ).toHaveTextContent('12.345 points'); + }); + + it('should render zero points correctly', () => { + mockUseRewardsContext.mockReturnValue({ + ...mockRewardsContextValue, + seasonStatus: { + ...mockSeasonStatus, + balance: { + total: 0, + }, + }, + }); + + render(); + + expect(screen.getByTestId('rewards-points-balance')).toBeInTheDocument(); + expect( + screen.getByTestId('rewards-points-balance-value'), + ).toHaveTextContent('0 points'); + }); + + it('should handle undefined seasonStatus gracefully', () => { + mockUseRewardsContext.mockReturnValue({ + ...mockRewardsContextValue, + seasonStatus: null, + }); + + render(); + + expect(screen.getByTestId('rewards-points-balance')).toBeInTheDocument(); + expect( + screen.getByTestId('rewards-points-balance-value'), + ).toHaveTextContent('0 points'); + }); + + it('should render with correct CSS classes and structure', () => { + mockUseRewardsContext.mockReturnValue(mockRewardsContextValue); + + render(); + + const container = screen.getByTestId('rewards-points-balance'); + expect(container).toHaveClass( + 'flex', + 'items-center', + 'gap-1', + 'px-1.5', + 'bg-background-muted', + 'rounded', + ); + + const image = screen.getByAltText('Rewards Points Icon'); + expect(image).toHaveAttribute( + 'src', + './images/metamask-rewards-points.svg', + ); + expect(image).toHaveStyle({ width: '16px', height: '16px' }); + }); + + it('should call useSelector with getIntlLocale selector', () => { + mockUseRewardsContext.mockReturnValue(mockRewardsContextValue); + + render(); + + expect(mockUseSelector).toHaveBeenCalled(); + }); + + it('should call useI18nContext hook', () => { + mockUseRewardsContext.mockReturnValue(mockRewardsContextValue); + + render(); + + expect(mockUseI18nContext).toHaveBeenCalled(); + }); + + it('should call useRewardsContext hook', () => { + mockUseRewardsContext.mockReturnValue(mockRewardsContextValue); + + render(); + + expect(mockUseRewardsContext).toHaveBeenCalled(); + }); +}); diff --git a/ui/components/app/rewards/RewardsPointsBalance.tsx b/ui/components/app/rewards/RewardsPointsBalance.tsx new file mode 100644 index 000000000000..8ff8b773b3b4 --- /dev/null +++ b/ui/components/app/rewards/RewardsPointsBalance.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { Box, Text, TextVariant } from '@metamask/design-system-react'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { getIntlLocale } from '../../../ducks/locale/locale'; +import { useRewardsContext } from '../../../contexts/rewards'; +import { Skeleton } from '../../component-library/skeleton'; + +const RewardsBadge = ({ text }: { text: string }) => { + const t = useI18nContext(); + + return ( + + {t('rewardsPointsIcon')} + + {text} + + + ); +}; + +/** + * Component to display the rewards points balance + * Shows the points balance with an icon for users who haven't opted in yet + * (i.e., when rewardsActiveAccount?.subscriptionId is null) + */ +export const RewardsPointsBalance = () => { + const t = useI18nContext(); + const locale = useSelector(getIntlLocale); + + const { + rewardsEnabled, + seasonStatus, + seasonStatusLoading, + candidateSubscriptionId, + } = useRewardsContext(); + + if (!rewardsEnabled) { + return null; + } + + if (!candidateSubscriptionId) { + return null; + } + + if (seasonStatusLoading && !seasonStatus?.balance) { + return ; + } + + // Format the points balance with proper locale-aware number formatting + const formattedPoints = new Intl.NumberFormat(locale).format( + seasonStatus?.balance?.total ?? 0, + ); + + return ; +}; diff --git a/ui/components/app/rewards/index.ts b/ui/components/app/rewards/index.ts new file mode 100644 index 000000000000..76ceef7614cc --- /dev/null +++ b/ui/components/app/rewards/index.ts @@ -0,0 +1 @@ +export { RewardsPointsBalance } from './RewardsPointsBalance'; diff --git a/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.test.tsx b/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.test.tsx index 62ebf51fd5c2..966734eaa9c4 100644 --- a/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.test.tsx +++ b/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.test.tsx @@ -429,9 +429,7 @@ describe('AggregatedPercentageOverviewCrossChains', () => { totalFiatBalance: 289.96, }); const { container } = render( - null} - />, + null} />, ); expect(container).toMatchSnapshot(); }); @@ -457,7 +455,7 @@ describe('AggregatedPercentageOverviewCrossChains', () => { }); render( - null} />, + null} />, ); const percentageElement = screen.getByText('(+0.00%)'); const numberElement = screen.getByText('+$0.00'); @@ -504,7 +502,7 @@ describe('AggregatedPercentageOverviewCrossChains', () => { const expectedAmountChange = '-$0.97'; const expectedPercentageChange = '(-0.33%)'; render( - null} />, + null} />, ); const percentageElement = screen.getByText(expectedPercentageChange); const numberElement = screen.getByText(expectedAmountChange); @@ -551,7 +549,7 @@ describe('AggregatedPercentageOverviewCrossChains', () => { const expectedAmountChange = '+$0.96'; const expectedPercentageChange = '(+0.33%)'; render( - null} />, + null} />, ); const percentageElement = screen.getByText(expectedPercentageChange); const numberElement = screen.getByText(expectedAmountChange); @@ -597,7 +595,7 @@ describe('AggregatedPercentageOverviewCrossChains', () => { const expectedAmountChange = '+$0.22'; const expectedPercentageChange = '(+0.08%)'; render( - null} />, + null} />, ); const percentageElement = screen.getByText(expectedPercentageChange); const numberElement = screen.getByText(expectedAmountChange); diff --git a/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.tsx b/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.tsx index be8b8164480a..2b7b26a32dca 100644 --- a/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.tsx +++ b/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.tsx @@ -32,9 +32,9 @@ import { Skeleton } from '../../component-library/skeleton'; import { isZeroAmount } from '../../../helpers/utils/number-utils'; export const AggregatedPercentageOverviewCrossChains = ({ - portfolioButton, + trailingChild, }: { - portfolioButton: () => JSX.Element | null; + trailingChild: () => JSX.Element | null; }) => { const { formatCurrencyCompact } = useFormatters(); const fiatCurrency = useSelector(getCurrentCurrency); @@ -187,7 +187,7 @@ export const AggregatedPercentageOverviewCrossChains = ({ {formattedPercentChangeCrossChains} - {portfolioButton()} + {trailingChild()} ); }; diff --git a/ui/components/app/wallet-overview/aggregated-percentage-overview.stories.tsx b/ui/components/app/wallet-overview/aggregated-percentage-overview.stories.tsx index 8b76289bc62e..b969f37af71d 100644 --- a/ui/components/app/wallet-overview/aggregated-percentage-overview.stories.tsx +++ b/ui/components/app/wallet-overview/aggregated-percentage-overview.stories.tsx @@ -23,16 +23,16 @@ const Story = { export default Story; export const Default = () => ( - null} /> + null} /> ); export const Multichain = () => ( - null} /> + null} /> ); export const MultichainPrivacyMode = () => ( null} + trailingChild={() => null} /> ); diff --git a/ui/components/app/wallet-overview/aggregated-percentage-overview.test.tsx b/ui/components/app/wallet-overview/aggregated-percentage-overview.test.tsx index 6ea41f8396a2..edfdcfb7cf5d 100644 --- a/ui/components/app/wallet-overview/aggregated-percentage-overview.test.tsx +++ b/ui/components/app/wallet-overview/aggregated-percentage-overview.test.tsx @@ -234,7 +234,7 @@ describe('AggregatedPercentageOverview', () => { totalFiatBalance: 0, }); const { container } = render( - null} />, + null} />, ); expect(container).toMatchSnapshot(); }); @@ -252,7 +252,7 @@ describe('AggregatedPercentageOverview', () => { totalFiatBalance: 0, }); - render( null} />); + render( null} />); const percentageElement = screen.getByText('(+0.00%)'); const numberElement = screen.getByText('+$0.00'); expect(percentageElement).toBeInTheDocument(); @@ -311,7 +311,7 @@ describe('AggregatedPercentageOverview', () => { }); const expectedAmountChange = '-$0.09'; const expectedPercentageChange = '(-0.29%)'; - render( null} />); + render( null} />); const percentageElement = screen.getByText(expectedPercentageChange); const numberElement = screen.getByText(expectedAmountChange); expect(percentageElement).toBeInTheDocument(); @@ -371,7 +371,7 @@ describe('AggregatedPercentageOverview', () => { mockGetTokensMarketData.mockReturnValue(positiveMarketDataMock); const expectedAmountChange = '+$0.09'; const expectedPercentageChange = '(+0.29%)'; - render( null} />); + render( null} />); const percentageElement = screen.getByText(expectedPercentageChange); const numberElement = screen.getByText(expectedAmountChange); expect(percentageElement).toBeInTheDocument(); @@ -431,7 +431,7 @@ describe('AggregatedPercentageOverview', () => { mockGetTokensMarketData.mockReturnValue(mixedMarketDataMock); const expectedAmountChange = '-$0.07'; const expectedPercentageChange = '(-0.23%)'; - render( null} />); + render( null} />); const percentageElement = screen.getByText(expectedPercentageChange); const numberElement = screen.getByText(expectedAmountChange); expect(percentageElement).toBeInTheDocument(); @@ -542,7 +542,7 @@ describe('AggregatedPercentageOverview', () => { }); const expectedAmountChange = '-$0.39'; const expectedPercentageChange = '(-1.08%)'; - render( null} />); + render( null} />); const percentageElement = screen.getByText(expectedPercentageChange); const numberElement = screen.getByText(expectedAmountChange); expect(percentageElement).toBeInTheDocument(); @@ -640,7 +640,7 @@ describe('AggregatedPercentageOverview', () => { }); const expectedAmountChange = '-$0.01'; const expectedPercentageChange = '(-0.03%)'; - render( null} />); + render( null} />); const percentageElement = screen.getByText(expectedPercentageChange); const numberElement = screen.getByText(expectedAmountChange); expect(percentageElement).toBeInTheDocument(); @@ -684,7 +684,7 @@ describe('AggregatedMultichainPercentageOverview', () => { describe('render', () => { it('renders correctly with zero values', () => { const { container } = render( - null} />, + null} />, ); expect(container).toMatchSnapshot(); }); @@ -701,7 +701,7 @@ describe('AggregatedMultichainPercentageOverview', () => { }); const { container } = render( - null} />, + null} />, ); expect(container).toMatchSnapshot(); }); @@ -718,7 +718,7 @@ describe('AggregatedMultichainPercentageOverview', () => { }); const { container } = render( - null} />, + null} />, ); expect(container).toMatchSnapshot(); }); @@ -726,7 +726,7 @@ describe('AggregatedMultichainPercentageOverview', () => { it('should display zero percentage and amount when balance is zero', () => { render( - null} />, + null} />, ); const percentageElement = screen.getByTestId( 'aggregated-percentage-change', @@ -748,7 +748,7 @@ describe('AggregatedMultichainPercentageOverview', () => { }); render( - null} />, + null} />, ); const percentageElement = screen.getByTestId( 'aggregated-percentage-change', @@ -770,7 +770,7 @@ describe('AggregatedMultichainPercentageOverview', () => { }); render( - null} />, + null} />, ); const percentageElement = screen.getByTestId( 'aggregated-percentage-change', @@ -793,7 +793,7 @@ describe('AggregatedMultichainPercentageOverview', () => { render( null} + trailingChild={() => null} privacyMode={true} />, ); @@ -817,7 +817,7 @@ describe('AggregatedMultichainPercentageOverview', () => { }); render( - null} />, + null} />, ); const percentageElement = screen.getByTestId( 'aggregated-percentage-change', @@ -839,7 +839,7 @@ describe('AggregatedMultichainPercentageOverview', () => { }); render( - null} />, + null} />, ); const percentageElement = screen.getByTestId( 'aggregated-percentage-change', @@ -851,7 +851,7 @@ describe('AggregatedMultichainPercentageOverview', () => { it('should use correct color for zero values', () => { render( - null} />, + null} />, ); const percentageElement = screen.getByTestId( 'aggregated-percentage-change', diff --git a/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx b/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx index d85ac0b5a572..ae8fc864a779 100644 --- a/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx +++ b/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx @@ -38,9 +38,9 @@ type MarketDataDetails = { }; export const AggregatedPercentageOverview = ({ - portfolioButton, + trailingChild, }: { - portfolioButton: () => JSX.Element | null; + trailingChild: () => JSX.Element | null; }) => { const tokensMarketData: Record = useSelector(getTokensMarketData); @@ -148,16 +148,16 @@ export const AggregatedPercentageOverview = ({ {formattedPercentChange} - {portfolioButton()} + {trailingChild()} ); }; export const AggregatedMultichainPercentageOverview = ({ - portfolioButton, + trailingChild, privacyMode = false, }: { - portfolioButton: () => JSX.Element | null; + trailingChild: () => JSX.Element | null; privacyMode?: boolean; }) => { const locale = useSelector(getIntlLocale); @@ -240,7 +240,7 @@ export const AggregatedMultichainPercentageOverview = ({ {localizedPercentChange}) - {portfolioButton()} + {trailingChild()} ); }; diff --git a/ui/components/app/wallet-overview/coin-overview.tsx b/ui/components/app/wallet-overview/coin-overview.tsx index 7c1376f65d11..1e88567e93e3 100644 --- a/ui/components/app/wallet-overview/coin-overview.tsx +++ b/ui/components/app/wallet-overview/coin-overview.tsx @@ -52,6 +52,8 @@ import { useMultichainSelector } from '../../../hooks/useMultichainSelector'; import { AggregatedBalance } from '../../ui/aggregated-balance/aggregated-balance'; import { Skeleton } from '../../component-library/skeleton'; import { isZeroAmount } from '../../../helpers/utils/number-utils'; +import { useRewardsEnabled } from '../../../hooks/rewards'; +import { RewardsPointsBalance } from '../rewards'; import WalletOverview from './wallet-overview'; import CoinButtons from './coin-buttons'; import { @@ -220,6 +222,7 @@ export const CoinOverview = ({ const anyEnabledNetworksAreAvailable = useSelector( selectAnyEnabledNetworksAreAvailable, ); + const isRewardsEnabled = useRewardsEnabled(); const handleSensitiveToggle = () => { dispatch(setPrivacyMode(!privacyMode)); @@ -245,7 +248,10 @@ export const CoinOverview = ({ }, [isMarketingEnabled, isMetaMetricsEnabled, metaMetricsId, trackEvent]); const renderPercentageAndAmountChange = () => { - const renderPortfolioButton = () => { + const renderPercentageAndAmountChangeTrail = () => { + if (isRewardsEnabled) { + return ; + } return ( ); - return null; }; const renderNativeTokenView = () => { @@ -270,7 +275,7 @@ export const CoinOverview = ({ > - {renderPortfolioButton()} + {renderPercentageAndAmountChangeTrail()} ); @@ -280,11 +285,11 @@ export const CoinOverview = ({ {isTokenNetworkFilterEqualCurrentNetwork ? ( ) : ( )} @@ -294,7 +299,7 @@ export const CoinOverview = ({ ); @@ -305,7 +310,7 @@ export const CoinOverview = ({ ); diff --git a/ui/components/app/wallet-overview/index.scss b/ui/components/app/wallet-overview/index.scss index dc1de8761849..f18172536745 100644 --- a/ui/components/app/wallet-overview/index.scss +++ b/ui/components/app/wallet-overview/index.scss @@ -39,6 +39,7 @@ display: flex; flex-direction: row; gap: 8px; + flex-wrap: wrap; } } @@ -72,7 +73,6 @@ min-width: 0; position: relative; align-items: start; - max-width: 326px; } &__primary-container { diff --git a/ui/contexts/rewards/index.tsx b/ui/contexts/rewards/index.tsx new file mode 100644 index 000000000000..1b6ffd98959a --- /dev/null +++ b/ui/contexts/rewards/index.tsx @@ -0,0 +1,70 @@ +import React, { useContext } from 'react'; +import type { SeasonStatusState } from '../../../shared/types/rewards'; +import { useCandidateSubscriptionId } from '../../hooks/rewards/useCandidateSubscriptionId'; +import { useSeasonStatus } from '../../hooks/rewards/useSeasonStatus'; +import { useRewardsEnabled } from '../../hooks/rewards/useRewardsEnabled'; + +export type RewardsContextValue = { + rewardsEnabled: boolean; + candidateSubscriptionId: string | null; + candidateSubscriptionIdError: boolean; + seasonStatus: SeasonStatusState | null; + seasonStatusError: string | null; + seasonStatusLoading: boolean; + refetchSeasonStatus: () => Promise; +}; + +export const RewardsContext = React.createContext({ + rewardsEnabled: false, + candidateSubscriptionId: null, + candidateSubscriptionIdError: false, + seasonStatus: null, + seasonStatusError: null, + seasonStatusLoading: false, + refetchSeasonStatus: async () => { + // Default empty function + }, +}); + +export const useRewardsContext = () => { + const context = useContext(RewardsContext); + if (!context) { + throw new Error('useRewardsContext must be used within a RewardsProvider'); + } + return context; +}; + +export const RewardsProvider: React.FC = ({ children }) => { + const rewardsEnabled = useRewardsEnabled(); + const { + candidateSubscriptionId, + candidateSubscriptionIdError, + fetchCandidateSubscriptionId, + } = useCandidateSubscriptionId(); + + const { + seasonStatus, + seasonStatusError, + seasonStatusLoading, + fetchSeasonStatus, + } = useSeasonStatus({ + subscriptionId: candidateSubscriptionId, + onAuthorizationError: fetchCandidateSubscriptionId, + }); + + return ( + + {children} + + ); +}; diff --git a/ui/hooks/rewards/index.ts b/ui/hooks/rewards/index.ts new file mode 100644 index 000000000000..a7012d668043 --- /dev/null +++ b/ui/hooks/rewards/index.ts @@ -0,0 +1,4 @@ +export { useRewardsContext } from '../../contexts/rewards'; +export { useCandidateSubscriptionId } from './useCandidateSubscriptionId'; +export { useSeasonStatus } from './useSeasonStatus'; +export { useRewardsEnabled } from './useRewardsEnabled'; diff --git a/ui/hooks/rewards/useCandidateSubscriptionId.test.ts b/ui/hooks/rewards/useCandidateSubscriptionId.test.ts new file mode 100644 index 000000000000..bb6e7dfc3db4 --- /dev/null +++ b/ui/hooks/rewards/useCandidateSubscriptionId.test.ts @@ -0,0 +1,535 @@ +import { act } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react'; +import log from 'loglevel'; +import { submitRequestToBackground } from '../../store/background-connection'; +import { renderHookWithProvider } from '../../../test/lib/render-helpers'; +import mockState from '../../../test/data/mock-state.json'; +import { useCandidateSubscriptionId } from './useCandidateSubscriptionId'; +import { useRewardsEnabled } from './useRewardsEnabled'; + +// Mock dependencies +jest.mock('../../store/background-connection', () => ({ + submitRequestToBackground: jest.fn(), +})); + +jest.mock('./useRewardsEnabled', () => ({ + useRewardsEnabled: jest.fn(), +})); + +jest.mock('loglevel', () => ({ + error: jest.fn(), + setLevel: jest.fn(), +})); + +// Mock console.log to avoid noise in tests +const originalConsoleLog = console.log; +beforeAll(() => { + console.log = jest.fn(); +}); + +afterAll(() => { + console.log = originalConsoleLog; +}); + +const mockSubmitRequestToBackground = + submitRequestToBackground as jest.MockedFunction< + typeof submitRequestToBackground + >; +const mockUseRewardsEnabled = useRewardsEnabled as jest.MockedFunction< + typeof useRewardsEnabled +>; +const mockLogError = log.error as jest.MockedFunction; + +describe('useCandidateSubscriptionId', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseRewardsEnabled.mockReturnValue(true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('Initial State', () => { + it('should return initial values', () => { + const { result } = renderHookWithProvider( + () => useCandidateSubscriptionId(), + mockState, + ); + + expect(result.current.candidateSubscriptionId).toBeNull(); + expect(result.current.candidateSubscriptionIdError).toBe(false); + expect(typeof result.current.fetchCandidateSubscriptionId).toBe( + 'function', + ); + }); + }); + + describe('Conditional Fetching', () => { + it('should not fetch when rewards are disabled', () => { + mockUseRewardsEnabled.mockReturnValue(false); + + const { result } = renderHookWithProvider( + () => useCandidateSubscriptionId(), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'eip155:1:0x123', + subscriptionId: 'sub-123', + }, + rewardsSubscriptions: {}, + }, + }, + ); + + expect(result.current.candidateSubscriptionId).toBeNull(); + expect(result.current.candidateSubscriptionIdError).toBe(false); + expect(mockSubmitRequestToBackground).not.toHaveBeenCalled(); + }); + + it('should not fetch when wallet is locked', () => { + mockUseRewardsEnabled.mockReturnValue(true); + + const testState = { + ...mockState, + metamask: { + ...mockState.metamask, + isUnlocked: false, + rewardsActiveAccount: { + account: 'eip155:1:0x123', + subscriptionId: 'sub-123', + }, + rewardsSubscriptions: {}, + }, + }; + + const { result } = renderHookWithProvider( + () => useCandidateSubscriptionId(), + testState, + ); + + expect(result.current.candidateSubscriptionId).toBeNull(); + expect(result.current.candidateSubscriptionIdError).toBe(false); + expect(mockSubmitRequestToBackground).not.toHaveBeenCalled(); + }); + + it('should not fetch when rewardsActiveAccountCaipAccountId is missing', () => { + mockUseRewardsEnabled.mockReturnValue(true); + + const { result } = renderHookWithProvider( + () => useCandidateSubscriptionId(), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: null, + rewardsSubscriptions: {}, + }, + }, + ); + + expect(result.current.candidateSubscriptionId).toBeNull(); + expect(result.current.candidateSubscriptionIdError).toBe(false); + expect(mockSubmitRequestToBackground).not.toHaveBeenCalled(); + }); + + it('should fetch when subscription IDs do not match', async () => { + mockUseRewardsEnabled.mockReturnValue(true); + mockSubmitRequestToBackground.mockResolvedValue('new-sub-id'); + + const { result } = renderHookWithProvider( + () => useCandidateSubscriptionId(), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'eip155:1:0x123', + subscriptionId: 'different-sub-id', + }, + rewardsSubscriptions: {}, + }, + }, + ); + + await waitFor(() => { + expect(mockSubmitRequestToBackground).toHaveBeenCalledWith( + 'getCandidateSubscriptionId', + [], + ); + }); + + await waitFor(() => { + expect(result.current.candidateSubscriptionId).toBe('new-sub-id'); + }); + }); + }); + + describe('fetchCandidateSubscriptionId Function', () => { + it('should fetch successfully and update state', async () => { + const mockId = 'test-subscription-id'; + mockSubmitRequestToBackground.mockResolvedValue(mockId); + + const { result } = renderHookWithProvider( + () => useCandidateSubscriptionId(), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'eip155:1:0x123', + subscriptionId: 'sub-123', + }, + rewardsSubscriptions: {}, + }, + }, + ); + + await act(async () => { + await result.current.fetchCandidateSubscriptionId(); + }); + + expect(mockSubmitRequestToBackground).toHaveBeenCalledWith( + 'getCandidateSubscriptionId', + [], + ); + expect(result.current.candidateSubscriptionId).toBe(mockId); + expect(result.current.candidateSubscriptionIdError).toBe(false); + }); + + it('should handle errors and update error state', async () => { + const mockError = new Error('API Error'); + mockSubmitRequestToBackground.mockRejectedValue(mockError); + + const { result } = renderHookWithProvider( + () => useCandidateSubscriptionId(), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'eip155:1:0x123', + subscriptionId: 'sub-123', + }, + rewardsSubscriptions: {}, + }, + }, + ); + + await act(async () => { + await result.current.fetchCandidateSubscriptionId(); + }); + + expect(mockSubmitRequestToBackground).toHaveBeenCalledWith( + 'getCandidateSubscriptionId', + [], + ); + expect(result.current.candidateSubscriptionId).toBeNull(); + expect(result.current.candidateSubscriptionIdError).toBe(true); + expect(mockLogError).toHaveBeenCalledWith( + '[useCandidateSubscriptionId] Error fetching candidate subscription ID:', + mockError, + ); + }); + + it('should not fetch when rewards are disabled in fetchCandidateSubscriptionId', async () => { + mockUseRewardsEnabled.mockReturnValue(false); + + const { result } = renderHookWithProvider( + () => useCandidateSubscriptionId(), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'eip155:1:0x123', + subscriptionId: 'sub-123', + }, + rewardsSubscriptions: {}, + }, + }, + ); + + await act(async () => { + await result.current.fetchCandidateSubscriptionId(); + }); + + expect(mockSubmitRequestToBackground).not.toHaveBeenCalled(); + expect(result.current.candidateSubscriptionId).toBeNull(); + expect(result.current.candidateSubscriptionIdError).toBe(false); + }); + }); + + describe('useEffect Behavior', () => { + it('should fetch when wallet becomes unlocked', async () => { + mockUseRewardsEnabled.mockReturnValue(true); + mockSubmitRequestToBackground.mockResolvedValue('test-id-123'); + + // Test with locked wallet first + const { result: lockedResult } = renderHookWithProvider( + () => useCandidateSubscriptionId(), + { + metamask: { + isUnlocked: false, + rewardsActiveAccount: { + account: 'eip155:1:0x123', + subscriptionId: 'sub-123', + }, + rewardsSubscriptions: {}, + }, + }, + ); + + // Initially should not fetch + expect(mockSubmitRequestToBackground).not.toHaveBeenCalled(); + expect(lockedResult.current.candidateSubscriptionId).toBeNull(); + + // Test with unlocked wallet + const { result: unlockedResult } = renderHookWithProvider( + () => useCandidateSubscriptionId(), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'eip155:1:0x123', + subscriptionId: 'sub-123', + }, + rewardsSubscriptions: {}, + }, + }, + ); + + await waitFor(() => { + expect(mockSubmitRequestToBackground).toHaveBeenCalledWith( + 'getCandidateSubscriptionId', + [], + ); + }); + + await waitFor(() => { + expect(unlockedResult.current.candidateSubscriptionId).toBe( + 'test-id-123', + ); + }); + }); + + it('should fetch when rewards become enabled', async () => { + mockSubmitRequestToBackground.mockResolvedValue('test-id-456'); + + // Test with rewards disabled first + const mockUseRewardsEnabledLocal = useRewardsEnabled as jest.Mock; + mockUseRewardsEnabledLocal.mockReturnValue(false); + + const { result: disabledResult } = renderHookWithProvider( + () => useCandidateSubscriptionId(), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'eip155:1:0x123', + subscriptionId: 'sub-123', + }, + rewardsSubscriptions: {}, + }, + }, + ); + + // Initially should not fetch + expect(mockSubmitRequestToBackground).not.toHaveBeenCalled(); + expect(disabledResult.current.candidateSubscriptionId).toBeNull(); + + // Test with rewards enabled + mockUseRewardsEnabledLocal.mockReturnValue(true); + + const { result: enabledResult } = renderHookWithProvider( + () => useCandidateSubscriptionId(), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'eip155:1:0x123', + subscriptionId: 'sub-123', + }, + rewardsSubscriptions: {}, + }, + }, + ); + + await waitFor(() => { + expect(mockSubmitRequestToBackground).toHaveBeenCalledWith( + 'getCandidateSubscriptionId', + [], + ); + }); + + await waitFor(() => { + expect(enabledResult.current.candidateSubscriptionId).toBe( + 'test-id-456', + ); + }); + }); + + it('should fetch when rewardsActiveAccountCaipAccountId becomes available', async () => { + mockUseRewardsEnabled.mockReturnValue(true); + mockSubmitRequestToBackground.mockResolvedValue('test-id-789'); + + // Test without rewardsActiveAccount + const { result: noAccountResult } = renderHookWithProvider( + () => useCandidateSubscriptionId(), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: null, + rewardsSubscriptions: {}, + }, + }, + ); + + // Initially should not fetch + expect(mockSubmitRequestToBackground).not.toHaveBeenCalled(); + expect(noAccountResult.current.candidateSubscriptionId).toBeNull(); + + // Test with rewardsActiveAccount + const { result: withAccountResult } = renderHookWithProvider( + () => useCandidateSubscriptionId(), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'eip155:1:0x123', + subscriptionId: 'sub-123', + }, + rewardsSubscriptions: {}, + }, + }, + ); + + await waitFor(() => { + expect(mockSubmitRequestToBackground).toHaveBeenCalledWith( + 'getCandidateSubscriptionId', + [], + ); + }); + + await waitFor(() => { + expect(withAccountResult.current.candidateSubscriptionId).toBe( + 'test-id-789', + ); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle null response from background', async () => { + mockSubmitRequestToBackground.mockResolvedValue(null); + + const { result } = renderHookWithProvider( + () => useCandidateSubscriptionId(), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'eip155:1:0x123', + subscriptionId: 'sub-123', + }, + rewardsSubscriptions: {}, + }, + }, + ); + + await act(async () => { + await result.current.fetchCandidateSubscriptionId(); + }); + + expect(result.current.candidateSubscriptionId).toBeNull(); + expect(result.current.candidateSubscriptionIdError).toBe(false); + }); + + it('should handle undefined response from background', async () => { + mockSubmitRequestToBackground.mockResolvedValue(undefined); + + const { result } = renderHookWithProvider( + () => useCandidateSubscriptionId(), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'eip155:1:0x123', + subscriptionId: 'sub-123', + }, + rewardsSubscriptions: {}, + }, + }, + ); + + await act(async () => { + await result.current.fetchCandidateSubscriptionId(); + }); + + expect(result.current.candidateSubscriptionId).toBeUndefined(); + expect(result.current.candidateSubscriptionIdError).toBe(false); + }); + + it('should handle empty string response from background', async () => { + mockSubmitRequestToBackground.mockResolvedValue(''); + + const { result } = renderHookWithProvider( + () => useCandidateSubscriptionId(), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'eip155:1:0x123', + subscriptionId: 'sub-123', + }, + rewardsSubscriptions: {}, + }, + }, + ); + + await act(async () => { + await result.current.fetchCandidateSubscriptionId(); + }); + + expect(result.current.candidateSubscriptionId).toBe(''); + expect(result.current.candidateSubscriptionIdError).toBe(false); + }); + + it('should maintain function reference stability for fetchCandidateSubscriptionId', () => { + const initialState = { + ...mockState, + metamask: { + ...mockState.metamask, + isUnlocked: false, + rewardsActiveAccount: { + account: 'eip155:1:0x123', + subscriptionId: 'sub-123', + }, + rewardsSubscriptions: {}, + }, + }; + + const updatedState = { + ...mockState, + metamask: { + ...mockState.metamask, + isUnlocked: true, + rewardsActiveAccount: { + account: 'eip155:1:0x123', + subscriptionId: 'sub-123', + }, + rewardsSubscriptions: {}, + }, + }; + + const { result, rerender } = renderHookWithProvider( + () => useCandidateSubscriptionId(), + initialState, + ); + + const firstFetchFunction = result.current.fetchCandidateSubscriptionId; + + rerender({ children: updatedState }); + + const secondFetchFunction = result.current.fetchCandidateSubscriptionId; + + expect(firstFetchFunction).toBe(secondFetchFunction); + }); + }); +}); diff --git a/ui/hooks/rewards/useCandidateSubscriptionId.ts b/ui/hooks/rewards/useCandidateSubscriptionId.ts new file mode 100644 index 000000000000..5825162641f0 --- /dev/null +++ b/ui/hooks/rewards/useCandidateSubscriptionId.ts @@ -0,0 +1,81 @@ +import { useState, useCallback, useEffect } from 'react'; +import log from 'loglevel'; +import { useSelector } from 'react-redux'; +import { submitRequestToBackground } from '../../store/background-connection'; +import { getIsUnlocked } from '../../ducks/metamask/metamask'; +import { useAppSelector } from '../../store/store'; +import { useRewardsEnabled } from './useRewardsEnabled'; + +type UseCandidateSubscriptionIdReturn = { + candidateSubscriptionId: string | null; + candidateSubscriptionIdError: boolean; + fetchCandidateSubscriptionId: () => Promise; +}; + +/** + * Hook to fetch and manage candidate subscription ID + */ +export const useCandidateSubscriptionId = + (): UseCandidateSubscriptionIdReturn => { + const [candidateSubscriptionId, setCandidateSubscriptionId] = useState< + string | null + >(null); + const [candidateSubscriptionIdError, setCandidateSubscriptionIdError] = + useState(false); + const isUnlocked = useSelector(getIsUnlocked); + const isRewardsEnabled = useRewardsEnabled(); + const rewardsActiveAccountSubscriptionId = useAppSelector( + (state) => state.metamask.rewardsActiveAccount?.subscriptionId, + ); + const rewardsActiveAccountCaipAccountId = useAppSelector( + (state) => state.metamask.rewardsActiveAccount?.account, + ); + const rewardsSubscriptions = useAppSelector( + (state) => state.metamask.rewardsSubscriptions, + ); + + const fetchCandidateSubscriptionId = useCallback(async () => { + try { + if (!isRewardsEnabled) { + setCandidateSubscriptionId(null); + setCandidateSubscriptionIdError(false); + return; + } + const candidateId = await submitRequestToBackground( + 'getCandidateSubscriptionId', + [], + ); + setCandidateSubscriptionId(candidateId); + setCandidateSubscriptionIdError(false); + } catch (error) { + log.error( + '[useCandidateSubscriptionId] Error fetching candidate subscription ID:', + error, + ); + setCandidateSubscriptionIdError(true); + } + }, [isRewardsEnabled]); + + useEffect(() => { + if ( + isUnlocked && + rewardsActiveAccountCaipAccountId && + (!candidateSubscriptionId || + rewardsActiveAccountSubscriptionId !== candidateSubscriptionId) + ) { + fetchCandidateSubscriptionId(); + } + }, [ + isUnlocked, + fetchCandidateSubscriptionId, + rewardsActiveAccountCaipAccountId, + rewardsActiveAccountSubscriptionId, + candidateSubscriptionId, + rewardsSubscriptions, + ]); + return { + candidateSubscriptionId, + candidateSubscriptionIdError, + fetchCandidateSubscriptionId, + }; + }; diff --git a/ui/hooks/rewards/useRewardsEnabled.test.ts b/ui/hooks/rewards/useRewardsEnabled.test.ts new file mode 100644 index 000000000000..36802bba9cfb --- /dev/null +++ b/ui/hooks/rewards/useRewardsEnabled.test.ts @@ -0,0 +1,320 @@ +import { renderHookWithProvider } from '../../../test/lib/render-helpers'; +import mockState from '../../../test/data/mock-state.json'; +import { useRewardsEnabled } from './useRewardsEnabled'; + +describe('useRewardsEnabled', () => { + describe('with boolean feature flags', () => { + it('should return true when both useExternalServices and rewardsEnabled feature flag are true', () => { + const state = { + ...mockState, + metamask: { + ...mockState.metamask, + useExternalServices: true, + remoteFeatureFlags: { + rewardsEnabled: true, + }, + }, + }; + + const { result } = renderHookWithProvider( + () => useRewardsEnabled(), + state, + ); + + expect(result.current).toBe(true); + }); + + it('should return false when useExternalServices is false', () => { + const state = { + ...mockState, + metamask: { + ...mockState.metamask, + useExternalServices: false, + remoteFeatureFlags: { + rewardsEnabled: true, + }, + }, + }; + + const { result } = renderHookWithProvider( + () => useRewardsEnabled(), + state, + ); + + expect(result.current).toBe(false); + }); + + it('should return false when rewardsEnabled feature flag is false', () => { + const state = { + ...mockState, + metamask: { + ...mockState.metamask, + useExternalServices: true, + remoteFeatureFlags: { + rewardsEnabled: false, + }, + }, + }; + + const { result } = renderHookWithProvider( + () => useRewardsEnabled(), + state, + ); + + expect(result.current).toBe(false); + }); + + it('should return false when both useExternalServices and rewardsEnabled feature flag are false', () => { + const state = { + ...mockState, + metamask: { + ...mockState.metamask, + useExternalServices: false, + remoteFeatureFlags: { + rewardsEnabled: false, + }, + }, + }; + + const { result } = renderHookWithProvider( + () => useRewardsEnabled(), + state, + ); + + expect(result.current).toBe(false); + }); + + it('should return false when useExternalServices is undefined', () => { + const state = { + ...mockState, + metamask: { + ...mockState.metamask, + // useExternalServices is undefined + remoteFeatureFlags: { + rewardsEnabled: true, + }, + }, + }; + + const { result } = renderHookWithProvider( + () => useRewardsEnabled(), + state, + ); + + expect(result.current).toBe(false); + }); + + it('should return false when rewardsEnabled feature flag is undefined', () => { + const state = { + ...mockState, + metamask: { + ...mockState.metamask, + useExternalServices: true, + remoteFeatureFlags: { + // rewardsEnabled is undefined + }, + }, + }; + + const { result } = renderHookWithProvider( + () => useRewardsEnabled(), + state, + ); + + expect(result.current).toBe(false); + }); + }); + + describe('with VersionGatedFeatureFlag', () => { + it('should return true when useExternalServices is true and VersionGatedFeatureFlag is enabled with valid version', () => { + const state = { + ...mockState, + metamask: { + ...mockState.metamask, + useExternalServices: true, + remoteFeatureFlags: { + rewardsEnabled: { + enabled: true, + minimumVersion: '12.0.0', + }, + }, + }, + }; + + const { result } = renderHookWithProvider( + () => useRewardsEnabled(), + state, + ); + + expect(result.current).toBe(true); + }); + + it('should return false when VersionGatedFeatureFlag is disabled', () => { + const state = { + ...mockState, + metamask: { + ...mockState.metamask, + useExternalServices: true, + remoteFeatureFlags: { + rewardsEnabled: { + enabled: false, + minimumVersion: '12.0.0', + }, + }, + }, + }; + + const { result } = renderHookWithProvider( + () => useRewardsEnabled(), + state, + ); + + expect(result.current).toBe(false); + }); + + it('should return false when VersionGatedFeatureFlag has invalid structure', () => { + const state = { + ...mockState, + metamask: { + ...mockState.metamask, + useExternalServices: true, + remoteFeatureFlags: { + rewardsEnabled: { + enabled: true, + minimumVersion: null, // Invalid structure + }, + }, + }, + }; + + const { result } = renderHookWithProvider( + () => useRewardsEnabled(), + state, + ); + + expect(result.current).toBe(false); + }); + + it('should return false when useExternalServices is false even with valid VersionGatedFeatureFlag', () => { + const state = { + ...mockState, + metamask: { + ...mockState.metamask, + useExternalServices: false, + remoteFeatureFlags: { + rewardsEnabled: { + enabled: true, + minimumVersion: '12.0.0', + }, + }, + }, + }; + + const { result } = renderHookWithProvider( + () => useRewardsEnabled(), + state, + ); + + expect(result.current).toBe(false); + }); + }); + + describe('memoization behavior', () => { + it('should return consistent results for the same inputs', () => { + const state = { + ...mockState, + metamask: { + ...mockState.metamask, + useExternalServices: true, + remoteFeatureFlags: { + rewardsEnabled: true, + }, + }, + }; + + const { result, rerender } = renderHookWithProvider( + () => useRewardsEnabled(), + state, + ); + const firstResult = result.current; + + rerender(); + const secondResult = result.current; + + expect(firstResult).toBe(true); + expect(secondResult).toBe(true); + }); + + it('should return different results for different useExternalServices values', () => { + const stateWithExternalServicesTrue = { + ...mockState, + metamask: { + ...mockState.metamask, + useExternalServices: true, + remoteFeatureFlags: { + rewardsEnabled: true, + }, + }, + }; + + const stateWithExternalServicesFalse = { + ...mockState, + metamask: { + ...mockState.metamask, + useExternalServices: false, + remoteFeatureFlags: { + rewardsEnabled: true, + }, + }, + }; + + const { result: resultTrue } = renderHookWithProvider( + () => useRewardsEnabled(), + stateWithExternalServicesTrue, + ); + const { result: resultFalse } = renderHookWithProvider( + () => useRewardsEnabled(), + stateWithExternalServicesFalse, + ); + + expect(resultTrue.current).toBe(true); + expect(resultFalse.current).toBe(false); + }); + + it('should return different results for different rewardsEnabled feature flag values', () => { + const stateWithRewardsTrue = { + ...mockState, + metamask: { + ...mockState.metamask, + useExternalServices: true, + remoteFeatureFlags: { + rewardsEnabled: true, + }, + }, + }; + + const stateWithRewardsFalse = { + ...mockState, + metamask: { + ...mockState.metamask, + useExternalServices: true, + remoteFeatureFlags: { + rewardsEnabled: false, + }, + }, + }; + + const { result: resultTrue } = renderHookWithProvider( + () => useRewardsEnabled(), + stateWithRewardsTrue, + ); + const { result: resultFalse } = renderHookWithProvider( + () => useRewardsEnabled(), + stateWithRewardsFalse, + ); + + expect(resultTrue.current).toBe(true); + expect(resultFalse.current).toBe(false); + }); + }); +}); diff --git a/ui/hooks/rewards/useRewardsEnabled.ts b/ui/hooks/rewards/useRewardsEnabled.ts new file mode 100644 index 000000000000..7560a8a1c6a9 --- /dev/null +++ b/ui/hooks/rewards/useRewardsEnabled.ts @@ -0,0 +1,43 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { getUseExternalServices } from '../../selectors'; +import { getRemoteFeatureFlags } from '../../selectors/remote-feature-flags'; +import { + validatedVersionGatedFeatureFlag, + VersionGatedFeatureFlag, +} from '../../../shared/lib/feature-flags/version-gating'; + +/** + * Custom hook to check if rewards feature is enabled. + * Follows the same logic as the RewardsController's isDisabled function. + * + * @returns boolean - True if rewards feature is enabled, false otherwise + */ +export const useRewardsEnabled = (): boolean => { + const remoteFeatureFlags = useSelector(getRemoteFeatureFlags); + const useExternalServices = useSelector(getUseExternalServices); + + const isRewardsEnabled = useMemo(() => { + const rewardsFeatureFlag = remoteFeatureFlags?.rewardsEnabled as + | VersionGatedFeatureFlag + | boolean + | undefined; + + // Resolve the feature flag (can be boolean or VersionGatedFeatureFlag) + const resolveFlag = (flag: unknown): boolean => { + if (typeof flag === 'boolean') { + return flag; + } + return Boolean( + validatedVersionGatedFeatureFlag(flag as VersionGatedFeatureFlag), + ); + }; + + const featureFlagEnabled = resolveFlag(rewardsFeatureFlag); + + // Rewards are enabled when BOTH feature flag is enabled AND useExternalServices is true + return featureFlagEnabled && Boolean(useExternalServices); + }, [remoteFeatureFlags, useExternalServices]); + + return isRewardsEnabled; +}; diff --git a/ui/hooks/rewards/useSeasonStatus.test.ts b/ui/hooks/rewards/useSeasonStatus.test.ts new file mode 100644 index 000000000000..980a90e286ac --- /dev/null +++ b/ui/hooks/rewards/useSeasonStatus.test.ts @@ -0,0 +1,612 @@ +import { act } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react'; +import log from 'loglevel'; +import { submitRequestToBackground } from '../../store/background-connection'; +import { renderHookWithProvider } from '../../../test/lib/render-helpers'; +import { SeasonStatusState } from '../../../shared/types/rewards'; +import { useSeasonStatus } from './useSeasonStatus'; +import { useRewardsEnabled } from './useRewardsEnabled'; + +// Mock dependencies +jest.mock('../../store/background-connection', () => ({ + submitRequestToBackground: jest.fn(), +})); + +jest.mock('./useRewardsEnabled', () => ({ + useRewardsEnabled: jest.fn(), +})); + +jest.mock('loglevel', () => ({ + error: jest.fn(), + setLevel: jest.fn(), +})); + +// Suppress console.log during tests +const originalConsoleLog = console.log; +beforeAll(() => { + console.log = jest.fn(); +}); + +afterAll(() => { + console.log = originalConsoleLog; +}); + +const mockSubmitRequestToBackground = + submitRequestToBackground as jest.MockedFunction< + typeof submitRequestToBackground + >; +const mockUseRewardsEnabled = useRewardsEnabled as jest.MockedFunction< + typeof useRewardsEnabled +>; +const mockLogError = log.error as jest.MockedFunction; + +// Mock authorization error callback +const mockOnAuthorizationError = jest.fn().mockResolvedValue(undefined); + +describe('useSeasonStatus', () => { + const mockSeasonStatus: SeasonStatusState = { + season: { + id: 'season-1', + name: 'Season 1', + startDate: 1640995200000, // 2022-01-01 + endDate: 1672531200000, // 2023-01-01 + tiers: [ + { + id: 'tier-1', + name: 'Bronze', + pointsNeeded: 0, + image: { + lightModeUrl: 'https://example.com/bronze-light.png', + darkModeUrl: 'https://example.com/bronze-dark.png', + }, + levelNumber: '1', + rewards: [], + }, + { + id: 'tier-2', + name: 'Silver', + pointsNeeded: 100, + image: { + lightModeUrl: 'https://example.com/silver-light.png', + darkModeUrl: 'https://example.com/silver-dark.png', + }, + levelNumber: '2', + rewards: [], + }, + ], + lastFetched: Date.now(), + }, + balance: { + total: 50, + updatedAt: Date.now(), + }, + tier: { + currentTier: { + id: 'tier-1', + name: 'Bronze', + pointsNeeded: 0, + image: { + lightModeUrl: 'https://example.com/bronze-light.png', + darkModeUrl: 'https://example.com/bronze-dark.png', + }, + levelNumber: '1', + rewards: [], + }, + nextTier: { + id: 'tier-2', + name: 'Silver', + pointsNeeded: 100, + image: { + lightModeUrl: 'https://example.com/silver-light.png', + darkModeUrl: 'https://example.com/silver-dark.png', + }, + levelNumber: '2', + rewards: [], + }, + nextTierPointsNeeded: 50, + }, + lastFetched: Date.now(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseRewardsEnabled.mockReturnValue(false); // Start with rewards disabled to prevent auto-fetch + mockSubmitRequestToBackground.mockResolvedValue(mockSeasonStatus); + mockOnAuthorizationError.mockClear(); + }); + + describe('when rewards are enabled and user is unlocked', () => { + it('should fetch season status with subscriptionId', async () => { + mockUseRewardsEnabled.mockReturnValue(true); // Enable rewards for this test + + const { result } = renderHookWithProvider( + () => + useSeasonStatus({ + subscriptionId: 'test-subscription-id', + onAuthorizationError: mockOnAuthorizationError, + }), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'test-account-id', + }, + }, + }, + ); + + await waitFor(() => { + expect(result.current.seasonStatus).toEqual(mockSeasonStatus); + }); + + expect(mockSubmitRequestToBackground).toHaveBeenCalledWith( + 'getRewardsSeasonMetadata', + ['current'], + ); + expect(mockSubmitRequestToBackground).toHaveBeenCalledWith( + 'getRewardsSeasonStatus', + ['test-subscription-id', undefined], + ); + expect(result.current.seasonStatusError).toBeNull(); + expect(result.current.seasonStatusLoading).toBe(false); + }); + + it('should not fetch season status without subscriptionId', () => { + const { result } = renderHookWithProvider( + () => + useSeasonStatus({ + subscriptionId: null, + onAuthorizationError: mockOnAuthorizationError, + }), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'test-account-id', + }, + }, + }, + ); + + expect(result.current.seasonStatus).toBeNull(); + expect(result.current.seasonStatusError).toBeNull(); + expect(result.current.seasonStatusLoading).toBe(false); + expect(mockSubmitRequestToBackground).not.toHaveBeenCalled(); + }); + }); + + describe('when rewards are disabled', () => { + it('should not fetch season status', () => { + mockUseRewardsEnabled.mockReturnValue(false); + + const { result } = renderHookWithProvider( + () => + useSeasonStatus({ + subscriptionId: 'test-subscription-id', + onAuthorizationError: mockOnAuthorizationError, + }), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'test-account-id', + }, + }, + }, + ); + + expect(result.current.seasonStatus).toBeNull(); + expect(result.current.seasonStatusError).toBeNull(); + expect(result.current.seasonStatusLoading).toBe(false); + expect(mockSubmitRequestToBackground).not.toHaveBeenCalled(); + }); + }); + + describe('when user is locked', () => { + it('should not fetch season status', () => { + const { result } = renderHookWithProvider( + () => + useSeasonStatus({ + subscriptionId: 'test-subscription-id', + onAuthorizationError: mockOnAuthorizationError, + }), + { + metamask: { + isUnlocked: false, + rewardsActiveAccount: { + account: 'test-account-id', + }, + }, + }, + ); + + expect(result.current.seasonStatus).toBeNull(); + expect(result.current.seasonStatusError).toBeNull(); + expect(result.current.seasonStatusLoading).toBe(false); + expect(mockSubmitRequestToBackground).not.toHaveBeenCalled(); + }); + }); + + describe('when activeRewardsCaipAccountId is missing', () => { + it('should not fetch season status when activeRewardsCaipAccountId is null', () => { + mockUseRewardsEnabled.mockReturnValue(true); + + const { result } = renderHookWithProvider( + () => + useSeasonStatus({ + subscriptionId: 'test-subscription-id', + onAuthorizationError: mockOnAuthorizationError, + }), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: null, + }, + }, + ); + + expect(result.current.seasonStatus).toBeNull(); + expect(result.current.seasonStatusError).toBeNull(); + expect(result.current.seasonStatusLoading).toBe(false); + expect(mockSubmitRequestToBackground).not.toHaveBeenCalled(); + }); + + it('should not fetch season status when activeRewardsCaipAccountId is undefined', () => { + mockUseRewardsEnabled.mockReturnValue(true); + + const { result } = renderHookWithProvider( + () => + useSeasonStatus({ + subscriptionId: 'test-subscription-id', + onAuthorizationError: mockOnAuthorizationError, + }), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: undefined, + }, + }, + ); + + expect(result.current.seasonStatus).toBeNull(); + expect(result.current.seasonStatusError).toBeNull(); + expect(result.current.seasonStatusLoading).toBe(false); + expect(mockSubmitRequestToBackground).not.toHaveBeenCalled(); + }); + + it('should not fetch season status when rewardsActiveAccount.account is null', () => { + mockUseRewardsEnabled.mockReturnValue(true); + + const { result } = renderHookWithProvider( + () => + useSeasonStatus({ + subscriptionId: 'test-subscription-id', + onAuthorizationError: mockOnAuthorizationError, + }), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: null, + }, + }, + }, + ); + + expect(result.current.seasonStatus).toBeNull(); + expect(result.current.seasonStatusError).toBeNull(); + expect(result.current.seasonStatusLoading).toBe(false); + expect(mockSubmitRequestToBackground).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('should handle fetch errors', async () => { + mockUseRewardsEnabled.mockReturnValue(true); // Enable rewards for this test + const mockError = new Error('Failed to fetch season status'); + mockSubmitRequestToBackground.mockRejectedValue(mockError); + + const { result } = renderHookWithProvider( + () => + useSeasonStatus({ + subscriptionId: 'test-subscription-id', + onAuthorizationError: mockOnAuthorizationError, + }), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'test-account-id', + }, + }, + }, + ); + + await waitFor(() => { + expect(result.current.seasonStatusLoading).toBe(false); + }); + + expect(result.current.seasonStatus).toBeNull(); + expect(result.current.seasonStatusError).toBe( + 'Failed to fetch season status', + ); + expect(mockLogError).toHaveBeenCalledWith( + '[useSeasonStatus] Error fetching season status:', + mockError, + ); + }); + + it('should call onAuthorizationError when authorization fails', async () => { + mockUseRewardsEnabled.mockReturnValue(true); // Enable rewards for this test + const mockAuthError = new Error('Authorization failed'); + mockAuthError.name = 'AuthorizationFailedError'; + mockSubmitRequestToBackground.mockRejectedValue(mockAuthError); + + const localMockOnAuthorizationError = jest + .fn() + .mockResolvedValue(undefined); + + const { result } = renderHookWithProvider( + () => + useSeasonStatus({ + subscriptionId: 'test-subscription-id', + onAuthorizationError: localMockOnAuthorizationError, + }), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'test-account-id', + }, + }, + }, + ); + + await waitFor(() => { + expect(result.current.seasonStatusLoading).toBe(false); + }); + + expect(localMockOnAuthorizationError).toHaveBeenCalled(); + expect(result.current.seasonStatus).toBeNull(); + expect(result.current.seasonStatusError).toBe('Authorization failed'); + }); + + it('should not call onAuthorizationError for non-authorization errors', async () => { + mockUseRewardsEnabled.mockReturnValue(true); // Enable rewards for this test + const mockError = new Error('Network error'); + mockSubmitRequestToBackground.mockRejectedValue(mockError); + + const localMockOnAuthorizationError = jest.fn(); + + const { result } = renderHookWithProvider( + () => + useSeasonStatus({ + subscriptionId: 'test-subscription-id', + onAuthorizationError: localMockOnAuthorizationError, + }), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'test-account-id', + }, + }, + }, + ); + + await waitFor(() => { + expect(result.current.seasonStatusLoading).toBe(false); + }); + + expect(localMockOnAuthorizationError).not.toHaveBeenCalled(); + expect(result.current.seasonStatusError).toBe('Network error'); + }); + }); + + describe('loading states', () => { + it('should show loading state during fetch', async () => { + mockUseRewardsEnabled.mockReturnValue(true); // Enable rewards for this test + let resolvePromise!: (value: SeasonStatusState) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + mockSubmitRequestToBackground.mockReturnValue(promise); + + const { result } = renderHookWithProvider( + () => + useSeasonStatus({ + subscriptionId: 'test-subscription-id', + onAuthorizationError: mockOnAuthorizationError, + }), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'test-account-id', + }, + }, + }, + ); + + expect(result.current.seasonStatusLoading).toBe(true); + expect(result.current.seasonStatus).toBeNull(); + expect(result.current.seasonStatusError).toBeNull(); + + // Resolve the promise + resolvePromise(mockSeasonStatus); + + await waitFor(() => { + expect(result.current.seasonStatusLoading).toBe(false); + }); + + expect(result.current.seasonStatus).toEqual(mockSeasonStatus); + }); + }); + + describe('fetchSeasonStatus function', () => { + it('should expose fetchSeasonStatus function', () => { + const { result } = renderHookWithProvider( + () => + useSeasonStatus({ + subscriptionId: 'test-subscription-id', + onAuthorizationError: mockOnAuthorizationError, + }), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'test-account-id', + }, + }, + }, + ); + + expect(typeof result.current.fetchSeasonStatus).toBe('function'); + }); + + it('should allow manual refetch via fetchSeasonStatus', async () => { + mockUseRewardsEnabled.mockReturnValue(true); // Enable rewards for this test + + const { result } = renderHookWithProvider( + () => + useSeasonStatus({ + subscriptionId: 'test-subscription-id', + onAuthorizationError: mockOnAuthorizationError, + }), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'test-account-id', + }, + }, + }, + ); + + // Wait for initial fetch + await waitFor(() => { + expect(result.current.seasonStatusLoading).toBe(false); + }); + + // Clear previous calls + mockSubmitRequestToBackground.mockClear(); + + // Manual refetch + await act(async () => { + await result.current.fetchSeasonStatus(); + }); + + expect(mockSubmitRequestToBackground).toHaveBeenCalledWith( + 'getRewardsSeasonMetadata', + ['current'], + ); + expect(mockSubmitRequestToBackground).toHaveBeenCalledWith( + 'getRewardsSeasonStatus', + ['test-subscription-id', undefined], + ); + }); + + it('should not fetch if subscriptionId is not available during manual refetch', async () => { + const { result } = renderHookWithProvider( + () => + useSeasonStatus({ + subscriptionId: null, + onAuthorizationError: mockOnAuthorizationError, + }), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'test-account-id', + }, + }, + }, + ); + + await act(async () => { + await result.current.fetchSeasonStatus(); + }); + + expect(mockSubmitRequestToBackground).not.toHaveBeenCalled(); + }); + }); + + describe('dependency changes', () => { + it('should refetch when subscriptionId changes', async () => { + mockUseRewardsEnabled.mockReturnValue(true); // Enable rewards for this test + + let subscriptionId = 'subscription-1'; + const { result, rerender } = renderHookWithProvider( + () => + useSeasonStatus({ + subscriptionId, + onAuthorizationError: mockOnAuthorizationError, + }), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'test-account-id', + }, + }, + }, + ); + + // Wait for initial fetch + await waitFor(() => { + expect(result.current.seasonStatusLoading).toBe(false); + }); + + expect(mockSubmitRequestToBackground).toHaveBeenCalledWith( + 'getRewardsSeasonMetadata', + ['current'], + ); + expect(mockSubmitRequestToBackground).toHaveBeenCalledWith( + 'getRewardsSeasonStatus', + ['subscription-1', undefined], + ); + + // Change subscriptionId and rerender + subscriptionId = 'subscription-2'; + rerender(); + + // Wait for refetch + await waitFor(() => { + expect(mockSubmitRequestToBackground).toHaveBeenCalledWith( + 'getRewardsSeasonStatus', + ['subscription-2', undefined], + ); + }); + + expect(mockSubmitRequestToBackground).toHaveBeenCalledTimes(4); // 2 calls for each fetch (metadata + status) + }); + }); + + describe('memoization', () => { + it('should return stable fetchSeasonStatus function', () => { + const { result, rerender } = renderHookWithProvider( + () => + useSeasonStatus({ + subscriptionId: 'test-subscription-id', + onAuthorizationError: mockOnAuthorizationError, + }), + { + metamask: { + isUnlocked: true, + rewardsActiveAccount: { + account: 'test-account-id', + }, + }, + }, + ); + + const firstFetchFunction = result.current.fetchSeasonStatus; + + rerender(); + + const secondFetchFunction = result.current.fetchSeasonStatus; + + expect(firstFetchFunction).toBe(secondFetchFunction); + }); + }); +}); diff --git a/ui/hooks/rewards/useSeasonStatus.ts b/ui/hooks/rewards/useSeasonStatus.ts new file mode 100644 index 000000000000..635a8fd2a8da --- /dev/null +++ b/ui/hooks/rewards/useSeasonStatus.ts @@ -0,0 +1,110 @@ +import { useState, useCallback, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import log from 'loglevel'; +import { submitRequestToBackground } from '../../store/background-connection'; +import { getIsUnlocked } from '../../ducks/metamask/metamask'; +import { + SeasonDtoState, + SeasonStatusState, +} from '../../../shared/types/rewards'; +import { useAppSelector } from '../../store/store'; +import { useRewardsEnabled } from './useRewardsEnabled'; + +type UseSeasonStatusOptions = { + subscriptionId: string | null; + onAuthorizationError: () => Promise; +}; + +type UseSeasonStatusReturn = { + seasonStatus: SeasonStatusState | null; + seasonStatusError: string | null; + seasonStatusLoading: boolean; + fetchSeasonStatus: () => Promise; +}; + +/** + * Hook to fetch and manage season status + * + * @param options0 + * @param options0.subscriptionId + * @param options0.onAuthorizationError + */ +export const useSeasonStatus = ({ + subscriptionId, + onAuthorizationError, +}: UseSeasonStatusOptions): UseSeasonStatusReturn => { + const isUnlocked = useSelector(getIsUnlocked); + const isRewardsEnabled = useRewardsEnabled(); + const [seasonStatus, setSeasonStatus] = useState( + null, + ); + const [seasonStatusError, setSeasonStatusError] = useState( + null, + ); + const [seasonStatusLoading, setSeasonStatusLoading] = useState(false); + const activeRewardsCaipAccountId = useAppSelector( + (state) => state.metamask.rewardsActiveAccount?.account, + ); + + const fetchSeasonStatus = useCallback(async (): Promise => { + // Don't fetch if no subscriptionId or season metadata + if (!subscriptionId || !isRewardsEnabled) { + setSeasonStatus(null); + setSeasonStatusLoading(false); + return; + } + + setSeasonStatusLoading(true); + + try { + const currentSeasonMetadata = + await submitRequestToBackground( + 'getRewardsSeasonMetadata', + ['current'], + ); + + if (!currentSeasonMetadata) { + throw new Error('No season metadata found'); + } + + const statusData = await submitRequestToBackground( + 'getRewardsSeasonStatus', + [subscriptionId, currentSeasonMetadata.id], + ); + + setSeasonStatus(statusData); + setSeasonStatusError(null); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + log.error('[useSeasonStatus] Error fetching season status:', error); + setSeasonStatusError(errorMessage); + + // If authorization failed, trigger callback + if ( + (errorMessage.includes('Authorization') || + errorMessage.includes('Unauthorized')) && + onAuthorizationError + ) { + await onAuthorizationError(); + setSeasonStatus(null); + } + } finally { + setSeasonStatusLoading(false); + } + }, [subscriptionId, onAuthorizationError, isRewardsEnabled]); + + // Fetch season status when dependencies change + useEffect(() => { + if (isUnlocked && activeRewardsCaipAccountId) { + fetchSeasonStatus(); + } + }, [isUnlocked, fetchSeasonStatus, activeRewardsCaipAccountId]); + + return { + seasonStatus, + seasonStatusError, + seasonStatusLoading, + fetchSeasonStatus, + }; +}; diff --git a/ui/pages/index.js b/ui/pages/index.js index a93e9bad7b87..f98ff564fc92 100644 --- a/ui/pages/index.js +++ b/ui/pages/index.js @@ -13,6 +13,7 @@ import { MetamaskNotificationsProvider } from '../contexts/metamask-notification import { AssetPollingProvider } from '../contexts/assetPolling'; import { MetamaskIdentityProvider } from '../contexts/identity'; import { ShieldSubscriptionProvider } from '../contexts/shield/shield-subscription'; +import { RewardsProvider } from '../contexts/rewards'; import ErrorPage from './error-page/error-page.component'; import Routes from './routes'; @@ -56,7 +57,9 @@ class Index extends PureComponent { - + + +