Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: backend version based feature toggles #647

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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