From 6b457187593a37d34faf23e6d4898cb2a7fb59b7 Mon Sep 17 00:00:00 2001 From: Thebora Kompanioni Date: Sun, 10 Sep 2023 15:04:46 +0200 Subject: [PATCH] feat: backend version based feature toggles (#647) * feat(api): add getinfo request to JmWalletApi * chore: add server information to ServiceInfoContext * feat(features): feature toggles based on backend version * ui: hide import wallet button on unsupported backend version * ui: hide rescan chain button on unsupported backend version * fix: handle nullable serviceInfo and fix tests * test: verify import wallet is not displayed --- .gitignore | 2 + src/components/App.test.tsx | 2 + src/components/CreateWallet.test.jsx | 4 +- src/components/Settings.jsx | 3 +- src/components/Wallet.test.tsx | 4 +- src/components/Wallets.jsx | 32 +++++++----- src/components/Wallets.test.jsx | 43 +++++++++++++++- src/constants/features.ts | 26 ++++++++++ src/context/ServiceInfoContext.tsx | 76 +++++++++++++++++++++++++--- src/globals.d.ts | 2 + src/libs/JmWalletApi.ts | 7 +++ 11 files changed, 176 insertions(+), 25 deletions(-) create mode 100644 src/constants/features.ts diff --git a/.gitignore b/.gitignore index f7d330a20..ec94a961b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ .env.production.local npm-debug.log* + +.idea/ diff --git a/src/components/App.test.tsx b/src/components/App.test.tsx index c3f047e9a..ff6c22a08 100644 --- a/src/components/App.test.tsx +++ b/src/components/App.test.tsx @@ -7,12 +7,14 @@ import App from './App' jest.mock('../libs/JmWalletApi', () => ({ ...jest.requireActual('../libs/JmWalletApi'), + getGetinfo: jest.fn(), getSession: jest.fn(), })) describe('', () => { beforeEach(() => { const neverResolvingPromise = new Promise(() => {}) + ;(apiMock.getGetinfo as jest.Mock).mockResolvedValue(neverResolvingPromise) ;(apiMock.getSession as jest.Mock).mockResolvedValue(neverResolvingPromise) }) diff --git a/src/components/CreateWallet.test.jsx b/src/components/CreateWallet.test.jsx index cd1ef526d..6d0c9f3e8 100644 --- a/src/components/CreateWallet.test.jsx +++ b/src/components/CreateWallet.test.jsx @@ -11,6 +11,7 @@ import CreateWallet from './CreateWallet' jest.mock('../libs/JmWalletApi', () => ({ ...jest.requireActual('../libs/JmWalletApi'), + getGetinfo: jest.fn(), getSession: jest.fn(), postWalletCreate: jest.fn(), })) @@ -32,7 +33,8 @@ describe('', () => { beforeEach(() => { const neverResolvingPromise = new Promise(() => {}) - apiMock.getSession.mockReturnValue(neverResolvingPromise) + apiMock.getGetinfo.mockResolvedValue(neverResolvingPromise) + apiMock.getSession.mockResolvedValue(neverResolvingPromise) }) it('should render without errors', () => { diff --git a/src/components/Settings.jsx b/src/components/Settings.jsx index 1ea0243d2..56204f26d 100644 --- a/src/components/Settings.jsx +++ b/src/components/Settings.jsx @@ -17,6 +17,7 @@ import styles from './Settings.module.css' import SeedModal from './settings/SeedModal' import FeeConfigModal from './settings/FeeConfigModal' import { isDebugFeatureEnabled } from '../constants/debugFeatures' +import { isFeatureEnabled } from '../constants/features' export default function Settings({ wallet, stopWallet }) { const { t, i18n } = useTranslation() @@ -226,7 +227,7 @@ export default function Settings({ wallet, stopWallet }) { )} - {isDebugFeatureEnabled('rescanChainPage') && ( + {serviceInfo && isFeatureEnabled('rescanChain', serviceInfo) && isDebugFeatureEnabled('rescanChainPage') && ( ({ ...jest.requireActual('../libs/JmWalletApi'), + getGetinfo: jest.fn(), getSession: jest.fn(), })) @@ -43,7 +44,8 @@ describe('', () => { beforeEach(() => { const neverResolvingPromise = new Promise(() => {}) - jest.mocked(apiMock.getSession).mockReturnValue(neverResolvingPromise) + ;(apiMock.getGetinfo as jest.Mock).mockResolvedValue(neverResolvingPromise) + ;(apiMock.getSession as jest.Mock).mockResolvedValue(neverResolvingPromise) }) it('should render inactive wallet without errors', () => { diff --git a/src/components/Wallets.jsx b/src/components/Wallets.jsx index fead18e1d..91418795a 100644 --- a/src/components/Wallets.jsx +++ b/src/components/Wallets.jsx @@ -12,6 +12,7 @@ import { walletDisplayName } from '../utils' import * as Api from '../libs/JmWalletApi' import { routes } from '../constants/routes' import { ConfirmModal } from './Modal' +import { isFeatureEnabled } from '../constants/features' function arrayEquals(a, b) { return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((val, index) => val === b[index]) @@ -232,6 +233,7 @@ export default function Wallets({ currentWallet, startWallet, stopWallet }) { ) }) )} +
0, - disabled: isUnlocking, + disabled: isLoading || isUnlocking, })} data-testid="new-wallet-btn" > @@ -252,19 +254,21 @@ export default function Wallets({ currentWallet, startWallet, stopWallet }) { {t('wallets.button_new_wallet')}
- -
- - {t('wallets.button_import_wallet')} -
- + {serviceInfo && isFeatureEnabled('importWallet', serviceInfo) && ( + +
+ + {t('wallets.button_import_wallet')} +
+ + )} diff --git a/src/components/Wallets.test.jsx b/src/components/Wallets.test.jsx index 9616a0118..802c78985 100644 --- a/src/components/Wallets.test.jsx +++ b/src/components/Wallets.test.jsx @@ -9,6 +9,7 @@ import Wallets from './Wallets' jest.mock('../libs/JmWalletApi', () => ({ ...jest.requireActual('../libs/JmWalletApi'), + getGetinfo: jest.fn(), getSession: jest.fn(), getWalletAll: jest.fn(), postWalletUnlock: jest.fn(), @@ -38,6 +39,7 @@ describe('', () => { beforeEach(() => { const neverResolvingPromise = new Promise(() => {}) apiMock.getSession.mockResolvedValue(neverResolvingPromise) + apiMock.getGetinfo.mockResolvedValue(neverResolvingPromise) }) it('should render without errors', () => { @@ -86,6 +88,10 @@ describe('', () => { ok: true, json: () => Promise.resolve({ wallets: [] }), }) + apiMock.getGetinfo.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ version: '0.9.10dev' }), + }) act(() => setup({})) @@ -102,7 +108,7 @@ describe('', () => { const newWalletButtonBeforeAfter = screen.getByTestId('new-wallet-btn') expect(newWalletButtonBeforeAfter.classList.contains('btn-lg')).toBe(true) - const importWalletButton = screen.getByTestId('import-wallet-btn') + const importWalletButton = await screen.findByTestId('import-wallet-btn') expect(importWalletButton.classList.contains('btn-lg')).toBe(true) }) @@ -121,6 +127,10 @@ describe('', () => { ok: true, json: () => Promise.resolve({ wallets: ['wallet0.jmdat', 'wallet1.jmdat'] }), }) + apiMock.getGetinfo.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ version: '0.9.10dev' }), + }) act(() => setup({})) @@ -136,11 +146,40 @@ describe('', () => { expect(newWalletButton.classList.contains('btn')).toBe(true) expect(newWalletButton.classList.contains('btn-lg')).toBe(false) - const importWalletButton = screen.getByTestId('import-wallet-btn') + const importWalletButton = await screen.findByTestId('import-wallet-btn') expect(importWalletButton.classList.contains('btn')).toBe(true) expect(importWalletButton.classList.contains('btn-lg')).toBe(false) }) + it('should hide "Import Wallet"-button on unsupported backend version', async () => { + apiMock.getSession.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + session: false, + maker_running: false, + coinjoin_in_process: false, + wallet_name: 'None', + }), + }) + apiMock.getWalletAll.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ wallets: [] }), + }) + apiMock.getGetinfo.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ version: '0.9.9' }), + }) + + act(() => setup({})) + + expect(screen.getByText('wallets.text_loading')).toBeInTheDocument() + await waitForElementToBeRemoved(screen.getByText('wallets.text_loading')) + + expect(screen.queryByTestId('import-wallet-btn')).not.toBeInTheDocument() + expect(screen.getByTestId('new-wallet-btn')).toBeInTheDocument() + }) + describe(' lock/unlock flow', () => { const dummyWalletName = 'dummy.jmdat' const dummyToken = 'dummyToken' diff --git a/src/constants/features.ts b/src/constants/features.ts new file mode 100644 index 000000000..d0ea91144 --- /dev/null +++ b/src/constants/features.ts @@ -0,0 +1,26 @@ +import { ServiceInfo } from '../context/ServiceInfoContext' + +interface Features { + importWallet: SemVer + rescanChain: SemVer +} + +const features: Features = { + importWallet: { major: 0, minor: 9, patch: 10 }, // added in https://github.com/JoinMarket-Org/joinmarket-clientserver/pull/1461 + rescanChain: { major: 0, minor: 9, patch: 10 }, // added in https://github.com/JoinMarket-Org/joinmarket-clientserver/pull/1461 +} + +type Feature = keyof Features + +const __isFeatureEnabled = (name: Feature, version: SemVer): boolean => { + const target = features[name] + return ( + version.major > target.major || + (version.major === target.major && version.minor > target.minor) || + (version.major === target.major && version.minor === target.minor && version.patch >= target.patch) + ) +} + +export const isFeatureEnabled = (name: Feature, serviceInfo: ServiceInfo): boolean => { + return !!serviceInfo.server?.version && __isFeatureEnabled(name, serviceInfo.server.version) +} diff --git a/src/context/ServiceInfoContext.tsx b/src/context/ServiceInfoContext.tsx index 1659e8fe7..ba82779bb 100644 --- a/src/context/ServiceInfoContext.tsx +++ b/src/context/ServiceInfoContext.tsx @@ -53,21 +53,56 @@ interface JmSessionData { rescanning: boolean } +interface JmGetInfoData { + version: string +} + +const UNKNOWN_VERSION: SemVer = { major: 0, minor: 0, patch: 0, raw: 'unknown' } + type SessionFlag = { sessionActive: boolean } type MakerRunningFlag = { makerRunning: boolean } type CoinjoinInProgressFlag = { coinjoinInProgress: boolean } type RescanBlockchainInProgressFlag = { rescanning: boolean } +type SessionInfo = { + walletName: Api.WalletName | null + schedule: Schedule | null + offers: Offer[] | null + nickname: string | null +} +type ServerInfo = { + server?: { + version?: SemVer + } +} + type ServiceInfo = SessionFlag & MakerRunningFlag & CoinjoinInProgressFlag & - RescanBlockchainInProgressFlag & { - walletName: Api.WalletName | null - schedule: Schedule | null - offers: Offer[] | null - nickname: string | null + RescanBlockchainInProgressFlag & + SessionInfo & + ServerInfo +type ServiceInfoUpdate = + | ServiceInfo + | MakerRunningFlag + | CoinjoinInProgressFlag + | RescanBlockchainInProgressFlag + | ServerInfo + +const versionRegex = new RegExp(/^(\d+)\.(\d+)\.(\d+).*$/) +const toSemVer = (data: JmGetInfoData): SemVer => { + const arr = versionRegex.exec(data.version) + if (!arr || arr.length < 4) { + return UNKNOWN_VERSION } -type ServiceInfoUpdate = ServiceInfo | MakerRunningFlag | CoinjoinInProgressFlag | RescanBlockchainInProgressFlag + + return { + major: parseInt(arr[1], 10), + minor: parseInt(arr[2], 10), + patch: parseInt(arr[3], 10), + raw: data.version, + } +} interface ServiceInfoContextEntry { serviceInfo: ServiceInfo | null @@ -91,6 +126,34 @@ const ServiceInfoProvider = ({ children }: React.PropsWithChildren<{}>) => { ) const [connectionError, setConnectionError] = useState() + useEffect(() => { + const abortCtrl = new AbortController() + + Api.getGetinfo({ signal: abortCtrl.signal }) + .then((res) => (res.ok ? res.json() : Api.Helper.throwError(res))) + .then((data: JmGetInfoData) => { + dispatchServiceInfo({ + server: { + version: toSemVer(data), + }, + }) + }) + .catch((err) => { + const notFound = err.response.status === 404 + if (notFound) { + dispatchServiceInfo({ + server: { + version: UNKNOWN_VERSION, + }, + }) + } + }) + + return () => { + abortCtrl.abort() + } + }, [connectionError]) + useEffect(() => { if (connectionError) { // Just reset the wallet info, not the session storage (token), @@ -262,6 +325,7 @@ export { useReloadServiceInfo, useDispatchServiceInfo, useSessionConnectionError, + ServiceInfo, Schedule, StateFlag, } diff --git a/src/globals.d.ts b/src/globals.d.ts index 476ca5fdf..30c01b0c8 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -8,3 +8,5 @@ type Seconds = number declare type MnemonicPhrase = string[] declare type SimpleAlert = import('react-bootstrap').AlertProps & { message: string | import('react').ReactNode } + +declare type SemVer = { major: number; minor: number; patch: number; raw?: string } diff --git a/src/libs/JmWalletApi.ts b/src/libs/JmWalletApi.ts index aa8305bd3..916be1d7e 100644 --- a/src/libs/JmWalletApi.ts +++ b/src/libs/JmWalletApi.ts @@ -210,6 +210,12 @@ const Helper = (() => { } })() +const getGetinfo = async ({ signal }: ApiRequestContext) => { + return await fetch(`${basePath()}/v1/getinfo`, { + signal, + }) +} + const getSession = async ({ token, signal }: ApiRequestContext & { token?: ApiToken }) => { return await fetch(`${basePath()}/v1/session`, { headers: token ? { ...Helper.buildAuthHeader(token) } : undefined, @@ -465,6 +471,7 @@ export class JmApiError extends Error { } export { + getGetinfo, postMakerStart, getMakerStop, getSession,