import { FetchMock } from 'jest-fetch-mock/types' import { expectSaga } from 'redux-saga-test-plan' import * as matchers from 'redux-saga-test-plan/matchers' import { select } from 'redux-saga/effects' import { DEEP_LINK_URL_SCHEME } from 'src/config' import { Actions, celebratedNftFound, nftRewardReadyToDisplay } from 'src/home/actions' import { NftCelebrationStatus } from 'src/home/reducers' import { nftCelebrationSelector } from 'src/home/selectors' import * as nftSaga from 'src/nfts/saga' import { handleFetchNfts, watchFirstFetchCompleted } from 'src/nfts/saga' import { fetchNftsCompleted, fetchNftsFailed } from 'src/nfts/slice' import { getDynamicConfigParams, getFeatureGate, getMultichainFeatures } from 'src/statsig' import { StatsigFeatureGates } from 'src/statsig/types' import { NetworkId } from 'src/transactions/types' import Logger from 'src/utils/Logger' import networkConfig from 'src/web3/networkConfig' import { walletAddressSelector } from 'src/web3/selectors' import { createMockStore } from 'test/utils' import { mockNftAllFields, mockNftMinimumFields } from 'test/values' jest.mock('src/statsig') const loggerDebugSpy = jest.spyOn(Logger, 'debug') const loggerErrorSpy = jest.spyOn(Logger, 'error') const nftResponse1 = JSON.stringify({ result: [mockNftAllFields], }) const nftResponse2 = JSON.stringify({ result: [mockNftMinimumFields], }) const mockCelebratedNft = { networkId: mockNftAllFields.networkId, contractAddress: mockNftAllFields.contractAddress, } const mockRemoteConfig = { celebratedNft: mockCelebratedNft, deepLink: `${DEEP_LINK_URL_SCHEME}://test`, rewardExpirationDate: '3000-12-01T00:00:00.000Z', rewardReminderDate: '3000-01-01T00:00:00.000Z', } const mockExpiredRemoteConfig = { ...mockRemoteConfig, rewardExpirationDate: '2000-12-01T00:00:00.000Z', rewardReminderDate: '2000-01-01T00:00:00.000Z', } const mockNftCelebrationStore = (status: NftCelebrationStatus) => { return { home: { nftCelebration: { networkId: mockNftAllFields.networkId, contractAddress: mockNftAllFields.contractAddress, deepLink: `${DEEP_LINK_URL_SCHEME}://test`, rewardExpirationDate: '3000-12-01T00:00:00.000Z', rewardReminderDate: '3000-01-01T00:00:00.000Z', status, }, }, } } describe('Given Nfts saga', () => { describe('When fetching users Nfts', () => { const mockFetch = fetch as FetchMock beforeEach(() => { mockFetch.resetMocks() jest.clearAllMocks() jest.mocked(getMultichainFeatures).mockReturnValue({ showNfts: [NetworkId['celo-alfajores'], NetworkId['ethereum-sepolia']], }) }) it("should fetch user's NFTs", async () => { mockFetch.mockResponseOnce(nftResponse1) mockFetch.mockResponseOnce(nftResponse2) await expectSaga(handleFetchNfts) .provide([[select(walletAddressSelector), '0xabc']]) .put( fetchNftsCompleted({ nfts: [ { ...mockNftAllFields, networkId: NetworkId['celo-alfajores'] }, { ...mockNftMinimumFields, networkId: NetworkId['ethereum-sepolia'] }, ], }) ) .run() expect(mockFetch).toHaveBeenCalledWith( `${networkConfig.getNftsByOwnerAddressUrl}?address=0xabc&networkId=celo-alfajores`, { headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, method: 'GET', signal: expect.any(AbortSignal), } ) expect(mockFetch).toHaveBeenCalledWith( `${networkConfig.getNftsByOwnerAddressUrl}?address=0xabc&networkId=ethereum-sepolia`, { headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, method: 'GET', signal: expect.any(AbortSignal), } ) }) it('should save error on parse fail', async () => { mockFetch.mockResponseOnce('invalid json') await expectSaga(handleFetchNfts) .provide([[select(walletAddressSelector), '0xabc']]) .put( fetchNftsFailed({ error: `invalid json response body at reason: Unexpected token 'i', "invalid json" is not valid JSON`, }) ) .run() expect(loggerErrorSpy).toHaveBeenCalledTimes(1) }) it('should save error on fetch fail', async () => { mockFetch.mockResponse(JSON.stringify({ message: 'something went wrong' }), { status: 500 }) await expectSaga(handleFetchNfts) .provide([[select(walletAddressSelector), '0xabc']]) .put( fetchNftsFailed({ error: 'Unable to fetch NFTs for celo-alfajores: 500 {"message":"something went wrong"}', }) ) .run() }) it('should not fetch when no wallet address found', async () => { await expectSaga(handleFetchNfts) .provide([[select(walletAddressSelector), null]]) .not.put.actionType(fetchNftsCompleted.type) .run() expect(loggerDebugSpy).toHaveBeenCalledWith( 'NftsSaga', 'Wallet address not found, skipping NFTs list fetch' ) }) }) describe('findCelebratedNft saga', () => { it('should put celebrated NFT once found', () => { const mockAction = fetchNftsCompleted({ nfts: [mockNftAllFields] }) jest.mocked(getFeatureGate).mockReturnValue(true) jest.mocked(getDynamicConfigParams).mockReturnValue(mockRemoteConfig) return expectSaga(nftSaga.findCelebratedNft, mockAction) .provide([[select(nftCelebrationSelector), null]]) .put( celebratedNftFound({ networkId: mockRemoteConfig.celebratedNft.networkId, contractAddress: mockRemoteConfig.celebratedNft.contractAddress, deepLink: mockRemoteConfig.deepLink, rewardExpirationDate: mockRemoteConfig.rewardExpirationDate, rewardReminderDate: mockRemoteConfig.rewardReminderDate, }) ) .run() }) it('should not put celebrated NFT if feature gate is closed', () => { const mockAction = fetchNftsCompleted({ nfts: [mockNftAllFields] }) jest .mocked(getFeatureGate) .mockImplementation( (featureGate) => featureGate !== StatsigFeatureGates.SHOW_NFT_CELEBRATION ) jest.mocked(getDynamicConfigParams).mockReturnValue(mockExpiredRemoteConfig) return expectSaga(nftSaga.findCelebratedNft, mockAction) .provide([[select(nftCelebrationSelector), undefined]]) .not.put.actionType(Actions.CELEBRATED_NFT_FOUND) .run() }) it('should not put celebrated NFT if dynamic config is empty', () => { const mockAction = fetchNftsCompleted({ nfts: [mockNftAllFields] }) jest.mocked(getFeatureGate).mockReturnValue(true) jest.mocked(getDynamicConfigParams).mockReturnValue({}) return expectSaga(nftSaga.findCelebratedNft, mockAction) .provide([[select(nftCelebrationSelector), undefined]]) .not.put.actionType(Actions.CELEBRATED_NFT_FOUND) .run() }) it('should not put celebrated NFT if celebrated NFT already exists', () => { const mockAction = fetchNftsCompleted({ nfts: [mockNftAllFields] }) jest.mocked(getFeatureGate).mockReturnValue(true) jest.mocked(getDynamicConfigParams).mockReturnValue(mockRemoteConfig) return expectSaga(nftSaga.findCelebratedNft, mockAction) .withState( createMockStore( mockNftCelebrationStore(NftCelebrationStatus.celebrationReadyToDisplay) ).getState() ) .provide([[select(nftCelebrationSelector), mockCelebratedNft]]) .not.put.actionType(Actions.CELEBRATED_NFT_FOUND) .run() }) }) describe('findNftReward saga', () => { it('should not set status "reward ready" if feature gate is closed', () => { const mockAction = fetchNftsCompleted({ nfts: [mockNftAllFields] }) jest .mocked(getFeatureGate) .mockImplementation((featureGate) => featureGate !== StatsigFeatureGates.SHOW_NFT_REWARD) jest.mocked(getDynamicConfigParams).mockReturnValue(mockExpiredRemoteConfig) return expectSaga(nftSaga.findCelebratedNft, mockAction) .provide([[select(nftCelebrationSelector), undefined]]) .not.put.actionType(Actions.NFT_REWARD_READY_TO_DISPLAY) .run() }) it('should not set status "reward ready" if dynamic config is empty', () => { const mockAction = fetchNftsCompleted({ nfts: [mockNftAllFields] }) jest.mocked(getFeatureGate).mockReturnValue(true) jest.mocked(getDynamicConfigParams).mockReturnValue({}) return expectSaga(nftSaga.findCelebratedNft, mockAction) .provide([[select(nftCelebrationSelector), undefined]]) .not.put.actionType(Actions.NFT_REWARD_READY_TO_DISPLAY) .run() }) it('should log an error if expiration date is invalid', async () => { const mockAction = fetchNftsCompleted({ nfts: [mockNftAllFields] }) jest.mocked(getFeatureGate).mockReturnValue(true) jest .mocked(getDynamicConfigParams) .mockReturnValue({ ...mockRemoteConfig, rewardExpirationDate: 'INVALID VALUE' }) await expectSaga(nftSaga.findNftReward, mockAction) .provide([[select(nftCelebrationSelector), undefined]]) .not.put.actionType(Actions.NFT_REWARD_READY_TO_DISPLAY) .run() expect(loggerErrorSpy).toHaveBeenCalledWith( 'NftsSaga', 'Invalid expiration date in remote config' ) }) it('should log an error if reward reminder date is invalid', async () => { const mockAction = fetchNftsCompleted({ nfts: [mockNftAllFields] }) jest.mocked(getFeatureGate).mockReturnValue(true) jest .mocked(getDynamicConfigParams) .mockReturnValue({ ...mockRemoteConfig, rewardReminderDate: 'INVALID VALUE' }) await expectSaga(nftSaga.findNftReward, mockAction) .provide([[select(nftCelebrationSelector), undefined]]) .not.put.actionType(Actions.NFT_REWARD_READY_TO_DISPLAY) .run() expect(loggerErrorSpy).toHaveBeenCalledWith( 'NftsSaga', 'Invalid reminder date in remote config' ) }) it('should set status "reward ready" if celebration was displayed', () => { const mockAction = fetchNftsCompleted({ nfts: [mockNftAllFields] }) jest.mocked(getFeatureGate).mockReturnValue(true) jest.mocked(getDynamicConfigParams).mockReturnValue(mockRemoteConfig) return expectSaga(nftSaga.findNftReward, mockAction) .withState( createMockStore( mockNftCelebrationStore(NftCelebrationStatus.celebrationDisplayed) ).getState() ) .put( nftRewardReadyToDisplay({ showReminder: false, valuesToSync: { rewardExpirationDate: mockRemoteConfig.rewardExpirationDate, rewardReminderDate: mockRemoteConfig.rewardReminderDate, deepLink: mockRemoteConfig.deepLink, }, }) ) .run() }) it('should not set status "reward ready" if celebration was not displayed', async () => { const mockAction = fetchNftsCompleted({ nfts: [mockNftAllFields] }) jest.mocked(getFeatureGate).mockReturnValue(true) jest.mocked(getDynamicConfigParams).mockReturnValue(mockRemoteConfig) await expectSaga(nftSaga.findNftReward, mockAction) .withState( createMockStore( mockNftCelebrationStore(NftCelebrationStatus.celebrationReadyToDisplay) ).getState() ) .not.put( nftRewardReadyToDisplay({ showReminder: false, valuesToSync: { rewardExpirationDate: mockRemoteConfig.rewardExpirationDate, rewardReminderDate: mockRemoteConfig.rewardReminderDate, deepLink: mockRemoteConfig.deepLink, }, }) ) .run() }) describe('when reward is about to expire', () => { beforeAll(() => { jest.useFakeTimers({ now: new Date('3000-01-01T00:00:00.000Z') }) }) afterAll(() => { jest.useFakeTimers({ doNotFake: ['Date'] }) }) it('should set status "reminder ready" if reward was displayed', async () => { const mockAction = fetchNftsCompleted({ nfts: [mockNftAllFields] }) jest.mocked(getFeatureGate).mockReturnValue(true) jest.mocked(getDynamicConfigParams).mockReturnValue(mockRemoteConfig) return expectSaga(nftSaga.findNftReward, mockAction) .withState( createMockStore( mockNftCelebrationStore(NftCelebrationStatus.rewardDisplayed) ).getState() ) .put( nftRewardReadyToDisplay({ showReminder: true, valuesToSync: { rewardExpirationDate: mockRemoteConfig.rewardExpirationDate, rewardReminderDate: mockRemoteConfig.rewardReminderDate, deepLink: mockRemoteConfig.deepLink, }, }) ) .run() }) it('should set status "reminder ready" if celebration was displayed', async () => { const mockAction = fetchNftsCompleted({ nfts: [mockNftAllFields] }) jest.mocked(getFeatureGate).mockReturnValue(true) jest.mocked(getDynamicConfigParams).mockReturnValue(mockRemoteConfig) return expectSaga(nftSaga.findNftReward, mockAction) .withState( createMockStore( mockNftCelebrationStore(NftCelebrationStatus.rewardDisplayed) ).getState() ) .put( nftRewardReadyToDisplay({ showReminder: true, valuesToSync: { rewardExpirationDate: mockRemoteConfig.rewardExpirationDate, rewardReminderDate: mockRemoteConfig.rewardReminderDate, deepLink: mockRemoteConfig.deepLink, }, }) ) .run() }) it('should not set status "reminder ready" if celebration was not displayed', async () => { const mockAction = fetchNftsCompleted({ nfts: [mockNftAllFields] }) jest.mocked(getFeatureGate).mockReturnValue(true) jest.mocked(getDynamicConfigParams).mockReturnValue(mockRemoteConfig) return expectSaga(nftSaga.findNftReward, mockAction) .withState( createMockStore( mockNftCelebrationStore(NftCelebrationStatus.celebrationReadyToDisplay) ).getState() ) .not.put.actionType(Actions.NFT_REWARD_READY_TO_DISPLAY) .run() }) it('should not set status "reminder ready" again if reminder was displayed', async () => { const mockAction = fetchNftsCompleted({ nfts: [mockNftAllFields] }) jest.mocked(getFeatureGate).mockReturnValue(true) jest.mocked(getDynamicConfigParams).mockReturnValue(mockRemoteConfig) return expectSaga(nftSaga.findNftReward, mockAction) .withState( createMockStore( mockNftCelebrationStore(NftCelebrationStatus.reminderDisplayed) ).getState() ) .not.put.actionType(Actions.NFT_REWARD_READY_TO_DISPLAY) .run() }) it('should not set status "reward ready" if reminder is ready', async () => { const mockAction = fetchNftsCompleted({ nfts: [mockNftAllFields] }) jest.mocked(getFeatureGate).mockReturnValue(true) jest.mocked(getDynamicConfigParams).mockReturnValue(mockRemoteConfig) return expectSaga(nftSaga.findNftReward, mockAction) .withState( createMockStore( mockNftCelebrationStore(NftCelebrationStatus.reminderReadyToDisplay) ).getState() ) .not.put( nftRewardReadyToDisplay({ showReminder: false, valuesToSync: { rewardExpirationDate: mockRemoteConfig.rewardExpirationDate, rewardReminderDate: mockRemoteConfig.rewardReminderDate, deepLink: mockRemoteConfig.deepLink, }, }) ) .run() }) it('should not set status "reward ready" if reminder is displayed', async () => { const mockAction = fetchNftsCompleted({ nfts: [mockNftAllFields] }) jest.mocked(getFeatureGate).mockReturnValue(true) jest.mocked(getDynamicConfigParams).mockReturnValue(mockRemoteConfig) return expectSaga(nftSaga.findNftReward, mockAction) .withState( createMockStore( mockNftCelebrationStore(NftCelebrationStatus.reminderDisplayed) ).getState() ) .not.put.actionType(Actions.NFT_REWARD_READY_TO_DISPLAY) .run() }) }) }) describe('watchFirstFetchCompleted saga', () => { it('should call findCelebratedNft only once even if multiple fetchNftsCompleted actions are dispatched', async () => { const mockFindCelebratedNft = jest.fn() const mockFindNftReward = jest.fn() const mockAction = fetchNftsCompleted({ nfts: [mockNftAllFields] }) await expectSaga(watchFirstFetchCompleted) .withState(createMockStore().getState()) .provide([[matchers.call.fn(nftSaga.findCelebratedNft), mockFindCelebratedNft()]]) .provide([[matchers.call.fn(nftSaga.findNftReward), mockFindNftReward()]]) .dispatch(mockAction) .dispatch(mockAction) .run() expect(mockFindCelebratedNft).toHaveBeenCalledTimes(1) expect(mockFindNftReward).toHaveBeenCalledTimes(1) }) }) })