feat(trading): add provider metadata handling and related tests

feat(tests): add trading test utilities and refactor provider metadata tests

feat(deps): update @testing-library/react version in package.json

(cherry picked from commit 68f5c75281)
This commit is contained in:
Lukas 'Sherpa' Werner
2026-02-11 13:08:01 +01:00
committed by pavelmario
parent b8ec4efd1e
commit 10156ad456
9 changed files with 422 additions and 54 deletions

View File

@@ -13,13 +13,9 @@ type TradingFooterProps = {
export const TradingFooter = ({ provider }: TradingFooterProps) => {
const currentProviderMetadata = useSelector(selectTradingProviderMetadata);
const providerMetadata = provider ?? currentProviderMetadata;
const { companyName, termsUrl } = provider ?? currentProviderMetadata ?? {};
const providerName = providerMetadata?.companyName ? (
providerMetadata.companyName
) : (
<Translation id="TR_TERMS_PROVIDER_PLACEHOLDER" />
);
const providerName = companyName ?? <Translation id="TR_TERMS_PROVIDER_PLACEHOLDER" />;
return (
<Column alignItems="center" margin={{ top: 48 }} gap={12}>
@@ -29,11 +25,7 @@ export const TradingFooter = ({ provider }: TradingFooterProps) => {
values={{
provider: providerName,
comp: chunks =>
providerMetadata?.termsUrl ? (
<Link href={providerMetadata.termsUrl}>{providerName}</Link>
) : (
chunks
),
termsUrl ? <Link href={termsUrl}>{providerName}</Link> : chunks,
}}
/>
</Text>

View File

@@ -23,6 +23,7 @@
"@suite-common/wallet-types": "workspace:*",
"@suite-common/wallet-utils": "workspace:*",
"@suite/intl": "workspace:*",
"@testing-library/react": "^16.3.0",
"@trezor/address-validator": "workspace:*",
"@trezor/connect": "workspace:*",
"@trezor/connect-plugin-ethereum": "workspace:*",
@@ -36,7 +37,6 @@
},
"devDependencies": {
"@suite-common/test-utils": "workspace:*",
"@testing-library/react": "^16.3.0",
"@types/invity-api": "^1.1.12"
}
}

View File

@@ -0,0 +1,176 @@
import { ReactNode } from 'react';
import { Provider } from 'react-redux';
import { combineReducers } from '@reduxjs/toolkit';
import { RenderHookOptions, renderHook } from '@testing-library/react';
import type { BuyProviderInfo, ExchangeProviderInfo, SellProviderInfo } from 'invity-api';
import { configureMockStore } from '@suite-common/test-utils';
import type { BuyInfo } from '../reducers/buyReducer';
import type { ExchangeInfo } from '../reducers/exchangeReducer';
import type { SellInfo } from '../reducers/sellReducer';
import { TradingState, initialState, tradingCommonReducer } from '../reducers/tradingCommonReducer';
import { regional } from '../regional';
export type TradingTestState = {
wallet: {
trading: TradingState;
};
};
type RenderHookWithTradingStoreOptions<Props> = RenderHookOptions<Props> & {
preloadedState?: Partial<TradingTestState>;
};
/**
* Creates a trading test state with proper structure.
*
* @param overrides - Partial TradingState to merge with initialState
* @returns Complete TradingTestState ready for Redux store
*
* @example
* ```ts
* const state = createTradingTestState({
* currentProviderMetadata: mockProvider,
* buy: { ...initialState.buy, isLoading: true }
* });
* ```
*/
export const createTradingTestState = (
overrides: Partial<TradingState> = {},
): TradingTestState => ({
wallet: {
trading: {
...initialState,
...overrides,
},
},
});
/**
* Creates a partial BuyInfo state for testing.
*
* @param providerInfos - Map of provider name to BuyProviderInfo
* @returns Partial BuyInfo with minimal required fields
*
* @example
* ```ts
* const buyInfo = createBuyInfoState({
* changenow: getProviderMetadataFixture('changenow') as BuyProviderInfo
* });
* ```
*/
export const createBuyInfoState = (
providerInfos: Record<string, BuyProviderInfo> = {},
): Partial<BuyInfo> => ({
buyInfo: {
country: regional.UNKNOWN_COUNTRY,
providers: [],
defaultAmountsOfFiatCurrencies: {} as any,
},
providerInfos,
supportedCryptoCurrencies: [],
supportedFiatCurrencies: [],
});
/**
* Creates a partial ExchangeInfo state for testing.
*
* @param providerInfos - Map of provider name to ExchangeProviderInfo
* @returns Partial ExchangeInfo with minimal required fields
*/
export const createExchangeInfoState = (
providerInfos: Record<string, ExchangeProviderInfo> = {},
): Partial<ExchangeInfo> => ({
providerInfos,
buyCryptoIds: [],
sellCryptoIds: [],
});
/**
* Creates a partial SellInfo state for testing.
*
* @param providerInfos - Map of provider name to SellProviderInfo
* @returns Partial SellInfo with minimal required fields
*/
export const createSellInfoState = (
providerInfos: Record<string, SellProviderInfo> = {},
): Partial<SellInfo> => ({
country: regional.UNKNOWN_COUNTRY,
providerInfos,
supportedCryptoCurrencies: [],
supportedFiatCurrencies: [],
});
/**
* Renders a hook with pre-configured trading Redux store.
*
* This utility automatically creates a Redux store with the trading reducer
* and wraps the hook in a Provider. It returns the standard React Testing Library
* hook result plus the store instance for state assertions.
*
* @template Result - Return type of the hook
* @template Props - Props type for the hook (for rerendering)
* @param callback - Hook function to test
* @param options - Rendering options
* @param options.preloadedState - Initial Redux state for the store
* @param options.initialProps - Initial props to pass to the hook
* @returns Hook result with additional `store` property
*
* @example
* ```ts
* // Simple usage
* const { result } = renderHookWithTradingStore(
* () => useMyHook('buy')
* );
*
* // With preloaded state
* const { result, store } = renderHookWithTradingStore(
* () => useProviderMetadataChangeEffect('buy', 'changenow', true),
* {
* preloadedState: createTradingTestState({
* buy: {
* ...initialState.buy,
* buyInfo: createBuyInfoState({
* changenow: mockProvider as BuyProviderInfo
* })
* }
* })
* }
* );
*
* // With props for rerendering
* const { result, rerender } = renderHookWithTradingStore<
* ReturnType<typeof useMyHook>,
* { provider: string }
* >(
* ({ provider }) => useMyHook(provider),
* { initialProps: { provider: 'changenow' } }
* );
*
* rerender({ provider: 'sideshift' });
* ```
*/
export const renderHookWithTradingStore = <Result, Props = unknown>(
callback: (props: Props) => Result,
{ preloadedState, ...options }: RenderHookWithTradingStoreOptions<Props> = {},
) => {
const store = configureMockStore({
reducer: combineReducers({
wallet: combineReducers({
trading: tradingCommonReducer,
}),
}),
preloadedState: preloadedState || createTradingTestState(),
});
const wrapper = ({ children }: { children: ReactNode }) => (
<Provider store={store}>{children}</Provider>
);
return {
...renderHook(callback, { wrapper, ...options }),
store,
};
};

View File

@@ -0,0 +1,154 @@
import {
createBuyInfoState,
createExchangeInfoState,
createSellInfoState,
createTradingTestState,
renderHookWithTradingStore,
} from '../../__tests__/testUtils';
import { getProviderMetadataFixture } from '../../reducers/__fixtures__/providerMetadata';
import { initialState } from '../../reducers/tradingCommonReducer';
import { TradingType } from '../../types';
import { useProviderMetadataChangeEffect } from '../useProviderMetadataChangeEffect';
const mockProviderMetadataChangeNow = getProviderMetadataFixture();
const mockProviderMetadataSideShift = getProviderMetadataFixture('sideshift');
describe('useProviderMetadataChangeEffect', () => {
it('should return undefined when no provider metadata is set', () => {
const { result } = renderHookWithTradingStore(() =>
useProviderMetadataChangeEffect('buy', undefined, true),
);
expect(result.current).toBeUndefined();
});
it('should not update provider metadata when areProviderChangesAllowed is false', () => {
const { result, rerender } = renderHookWithTradingStore<
ReturnType<typeof useProviderMetadataChangeEffect>,
{ tradingType: TradingType; quoteName?: string; areProviderChangesAllowed?: boolean }
>(
({ tradingType, quoteName, areProviderChangesAllowed }) =>
useProviderMetadataChangeEffect(tradingType, quoteName, areProviderChangesAllowed),
{
preloadedState: createTradingTestState({
buy: {
...initialState.buy,
buyInfo: createBuyInfoState({ changenow: mockProviderMetadataChangeNow }),
},
}),
initialProps: {
tradingType: 'buy' as TradingType,
quoteName: 'changenow',
areProviderChangesAllowed: false,
},
},
);
expect(result.current).toBeUndefined();
rerender({
tradingType: 'buy',
quoteName: 'changenow',
areProviderChangesAllowed: true,
});
expect(result.current).toEqual(mockProviderMetadataChangeNow);
});
it('should update provider metadata when provider changes and areProviderChangesAllowed is true (buy)', () => {
const { result, rerender } = renderHookWithTradingStore<
ReturnType<typeof useProviderMetadataChangeEffect>,
{ tradingType: TradingType; quoteName?: string; areProviderChangesAllowed?: boolean }
>(
({ tradingType, quoteName, areProviderChangesAllowed }) =>
useProviderMetadataChangeEffect(tradingType, quoteName, areProviderChangesAllowed),
{
preloadedState: createTradingTestState({
buy: {
...initialState.buy,
buyInfo: createBuyInfoState({
changenow: mockProviderMetadataChangeNow,
sideshift: mockProviderMetadataSideShift,
}),
},
}),
initialProps: {
tradingType: 'buy' as TradingType,
quoteName: 'changenow',
areProviderChangesAllowed: true,
},
},
);
expect(result.current).toEqual(mockProviderMetadataChangeNow);
rerender({
tradingType: 'buy',
quoteName: 'sideshift',
areProviderChangesAllowed: true,
});
expect(result.current).toEqual(mockProviderMetadataSideShift);
});
it('should update provider metadata for exchange type', () => {
const { result } = renderHookWithTradingStore(
() => useProviderMetadataChangeEffect('exchange', 'exchangeProvider', true),
{
preloadedState: createTradingTestState({
exchange: {
...initialState.exchange,
exchangeInfo: createExchangeInfoState({
exchangeProvider: mockProviderMetadataChangeNow,
}),
},
}),
},
);
expect(result.current).toEqual(mockProviderMetadataChangeNow);
});
it('should update provider metadata for sell type', () => {
const { result } = renderHookWithTradingStore(
() => useProviderMetadataChangeEffect('sell', 'sellProvider', true),
{
preloadedState: createTradingTestState({
sell: {
...initialState.sell,
sellInfo: createSellInfoState({
sellProvider: mockProviderMetadataChangeNow,
}),
},
}),
},
);
expect(result.current).toEqual(mockProviderMetadataChangeNow);
});
it('should clear provider metadata on unmount', () => {
const { unmount, store } = renderHookWithTradingStore(
() => useProviderMetadataChangeEffect('buy', 'changenow', true),
{
preloadedState: createTradingTestState({
currentProviderMetadata: mockProviderMetadataChangeNow,
buy: {
...initialState.buy,
buyInfo: createBuyInfoState({
changenow: mockProviderMetadataChangeNow,
}),
},
}),
},
);
expect(store.getState().wallet.trading.currentProviderMetadata).toEqual(
mockProviderMetadataChangeNow,
);
unmount();
expect(store.getState().wallet.trading.currentProviderMetadata).toBeUndefined();
});
it('should handle undefined quoteName gracefully', () => {
const { result } = renderHookWithTradingStore(() =>
useProviderMetadataChangeEffect('buy', undefined, true),
);
expect(result.current).toBeUndefined();
});
});

View File

@@ -9,7 +9,11 @@ import {
} from '../selectors/tradingSelectors';
import type { TradingType } from '../types';
export const useProviderMetadataChangeEffect = (tradingType: TradingType, quoteName?: string) => {
export const useProviderMetadataChangeEffect = (
tradingType: TradingType,
quoteName?: string,
areProviderChangesAllowed = true,
) => {
const dispatch = useDispatch();
const providerMetadata = useSelector((state: TradingRootState) =>
@@ -18,18 +22,16 @@ export const useProviderMetadataChangeEffect = (tradingType: TradingType, quoteN
const currentProviderMetadata = useSelector(selectTradingProviderMetadata);
useEffect(() => {
if (!quoteName && providerMetadata) {
dispatch(tradingActions.setCurrentProviderMetadata(undefined));
if (!areProviderChangesAllowed) {
return;
}
}, [dispatch, quoteName, providerMetadata]);
useEffect(() => {
if (providerMetadata === currentProviderMetadata) {
return;
}
dispatch(tradingActions.setCurrentProviderMetadata(providerMetadata));
}, [providerMetadata, currentProviderMetadata, dispatch]);
}, [providerMetadata, currentProviderMetadata, dispatch, areProviderChangesAllowed]);
useEffect(
() => () => {

View File

@@ -0,0 +1,13 @@
import type { ProviderMetadata } from 'invity-api';
export const getProviderMetadataFixture = (
providerName: string = 'changenow',
): ProviderMetadata => ({
name: providerName,
companyName: providerName,
logo: `${providerName}-icon.jpg`,
isActive: true,
supportUrl: `https://support.${providerName}.io`,
statusUrl: `https://${providerName}.io/exchange/txs/{{orderId}}`,
termsUrl: `https://${providerName}.io/terms-of-use`,
});

View File

@@ -5,6 +5,7 @@ import { configureMockStore, extraDependenciesCommonMock } from '@suite-common/t
import { selectTradingMaxSlippagePercentage } from '../../selectors/settingsSelectors';
import { buyThunks } from '../../thunks/buy';
import { sellThunks } from '../../thunks/sell';
import { getProviderMetadataFixture } from '../__fixtures__/providerMetadata';
import { tradingFixtures } from '../__fixtures__/tradingReducer';
import { buyInitialState, tradingBuyActions } from '../buyReducer';
import { exchangeInitialState, tradingExchangeActions } from '../exchangeReducer';
@@ -137,6 +138,45 @@ describe('Testing trading reducer', () => {
expect(store.getState().wallet.trading.modalAccountKey).toEqual('MY_KEY');
});
it('should set currentProviderMetadata with complete provider data', () => {
const providerMetadata = getProviderMetadataFixture('changenow');
expect(store.getState().wallet.trading.currentProviderMetadata).toBeUndefined();
store.dispatch(tradingActions.setCurrentProviderMetadata(providerMetadata));
expect(store.getState().wallet.trading.currentProviderMetadata).toEqual(
providerMetadata,
);
});
it('should update currentProviderMetadata when called multiple times', () => {
const firstProvider = getProviderMetadataFixture('changenow');
const secondProvider = getProviderMetadataFixture('sideshift');
store.dispatch(tradingActions.setCurrentProviderMetadata(firstProvider));
expect(store.getState().wallet.trading.currentProviderMetadata).toEqual(
firstProvider,
);
store.dispatch(tradingActions.setCurrentProviderMetadata(secondProvider));
expect(store.getState().wallet.trading.currentProviderMetadata).toEqual(
secondProvider,
);
});
it('should clear currentProviderMetadata when set to undefined', () => {
const providerMetadata = getProviderMetadataFixture('changenow');
store.dispatch(tradingActions.setCurrentProviderMetadata(providerMetadata));
expect(store.getState().wallet.trading.currentProviderMetadata).toEqual(
providerMetadata,
);
store.dispatch(tradingActions.setCurrentProviderMetadata(undefined));
expect(store.getState().wallet.trading.currentProviderMetadata).toBeUndefined();
});
});
describe('tradingSettings', () => {

View File

@@ -56,6 +56,7 @@ import {
selectTradingPlatformByCryptoId,
selectTradingPrefilledFromAccount,
selectTradingProviderByNameAndTradeType,
selectTradingProviderMetadata,
selectTradingSellAccountKey,
selectTradingSellFormStep,
selectTradingSellInfo,
@@ -1441,4 +1442,28 @@ describe('tradingSelectors', () => {
expect(first).toBe(second);
});
});
describe('selectTradingProviderMetadata', () => {
it('should return currentProviderMetadata from state', () => {
const providerMetadata = {
name: 'TEST_PROVIDER',
companyName: 'Test Company',
logo: 'https://example.com/logo.png',
isActive: true,
};
state.wallet.trading.currentProviderMetadata = providerMetadata;
const result = selectTradingProviderMetadata(state);
expect(result).toEqual(providerMetadata);
});
it('should return undefined when currentProviderMetadata is not set', () => {
state.wallet.trading.currentProviderMetadata = undefined;
const result = selectTradingProviderMetadata(state);
expect(result).toBeUndefined();
});
});
});

View File

@@ -1,14 +1,8 @@
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useIsFocused } from '@react-navigation/native';
import {
type TradingRootState,
TradingType,
selectTradingProviderByNameAndTradeType,
selectTradingProviderMetadata,
tradingActions,
useProviderMetadataChangeEffect as useCommonProviderMetadataChangeEffect,
} from '@suite-common/trading';
export type QuoteProviderFormWatch = (key: 'quote.exchange') => string | undefined;
@@ -17,36 +11,8 @@ export const useProviderMetadataChangeEffect = (
watch: QuoteProviderFormWatch,
tradingType: TradingType,
) => {
const dispatch = useDispatch();
const exchange = watch('quote.exchange');
const isFocused = useIsFocused();
const providerMetadata = useSelector((state: TradingRootState) =>
selectTradingProviderByNameAndTradeType(state, exchange, tradingType),
);
const currentProviderMetadata = useSelector(selectTradingProviderMetadata);
useEffect(() => {
// On navigation to preview screen the form is cleared, but we want to keep this value, therefore
// we skip updates to currentProviderMetadata.
// The effect will clear the provider metadata as soon as user goes back to form screen.
if (!isFocused) {
return;
}
if (providerMetadata === currentProviderMetadata) {
return;
}
dispatch(tradingActions.setCurrentProviderMetadata(providerMetadata));
}, [providerMetadata, currentProviderMetadata, dispatch, isFocused]);
useEffect(
() => () => {
dispatch(tradingActions.setCurrentProviderMetadata(undefined));
},
[dispatch],
);
return currentProviderMetadata;
return useCommonProviderMetadataChangeEffect(tradingType, exchange, isFocused);
};