Skip to content

Commit

Permalink
feat: backend version based feature toggles (#647)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
theborakompanioni authored Sep 10, 2023
1 parent a559e1e commit 6b45718
Show file tree
Hide file tree
Showing 11 changed files with 176 additions and 25 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,5 @@
.env.production.local

npm-debug.log*

.idea/
2 changes: 2 additions & 0 deletions src/components/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import App from './App'

jest.mock('../libs/JmWalletApi', () => ({
...jest.requireActual('../libs/JmWalletApi'),
getGetinfo: jest.fn(),
getSession: jest.fn(),
}))

describe('<App />', () => {
beforeEach(() => {
const neverResolvingPromise = new Promise(() => {})
;(apiMock.getGetinfo as jest.Mock).mockResolvedValue(neverResolvingPromise)
;(apiMock.getSession as jest.Mock).mockResolvedValue(neverResolvingPromise)
})

Expand Down
4 changes: 3 additions & 1 deletion src/components/CreateWallet.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}))
Expand All @@ -32,7 +33,8 @@ describe('<CreateWallet />', () => {

beforeEach(() => {
const neverResolvingPromise = new Promise(() => {})
apiMock.getSession.mockReturnValue(neverResolvingPromise)
apiMock.getGetinfo.mockResolvedValue(neverResolvingPromise)
apiMock.getSession.mockResolvedValue(neverResolvingPromise)
})

it('should render without errors', () => {
Expand Down
3 changes: 2 additions & 1 deletion src/components/Settings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -226,7 +227,7 @@ export default function Settings({ wallet, stopWallet }) {
)}
</rb.Button>

{isDebugFeatureEnabled('rescanChainPage') && (
{serviceInfo && isFeatureEnabled('rescanChain', serviceInfo) && isDebugFeatureEnabled('rescanChainPage') && (
<Link
to={routes.rescanChain}
className={`btn btn-outline-dark ${styles['settings-btn']} position-relative`}
Expand Down
4 changes: 3 additions & 1 deletion src/components/Wallet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Wallet, { WalletProps } from './Wallet'

jest.mock('../libs/JmWalletApi', () => ({
...jest.requireActual('../libs/JmWalletApi'),
getGetinfo: jest.fn(),
getSession: jest.fn(),
}))

Expand Down Expand Up @@ -43,7 +44,8 @@ describe('<Wallet />', () => {

beforeEach(() => {
const neverResolvingPromise = new Promise<Response>(() => {})
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', () => {
Expand Down
32 changes: 18 additions & 14 deletions src/components/Wallets.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -232,6 +233,7 @@ export default function Wallets({ currentWallet, startWallet, stopWallet }) {
)
})
)}

<div
className={classNames('d-flex', 'justify-content-center', 'gap-2', 'mt-4', {
'flex-column': walletList?.length === 0,
Expand All @@ -243,7 +245,7 @@ export default function Wallets({ currentWallet, startWallet, stopWallet }) {
'btn-lg': walletList?.length === 0,
'btn-dark': walletList?.length === 0,
'btn-outline-dark': !walletList || walletList.length > 0,
disabled: isUnlocking,
disabled: isLoading || isUnlocking,
})}
data-testid="new-wallet-btn"
>
Expand All @@ -252,19 +254,21 @@ export default function Wallets({ currentWallet, startWallet, stopWallet }) {
<span>{t('wallets.button_new_wallet')}</span>
</div>
</Link>
<Link
to={routes.importWallet}
className={classNames('btn', 'btn-outline-dark', {
'btn-lg': walletList?.length === 0,
disabled: isUnlocking,
})}
data-testid="import-wallet-btn"
>
<div className="d-flex justify-content-center align-items-center">
<Sprite symbol="arrow-right" width="20" height="20" className="me-2" />
<span>{t('wallets.button_import_wallet')}</span>
</div>
</Link>
{serviceInfo && isFeatureEnabled('importWallet', serviceInfo) && (
<Link
to={routes.importWallet}
className={classNames('btn', 'btn-outline-dark', {
'btn-lg': walletList?.length === 0,
disabled: isLoading || isUnlocking,
})}
data-testid="import-wallet-btn"
>
<div className="d-flex justify-content-center align-items-center">
<Sprite symbol="arrow-right" width="20" height="20" className="me-2" />
<span>{t('wallets.button_import_wallet')}</span>
</div>
</Link>
)}
</div>
</div>

Expand Down
43 changes: 41 additions & 2 deletions src/components/Wallets.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -38,6 +39,7 @@ describe('<Wallets />', () => {
beforeEach(() => {
const neverResolvingPromise = new Promise(() => {})
apiMock.getSession.mockResolvedValue(neverResolvingPromise)
apiMock.getGetinfo.mockResolvedValue(neverResolvingPromise)
})

it('should render without errors', () => {
Expand Down Expand Up @@ -86,6 +88,10 @@ describe('<Wallets />', () => {
ok: true,
json: () => Promise.resolve({ wallets: [] }),
})
apiMock.getGetinfo.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ version: '0.9.10dev' }),
})

act(() => setup({}))

Expand All @@ -102,7 +108,7 @@ describe('<Wallets />', () => {
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)
})

Expand All @@ -121,6 +127,10 @@ describe('<Wallets />', () => {
ok: true,
json: () => Promise.resolve({ wallets: ['wallet0.jmdat', 'wallet1.jmdat'] }),
})
apiMock.getGetinfo.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ version: '0.9.10dev' }),
})

act(() => setup({}))

Expand All @@ -136,11 +146,40 @@ describe('<Wallets />', () => {
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('<Wallets /> lock/unlock flow', () => {
const dummyWalletName = 'dummy.jmdat'
const dummyToken = 'dummyToken'
Expand Down
26 changes: 26 additions & 0 deletions src/constants/features.ts
Original file line number Diff line number Diff line change
@@ -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)
}
76 changes: 70 additions & 6 deletions src/context/ServiceInfoContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -91,6 +126,34 @@ const ServiceInfoProvider = ({ children }: React.PropsWithChildren<{}>) => {
)
const [connectionError, setConnectionError] = useState<Error>()

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),
Expand Down Expand Up @@ -262,6 +325,7 @@ export {
useReloadServiceInfo,
useDispatchServiceInfo,
useSessionConnectionError,
ServiceInfo,
Schedule,
StateFlag,
}
2 changes: 2 additions & 0 deletions src/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
7 changes: 7 additions & 0 deletions src/libs/JmWalletApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -465,6 +471,7 @@ export class JmApiError extends Error {
}

export {
getGetinfo,
postMakerStart,
getMakerStop,
getSession,
Expand Down

0 comments on commit 6b45718

Please sign in to comment.