diff --git a/common/actions/swap/actionTypes.ts b/common/actions/swap/actionTypes.ts index 75ef19f4..6f81de04 100644 --- a/common/actions/swap/actionTypes.ts +++ b/common/actions/swap/actionTypes.ts @@ -69,14 +69,14 @@ export interface BityOrderCreateRequestedSwapAction { }; } -interface BityOrderInput { +export interface BityOrderInput { amount: string; currency: string; reference: string; status: string; } -interface BityOrderOutput { +export interface BityOrderOutput { amount: string; currency: string; reference: string; diff --git a/common/sagas/config.ts b/common/sagas/config.ts index b97031a4..294713c7 100644 --- a/common/sagas/config.ts +++ b/common/sagas/config.ts @@ -30,7 +30,8 @@ import { changeNode, changeNodeIntent, setLatestBlock, - AddCustomNodeAction + AddCustomNodeAction, + ChangeNodeIntentAction } from 'actions/config'; import { showNotification } from 'actions/notifications'; import translate from 'translations'; @@ -100,13 +101,13 @@ export function* pollOfflineStatus(): SagaIterator { } // Fork our recurring API call, watch for the need to cancel. -function* handlePollOfflineStatus(): SagaIterator { +export function* handlePollOfflineStatus(): SagaIterator { const pollOfflineStatusTask = yield fork(pollOfflineStatus); yield take('CONFIG_STOP_POLL_OFFLINE_STATE'); yield cancel(pollOfflineStatusTask); } -function* handleTogglePollOfflineStatus(): SagaIterator { +export function* handleTogglePollOfflineStatus(): SagaIterator { const isForcedOffline = yield select(getForceOffline); if (isForcedOffline) { yield fork(handlePollOfflineStatus); @@ -117,14 +118,15 @@ function* handleTogglePollOfflineStatus(): SagaIterator { // @HACK For now we reload the app when doing a language swap to force non-connected // data to reload. Also the use of timeout to avoid using additional actions for now. -function* reload(): SagaIterator { +export function* reload(): SagaIterator { setTimeout(() => location.reload(), 250); } -function* handleNodeChangeIntent(action): SagaIterator { +export function* handleNodeChangeIntent( + action: ChangeNodeIntentAction +): SagaIterator { const currentNode = yield select(getNode); const currentConfig = yield select(getNodeConfig); - const currentWallet = yield select(getWalletInst); const currentNetwork = currentConfig.network; let actionConfig = NODES[action.payload]; @@ -173,6 +175,8 @@ function* handleNodeChangeIntent(action): SagaIterator { yield put(setLatestBlock(latestBlock)); yield put(changeNode(action.payload, actionConfig)); + const currentWallet = yield select(getWalletInst); + // if there's no wallet, do not reload as there's no component state to resync if (currentWallet && currentNetwork !== actionConfig.network) { yield call(reload); @@ -185,7 +189,7 @@ export function* switchToNewNode(action: AddCustomNodeAction): SagaIterator { } // unset web3 as the selected node if a non-web3 wallet has been selected -function* unsetWeb3Node(action): SagaIterator { +export function* unsetWeb3Node(action): SagaIterator { const node = yield select(getNode); const nodeConfig = yield select(getNodeConfig); const newWallet = action.payload; @@ -196,7 +200,11 @@ function* unsetWeb3Node(action): SagaIterator { } // switch back to a node with the same network as MetaMask/Mist - const equivalentNode = Object.keys(NODES) + yield put(changeNodeIntent(equivalentNodeOrDefault(nodeConfig))); +} + +export const equivalentNodeOrDefault = nodeConfig => { + const node = Object.keys(NODES) .filter(key => key !== 'web3') .reduce((found, key) => { const config = NODES[key]; @@ -210,12 +218,8 @@ function* unsetWeb3Node(action): SagaIterator { }, ''); // if no equivalent node was found, use the app default - const newNode = equivalentNode.length - ? equivalentNode - : configInitialState.nodeSelection; - - yield put(changeNodeIntent(newNode)); -} + return node.length ? node : configInitialState.nodeSelection; +}; export default function* configSaga(): SagaIterator { yield takeLatest( diff --git a/common/sagas/deterministicWallets.ts b/common/sagas/deterministicWallets.ts index 4bc4e637..b7ac41e4 100644 --- a/common/sagas/deterministicWallets.ts +++ b/common/sagas/deterministicWallets.ts @@ -25,7 +25,7 @@ import { getTokens } from 'selectors/wallet'; import translate from 'translations'; import { TokenValue } from 'libs/units'; -function* getDeterministicWallets( +export function* getDeterministicWallets( action: GetDeterministicWalletsAction ): SagaIterator { const { seed, dPath, publicKey, chainCode, limit, offset } = action.payload; @@ -64,7 +64,7 @@ function* getDeterministicWallets( } // Grab each wallet's main network token, and update it with it -function* updateWalletValues(): SagaIterator { +export function* updateWalletValues(): SagaIterator { const node: INode = yield select(getNodeLib); const wallets: DeterministicWalletData[] = yield select(getWallets); @@ -87,7 +87,7 @@ function* updateWalletValues(): SagaIterator { } // Grab the current desired token, and update the wallet with it -function* updateWalletTokenValues(): SagaIterator { +export function* updateWalletTokenValues(): SagaIterator { const desiredToken: string = yield select(getDesiredToken); if (!desiredToken) { return; diff --git a/common/sagas/notifications.ts b/common/sagas/notifications.ts index c21521da..e9686dc7 100644 --- a/common/sagas/notifications.ts +++ b/common/sagas/notifications.ts @@ -5,7 +5,9 @@ import { import { delay, SagaIterator } from 'redux-saga'; import { call, put, takeEvery } from 'redux-saga/effects'; -function* handleNotification(action: ShowNotificationAction): SagaIterator { +export function* handleNotification( + action: ShowNotificationAction +): SagaIterator { const { duration } = action.payload; // show forever if (duration === 0 || duration === Infinity) { diff --git a/common/sagas/swap/orders.ts b/common/sagas/swap/orders.ts index c78754c1..3b111d5f 100644 --- a/common/sagas/swap/orders.ts +++ b/common/sagas/swap/orders.ts @@ -31,7 +31,7 @@ import { export const getSwap = (state: AppState): SwapState => state.swap; const ONE_SECOND = 1000; const TEN_SECONDS = ONE_SECOND * 10; -const BITY_TIMEOUT_MESSAGE = ` +export const BITY_TIMEOUT_MESSAGE = ` Time has run out. If you have already sent, please wait 1 hour. If your order has not be processed after 1 hour, @@ -81,7 +81,7 @@ export function* pollBityOrderStatusSaga(): SagaIterator { } } -function* postBityOrderCreate( +export function* postBityOrderCreate( action: BityOrderCreateRequestedSwapAction ): SagaIterator { const payload = action.payload; diff --git a/common/sagas/swap/rates.ts b/common/sagas/swap/rates.ts index 640508b9..f5532ddc 100644 --- a/common/sagas/swap/rates.ts +++ b/common/sagas/swap/rates.ts @@ -20,7 +20,7 @@ export function* loadBityRates(): SagaIterator { } // Fork our recurring API call, watch for the need to cancel. -function* handleBityRates(): SagaIterator { +export function* handleBityRates(): SagaIterator { const loadBityRatesTask = yield fork(loadBityRates); yield take(TypeKeys.SWAP_STOP_LOAD_BITY_RATES); yield cancel(loadBityRatesTask); diff --git a/common/sagas/wallet.tsx b/common/sagas/wallet.tsx index f20b3e56..c3bd1e9c 100644 --- a/common/sagas/wallet.tsx +++ b/common/sagas/wallet.tsx @@ -39,7 +39,7 @@ import { getNetworkConfig, getNodeLib } from 'selectors/config'; import { getTokens, getWalletInst } from 'selectors/wallet'; import translate from 'translations'; -function* updateAccountBalance(): SagaIterator { +export function* updateAccountBalance(): SagaIterator { try { yield put(setBalancePending()); const wallet: null | IWallet = yield select(getWalletInst); @@ -56,7 +56,7 @@ function* updateAccountBalance(): SagaIterator { } } -function* updateTokenBalances(): SagaIterator { +export function* updateTokenBalances(): SagaIterator { try { const node: INode = yield select(getNodeLib); const wallet: null | IWallet = yield select(getWalletInst); @@ -87,7 +87,7 @@ function* updateTokenBalances(): SagaIterator { } } -function* updateBalances(): SagaIterator { +export function* updateBalances(): SagaIterator { yield fork(updateAccountBalance); yield fork(updateTokenBalances); } @@ -122,7 +122,7 @@ export function* unlockKeystore(action: UnlockKeystoreAction): SagaIterator { yield put(setWallet(wallet)); } -function* unlockMnemonic(action: UnlockMnemonicAction): SagaIterator { +export function* unlockMnemonic(action: UnlockMnemonicAction): SagaIterator { let wallet; const { phrase, pass, path, address } = action.payload; @@ -139,7 +139,7 @@ function* unlockMnemonic(action: UnlockMnemonicAction): SagaIterator { // inspired by v3: // https://github.com/kvhnuke/etherwallet/blob/417115b0ab4dd2033d9108a1a5c00652d38db68d/app/scripts/controllers/decryptWalletCtrl.js#L311 -function* unlockWeb3(): SagaIterator { +export function* unlockWeb3(): SagaIterator { const failMsg1 = 'Could not connect to MetaMask / Mist.'; const failMsg2 = 'No accounts found in MetaMask / Mist.'; const { web3 } = window as any; @@ -170,7 +170,7 @@ function* unlockWeb3(): SagaIterator { } } -function* broadcastTx(action: BroadcastTxRequestedAction): SagaIterator { +export function* broadcastTx(action: BroadcastTxRequestedAction): SagaIterator { const signedTx = action.payload.signedTx; try { const node: INode = yield select(getNodeLib); diff --git a/spec/sagas/__snapshots__/config.spec.ts.snap b/spec/sagas/__snapshots__/config.spec.ts.snap new file mode 100644 index 00000000..d67f1a3a --- /dev/null +++ b/spec/sagas/__snapshots__/config.spec.ts.snap @@ -0,0 +1,99 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`handleNodeChangeIntent* should race getCurrentBlock and delay 1`] = ` +Object { + "@@redux-saga/IO": true, + "RACE": Object { + "lb": Object { + "@@redux-saga/IO": true, + "CALL": Object { + "args": Array [], + "context": null, + "fn": [Function], + }, + }, + "to": Object { + "@@redux-saga/IO": true, + "CALL": Object { + "args": Array [ + 5000, + ], + "context": null, + "fn": [Function], + }, + }, + }, +} +`; + +exports[`handleNodeChangeIntent* should select getCustomNodeConfig and match race snapshot 1`] = ` +Object { + "@@redux-saga/IO": true, + "RACE": Object { + "lb": Object { + "@@redux-saga/IO": true, + "CALL": Object { + "args": Array [], + "context": null, + "fn": [Function], + }, + }, + "to": Object { + "@@redux-saga/IO": true, + "CALL": Object { + "args": Array [ + 5000, + ], + "context": null, + "fn": [Function], + }, + }, + }, +} +`; + +exports[`pollOfflineStatus* should put showNotification and put toggleOfflineConfig if !pingSucceeded && !isOffline 1`] = ` +Object { + "@@redux-saga/IO": true, + "PUT": Object { + "action": Object { + "payload": Object { + "duration": Infinity, + "id": 0.001, + "level": "danger", + "msg": "You’ve lost your connection to the network, check your internet + connection or try changing networks from the dropdown at the + top right of the page.", + }, + "type": "SHOW_NOTIFICATION", + }, + "channel": null, + }, +} +`; + +exports[`pollOfflineStatus* should race pingSucceeded and timeout 1`] = ` +Object { + "@@redux-saga/IO": true, + "RACE": Object { + "pingSucceeded": Object { + "@@redux-saga/IO": true, + "CALL": Object { + "args": Array [], + "context": null, + "fn": [Function], + }, + }, + "timeout": Object { + "@@redux-saga/IO": true, + "CALL": Object { + "args": Array [ + 5000, + ], + "context": null, + "fn": [Function], + }, + }, + }, +} +`; diff --git a/spec/sagas/__snapshots__/deterministicWallets.spec.ts.snap b/spec/sagas/__snapshots__/deterministicWallets.spec.ts.snap new file mode 100644 index 00000000..45760707 --- /dev/null +++ b/spec/sagas/__snapshots__/deterministicWallets.spec.ts.snap @@ -0,0 +1,216 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getDeterministicWallets* starting from publicKey & chainCode should match put snapshot 1`] = ` +Object { + "@@redux-saga/IO": true, + "PUT": Object { + "action": Object { + "payload": Array [ + Object { + "address": "0xa3fA2e024bf9964bb64bDf0afdCa8E3c374a6e41", + "index": 0, + "tokenValues": Object {}, + }, + Object { + "address": "0x9bbC617740413CDCc41fB69901b252e20fd6Ef61", + "index": 1, + "tokenValues": Object {}, + }, + Object { + "address": "0xE5297818aEee60385306b7087B024af20524C0FD", + "index": 2, + "tokenValues": Object {}, + }, + Object { + "address": "0x111789ac11B69fE7EbC307c908Efe7677fd347A2", + "index": 3, + "tokenValues": Object {}, + }, + Object { + "address": "0x315857914bEd907e0Cf33b5883e599dD6ACc45d2", + "index": 4, + "tokenValues": Object {}, + }, + Object { + "address": "0xEA316E5BFd9FDeCD81489929ae56DBE6ffaDD22C", + "index": 5, + "tokenValues": Object {}, + }, + Object { + "address": "0x5De67797cEeCeD707A6868bbE96331ED6f811F06", + "index": 6, + "tokenValues": Object {}, + }, + Object { + "address": "0xd0cb2Fe67f66DF4D6610E99dDE2ad1B0B5bf4054", + "index": 7, + "tokenValues": Object {}, + }, + Object { + "address": "0xC7D3A4101bf3Ac1A5aE898784F80D2F9A1E67B62", + "index": 8, + "tokenValues": Object {}, + }, + Object { + "address": "0x12228Ec03f6aFd6Dad66ceD9C8B9762b3080e319", + "index": 9, + "tokenValues": Object {}, + }, + ], + "type": "DW_SET_WALLETS", + }, + "channel": null, + }, +} +`; + +exports[`getDeterministicWallets* starting from seed should match put snapshot 1`] = ` +Object { + "@@redux-saga/IO": true, + "PUT": Object { + "action": Object { + "payload": Array [ + Object { + "address": "0x2e516E79F439469AA3DD43a93429ee45cBeb77Aa", + "index": 0, + "tokenValues": Object {}, + }, + Object { + "address": "0x4aE493688184D612aF70C0B04aA7f2C8eE03a1dE", + "index": 1, + "tokenValues": Object {}, + }, + Object { + "address": "0xbd208C37C747f784EFfA379aA417d867216Ace19", + "index": 2, + "tokenValues": Object {}, + }, + Object { + "address": "0x85FF91929287F6cdC801280EAf8fa899e22a02F6", + "index": 3, + "tokenValues": Object {}, + }, + Object { + "address": "0xDF5acC09CDf1f966CEc6eFAB3523804E1de65c7f", + "index": 4, + "tokenValues": Object {}, + }, + ], + "type": "DW_SET_WALLETS", + }, + "channel": null, + }, +} +`; + +exports[`updateWalletTokenValues* should match snapshot for put wallet1 update 1`] = ` +Object { + "done": false, + "value": Object { + "@@redux-saga/IO": true, + "PUT": Object { + "action": Object { + "payload": Object { + "address": "0x0", + "index": 0, + "tokenValues": Object { + "OMG": Object { + "decimal": 16, + "value": "64", + }, + }, + "value": "64", + }, + "type": "DW_UPDATE_WALLET", + }, + "channel": null, + }, + }, +} +`; + +exports[`updateWalletTokenValues* should match snapshot for put wallet2 update 1`] = ` +Object { + "done": false, + "value": Object { + "@@redux-saga/IO": true, + "PUT": Object { + "action": Object { + "payload": Object { + "address": "0x1", + "index": 1, + "tokenValues": Object { + "BAT": Object { + "decimal": 16, + "value": "64", + }, + "OMG": Object { + "decimal": 16, + "value": "c8", + }, + }, + "value": "64", + }, + "type": "DW_UPDATE_WALLET", + }, + "channel": null, + }, + }, +} +`; + +exports[`updateWalletTokenValues* should match snapshot of wallet token balances 1`] = ` +Object { + "@@redux-saga/IO": true, + "ALL": Array [ + Object { + "@@redux-saga/IO": true, + "CALL": Object { + "args": Array [ + "0x0", + Object { + "address": "0x2", + "decimal": 16, + "symbol": "OMG", + }, + ], + "context": RpcNode { + "client": RPCClient { + "batch": [Function], + "call": [Function], + "decorateRequest": [Function], + "endpoint": "", + "headers": Object {}, + }, + "requests": RPCRequests {}, + }, + "fn": [Function], + }, + }, + Object { + "@@redux-saga/IO": true, + "CALL": Object { + "args": Array [ + "0x1", + Object { + "address": "0x2", + "decimal": 16, + "symbol": "OMG", + }, + ], + "context": RpcNode { + "client": RPCClient { + "batch": [Function], + "call": [Function], + "decorateRequest": [Function], + "endpoint": "", + "headers": Object {}, + }, + "requests": RPCRequests {}, + }, + "fn": [Function], + }, + }, + ], +} +`; diff --git a/spec/sagas/__snapshots__/wallet.spec.tsx.snap b/spec/sagas/__snapshots__/wallet.spec.tsx.snap new file mode 100644 index 00000000..b5273d10 --- /dev/null +++ b/spec/sagas/__snapshots__/wallet.spec.tsx.snap @@ -0,0 +1,228 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`broadcastTx* should match put showNotifiction snapshot 1`] = ` +Object { + "@@redux-saga/IO": true, + "PUT": Object { + "action": Object { + "payload": Object { + "duration": 0, + "id": 0.001, + "level": "success", + "msg": , + }, + "type": "SHOW_NOTIFICATION", + }, + "channel": null, + }, +} +`; + +exports[`unlockKeystore* should match put setWallet snapshot 1`] = ` +Object { + "@@redux-saga/IO": true, + "PUT": Object { + "action": Object { + "payload": Wallet { + "_privKey": Object { + "data": Array [ + 139, + 203, + 68, + 86, + 239, + 3, + 86, + 206, + 6, + 44, + 133, + 124, + 239, + 221, + 62, + 209, + 186, + 180, + 84, + 50, + 207, + 118, + 214, + 213, + 52, + 8, + 153, + 207, + 208, + 247, + 2, + 232, + ], + "type": "Buffer", + }, + "_pubKey": undefined, + "signMessage": [Function], + "signRawTransaction": [Function], + "unlock": [Function], + }, + "type": "WALLET_SET", + }, + "channel": null, + }, +} +`; + +exports[`unlockMnemonic* should match put setWallet snapshot 1`] = ` +Object { + "@@redux-saga/IO": true, + "PUT": Object { + "action": Object { + "payload": Wallet { + "_privKey": Object { + "data": Array [ + 49, + 233, + 127, + 57, + 92, + 171, + 198, + 250, + 163, + 125, + 138, + 157, + 107, + 177, + 133, + 24, + 124, + 53, + 112, + 78, + 123, + 151, + 108, + 122, + 17, + 14, + 47, + 14, + 171, + 55, + 195, + 68, + ], + "type": "Buffer", + }, + "_pubKey": undefined, + "signMessage": [Function], + "signRawTransaction": [Function], + "unlock": [Function], + }, + "type": "WALLET_SET", + }, + "channel": null, + }, +} +`; + +exports[`unlockPrivateKey should match put setWallet snapshot 1`] = ` +Object { + "@@redux-saga/IO": true, + "PUT": Object { + "action": Object { + "payload": Wallet { + "_privKey": Object { + "data": Array [ + 49, + 233, + 127, + 57, + 92, + 171, + 198, + 250, + 163, + 125, + 138, + 157, + 107, + 177, + 133, + 24, + 124, + 53, + 112, + 78, + 123, + 151, + 108, + 122, + 17, + 14, + 47, + 14, + 171, + 55, + 195, + 68, + ], + "type": "Buffer", + }, + "_pubKey": undefined, + "signMessage": [Function], + "signRawTransaction": [Function], + "unlock": [Function], + }, + "type": "WALLET_SET", + }, + "channel": null, + }, +} +`; + +exports[`unlockWeb3* should match setWallet snapshot 1`] = ` +Object { + "@@redux-saga/IO": true, + "PUT": Object { + "action": Object { + "payload": Web3Wallet { + "address": "0xe2EdC95134bbD88443bc6D55b809F7d0C2f0C854", + "network": "ETH", + "web3": Object { + "eth": Object { + "getAccounts": [Function], + }, + "network": "1", + "version": Object { + "getNetwork": [Function], + }, + }, + }, + "type": "WALLET_SET", + }, + "channel": null, + }, +} +`; + +exports[`updateTokenBalances* should match put setTokenBalances snapshot 1`] = ` +Object { + "@@redux-saga/IO": true, + "PUT": Object { + "action": Object { + "payload": Object { + "BAT": "c8", + "OMG": "64", + }, + "type": "WALLET_SET_TOKEN_BALANCES", + }, + "channel": null, + }, +} +`; diff --git a/spec/sagas/config.spec.ts b/spec/sagas/config.spec.ts new file mode 100644 index 00000000..ba622fe3 --- /dev/null +++ b/spec/sagas/config.spec.ts @@ -0,0 +1,417 @@ +import { configuredStore } from 'store'; +import { delay } from 'redux-saga'; +import { call, cancel, fork, put, take, select } from 'redux-saga/effects'; +import { cloneableGenerator, createMockTask } from 'redux-saga/utils'; +import { + toggleOfflineConfig, + changeNode, + changeNodeIntent, + setLatestBlock +} from 'actions/config'; +import { + pollOfflineStatus, + handlePollOfflineStatus, + handleNodeChangeIntent, + handleTogglePollOfflineStatus, + reload, + unsetWeb3Node, + equivalentNodeOrDefault +} from 'sagas/config'; +import { NODES } from 'config/data'; +import { + getNode, + getNodeConfig, + getOffline, + getForceOffline, + getCustomNodeConfigs +} from 'selectors/config'; +import { INITIAL_STATE as configInitialState } from 'reducers/config'; +import { getWalletInst } from 'selectors/wallet'; +import { Web3Wallet } from 'libs/wallet'; +import { RPCNode } from 'libs/nodes'; +import { showNotification } from 'actions/notifications'; +import translate from 'translations'; + +// init module +configuredStore.getState(); + +describe('pollOfflineStatus*', () => { + const nav = navigator as any; + const doc = document as any; + const data = {} as any; + data.gen = cloneableGenerator(pollOfflineStatus)(); + const node = { + lib: { + ping: jest.fn() + } + }; + const isOffline = true; + const isForcedOffline = true; + const raceSuccess = { + pingSucceeded: true + }; + const raceFailure = { + pingSucceeded: false + }; + + let originalHidden; + let originalOnLine; + let originalRandom; + + beforeAll(() => { + // backup global config + originalHidden = document.hidden; + originalOnLine = navigator.onLine; + originalRandom = Math.random; + + // mock config + Object.defineProperty(document, 'hidden', { value: false, writable: true }); + Object.defineProperty(navigator, 'onLine', { value: true, writable: true }); + Math.random = () => 0.001; + }); + + afterAll(() => { + // restore global config + Object.defineProperty(document, 'hidden', { + value: originalHidden, + writable: false + }); + Object.defineProperty(navigator, 'onLine', { + value: originalOnLine, + writable: false + }); + Math.random = originalRandom; + }); + + it('should select getNodeConfig', () => { + expect(data.gen.next().value).toEqual(select(getNodeConfig)); + }); + + it('should select getOffline', () => { + expect(data.gen.next(node).value).toEqual(select(getOffline)); + }); + + it('should select getForceOffline', () => { + data.isOfflineClone = data.gen.clone(); + expect(data.gen.next(isOffline).value).toEqual(select(getForceOffline)); + }); + + it('should be done if isForcedOffline', () => { + data.clone1 = data.gen.clone(); + expect(data.clone1.next(isForcedOffline).done).toEqual(true); + }); + + it('should call delay if document is hidden', () => { + data.clone2 = data.gen.clone(); + doc.hidden = true; + + expect(data.clone2.next(!isForcedOffline).value).toEqual(call(delay, 1000)); + }); + + it('should race pingSucceeded and timeout', () => { + doc.hidden = false; + expect(data.gen.next(!isForcedOffline).value).toMatchSnapshot(); + }); + + it('should put showNotification and put toggleOfflineConfig if pingSucceeded && isOffline', () => { + expect(data.gen.next(raceSuccess).value).toEqual( + put( + showNotification( + 'success', + 'Your connection to the network has been restored!', + 3000 + ) + ) + ); + expect(data.gen.next().value).toEqual(put(toggleOfflineConfig())); + }); + + it('should put showNotification and put toggleOfflineConfig if !pingSucceeded && !isOffline', () => { + nav.onLine = !isOffline; + + data.isOfflineClone.next(!isOffline); + data.isOfflineClone.next(!isForcedOffline); + + data.clone3 = data.isOfflineClone.clone(); + + expect(data.isOfflineClone.next(raceFailure).value).toMatchSnapshot(); + expect(data.isOfflineClone.next().value).toEqual( + put(toggleOfflineConfig()) + ); + }); + + it('should call delay when neither case is true', () => { + expect(data.clone3.next(raceSuccess).value).toEqual(call(delay, 5000)); + }); +}); + +describe('handlePollOfflineStatus*', () => { + const gen = handlePollOfflineStatus(); + const mockTask = createMockTask(); + + it('should fork pollOffineStatus', () => { + const expectedForkYield = fork(pollOfflineStatus); + expect(gen.next().value).toEqual(expectedForkYield); + }); + + it('should take CONFIG_STOP_POLL_OFFLINE_STATE', () => { + expect(gen.next(mockTask).value).toEqual( + take('CONFIG_STOP_POLL_OFFLINE_STATE') + ); + }); + + it('should cancel pollOfflineStatus', () => { + expect(gen.next().value).toEqual(cancel(mockTask)); + }); +}); + +describe('handleTogglePollOfflineStatus*', () => { + const data = {} as any; + data.gen = cloneableGenerator(handleTogglePollOfflineStatus)(); + const isForcedOffline = true; + + it('should select getForceOffline', () => { + expect(data.gen.next().value).toEqual(select(getForceOffline)); + }); + + it('should fork handlePollOfflineStatus when isForcedOffline', () => { + data.clone = data.gen.clone(); + expect(data.gen.next(isForcedOffline).value).toEqual( + fork(handlePollOfflineStatus) + ); + }); + + it('should call handlePollOfflineStatus when !isForcedOffline', () => { + expect(data.clone.next(!isForcedOffline).value).toEqual( + call(handlePollOfflineStatus) + ); + }); + + it('should be done', () => { + expect(data.gen.next().done).toEqual(true); + expect(data.clone.next().done).toEqual(true); + }); +}); + +describe('handleNodeChangeIntent*', () => { + let originalRandom; + + // normal operation variables + const defaultNode = configInitialState.nodeSelection; + const defaultNodeConfig = NODES[defaultNode]; + const newNode = Object.keys(NODES).reduce( + (acc, cur) => (NODES[acc].network === defaultNodeConfig.network ? cur : acc) + ); + const newNodeConfig = NODES[newNode]; + const changeNodeIntentAction = changeNodeIntent(newNode); + const truthyWallet = true; + const latestBlock = '0xa'; + const raceSuccess = { + lb: latestBlock + }; + const raceFailure = { + to: true + }; + + const data = {} as any; + data.gen = cloneableGenerator(handleNodeChangeIntent)(changeNodeIntentAction); + + beforeAll(() => { + originalRandom = Math.random; + Math.random = () => 0.001; + }); + + afterAll(() => { + Math.random = originalRandom; + }); + + it('should select getNode', () => { + expect(data.gen.next().value).toEqual(select(getNode)); + }); + + it('should select nodeConfig', () => { + expect(data.gen.next(defaultNode).value).toEqual(select(getNodeConfig)); + }); + + it('should race getCurrentBlock and delay', () => { + expect(data.gen.next(defaultNodeConfig).value).toMatchSnapshot(); + }); + + it('should put showNotification and put changeNode if timeout', () => { + data.clone1 = data.gen.clone(); + expect(data.clone1.next(raceFailure).value).toEqual( + put(showNotification('danger', translate('ERROR_32'), 5000)) + ); + expect(data.clone1.next().value).toEqual( + put(changeNode(defaultNode, defaultNodeConfig)) + ); + expect(data.clone1.next().done).toEqual(true); + }); + + it('should put setLatestBlock', () => { + expect(data.gen.next(raceSuccess).value).toEqual( + put(setLatestBlock(latestBlock)) + ); + }); + + it('should put changeNode', () => { + expect(data.gen.next().value).toEqual( + put(changeNode(changeNodeIntentAction.payload, newNodeConfig)) + ); + }); + + it('should select getWalletInst', () => { + expect(data.gen.next().value).toEqual(select(getWalletInst)); + }); + + it('should call reload if wallet exists and network is new', () => { + data.clone2 = data.gen.clone(); + expect(data.clone2.next(truthyWallet).value).toEqual(call(reload)); + expect(data.clone2.next().done).toEqual(true); + }); + + it('should be done', () => { + expect(data.gen.next().done).toEqual(true); + }); + + // custom node variables + const customNodeConfigs = [ + { + name: 'name', + url: 'url', + port: 443, + network: 'network' + } + ]; + const customNodeIdFound = 'url:443'; + const customNodeIdNotFound = 'notFound'; + const customNodeAction = changeNodeIntent(customNodeIdFound); + const customNodeNotFoundAction = changeNodeIntent(customNodeIdNotFound); + data.customNode = handleNodeChangeIntent(customNodeAction); + data.customNodeNotFound = handleNodeChangeIntent(customNodeNotFoundAction); + + // test custom node + it('should select getCustomNodeConfig and match race snapshot', () => { + data.customNode.next(); + data.customNode.next(defaultNode); + expect(data.customNode.next(defaultNodeConfig).value).toEqual( + select(getCustomNodeConfigs) + ); + expect(data.customNode.next(customNodeConfigs).value).toMatchSnapshot(); + }); + + // test custom node not found + it('should select getCustomNodeConfig, put showNotification, put changeNode', () => { + data.customNodeNotFound.next(); + data.customNodeNotFound.next(defaultNode); + expect(data.customNodeNotFound.next(defaultNodeConfig).value).toEqual( + select(getCustomNodeConfigs) + ); + expect(data.customNodeNotFound.next(customNodeConfigs).value).toEqual( + put( + showNotification( + 'danger', + `Attempted to switch to unknown node '${ + customNodeNotFoundAction.payload + }'`, + 5000 + ) + ) + ); + expect(data.customNodeNotFound.next().value).toEqual( + put(changeNode(defaultNode, defaultNodeConfig)) + ); + expect(data.customNodeNotFound.next().done).toEqual(true); + }); +}); + +describe('unsetWeb3Node*', () => { + const fakeAction = {}; + const mockNode = 'web3'; + const mockNodeConfig = { network: 'ETH' }; + const gen = unsetWeb3Node(fakeAction); + + it('should select getNode', () => { + expect(gen.next().value).toEqual(select(getNode)); + }); + + it('should select getNodeConfig', () => { + expect(gen.next(mockNode).value).toEqual(select(getNodeConfig)); + }); + + it('should put changeNodeIntent', () => { + expect(gen.next(mockNodeConfig).value).toEqual( + put(changeNodeIntent(equivalentNodeOrDefault(mockNodeConfig))) + ); + }); + + it('should be done', () => { + expect(gen.next().done).toEqual(true); + }); + + it('should return early if node type is not web3', () => { + const gen1 = unsetWeb3Node({ payload: false }); + gen1.next(); //getNode + gen1.next('notWeb3'); //getNodeConfig + expect(gen1.next().done).toEqual(true); + }); + + it('should return early if wallet type is web3', () => { + const mockWeb3 = {}; + const mockAddress = '0x0'; + const mockNetwork = 'ETH'; + const mockWeb3Wallet = new Web3Wallet(mockWeb3, mockAddress, mockNetwork); + const gen2 = unsetWeb3Node({ payload: mockWeb3Wallet }); + gen2.next(); //getNode + gen2.next('web3'); //getNodeConfig + expect(gen2.next().done).toEqual(true); + }); +}); + +describe('equivalentNodeOrDefault', () => { + const originalNodeList = Object.keys(NODES); + const appDefaultNode = configInitialState.nodeSelection; + const mockNodeConfig = { + network: 'ETH', + service: 'fakeService', + lib: new RPCNode('fakeEndpoint'), + estimateGas: false + }; + + afterEach(() => { + Object.keys(NODES).forEach(node => { + if (originalNodeList.indexOf(node) === -1) { + delete NODES[node]; + } + }); + }); + + it('should return node with equivalent network', () => { + const node = equivalentNodeOrDefault({ + ...mockNodeConfig, + network: 'Kovan' + }); + expect(NODES[node].network).toEqual('Kovan'); + }); + + it('should return app default if no eqivalent is found', () => { + const node = equivalentNodeOrDefault({ + ...mockNodeConfig, + network: 'noEqivalentExists' + }); + expect(node).toEqual(appDefaultNode); + }); + + it('should ignore web3 from node list', () => { + NODES.web3 = { + ...mockNodeConfig, + network: 'uniqueToWeb3' + }; + + const node = equivalentNodeOrDefault({ + ...mockNodeConfig, + network: 'uniqueToWeb3' + }); + expect(node).toEqual(appDefaultNode); + }); +}); diff --git a/spec/sagas/deterministicWallets.spec.ts b/spec/sagas/deterministicWallets.spec.ts new file mode 100644 index 00000000..1f480718 --- /dev/null +++ b/spec/sagas/deterministicWallets.spec.ts @@ -0,0 +1,220 @@ +import { configuredStore } from 'store'; +import { INode } from 'libs/nodes/INode'; +import { cloneableGenerator } from 'redux-saga/utils'; +import { all, apply, fork, put, select } from 'redux-saga/effects'; +import RpcNode from 'libs/nodes/rpc'; +import { getDesiredToken, getWallets } from 'selectors/deterministicWallets'; +import { getTokens } from 'selectors/wallet'; +import { getNodeLib } from 'selectors/config'; +import * as dWalletActions from 'actions/deterministicWallets'; +import { Token } from 'config/data'; +import { + getDeterministicWallets, + updateWalletValues, + updateWalletTokenValues +} from 'sagas/deterministicWallets'; +import { TokenValue, Wei } from 'libs/units'; + +// init module +configuredStore.getState(); + +const genWalletData1 = () => ({ + index: 0, + address: '0x0', + value: TokenValue('100'), + tokenValues: { + OMG: { + value: TokenValue('100'), + decimal: 16 + } + } +}); + +const genWalletData2 = () => ({ + index: 1, + address: '0x1', + value: TokenValue('100'), + tokenValues: { + BAT: { + value: TokenValue('100'), + decimal: 16 + } + } +}); + +const genBalances = () => [Wei('100'), Wei('200')]; + +describe('getDeterministicWallets*', () => { + describe('starting from seed', () => { + const dWallet = { + seed: + '1ba4b713b9cf6f91e8e2eea015fc4e107452fa7d8ade32322207967371e5c0fb93289d4dde94ce13625ecc60279d211b6d677c67f54b9e97c7e68afc9ca1b5ea', + dPath: "m/44'/60'/0'/0" + }; + const action = dWalletActions.getDeterministicWallets(dWallet); + const gen = getDeterministicWallets(action); + + it('should match put snapshot', () => { + expect(gen.next().value).toMatchSnapshot(); + }); + + it('should fork updateWalletValues', () => { + expect(gen.next().value).toEqual(fork(updateWalletValues)); + }); + + it('should fork updateWalletTokenValues', () => { + expect(gen.next().value).toEqual(fork(updateWalletTokenValues)); + }); + }); + + describe('starting from publicKey & chainCode', () => { + const dWallet = { + dPath: '', + publicKey: + '02fcba7ecf41bc7e1be4ee122d9d22e3333671eb0a3a87b5cdf099d59874e1940f', + chainCode: + '180c998615636cd875aa70c71cfa6b7bf570187a56d8c6d054e60b644d13e9d3', + limit: 10, + offset: 0 + }; + + const action = dWalletActions.getDeterministicWallets(dWallet); + const gen = getDeterministicWallets(action); + + it('should match put snapshot', () => { + expect(gen.next().value).toMatchSnapshot(); + }); + + it('should fork updateWalletValues', () => { + expect(gen.next().value).toEqual(fork(updateWalletValues)); + }); + + it('should fork updateWalletTokenValues', () => { + expect(gen.next().value).toEqual(fork(updateWalletTokenValues)); + }); + }); +}); + +describe('updateWalletValues*', () => { + const walletData1 = genWalletData1(); + const walletData2 = genWalletData2(); + const wallets: dWalletActions.DeterministicWalletData[] = [ + walletData1, + walletData2 + ]; + const balances = genBalances(); + const node: INode = new RpcNode(''); + const gen = updateWalletValues(); + + it('should select getNodeLib', () => { + expect(gen.next().value).toEqual(select(getNodeLib)); + }); + + it('should select getWallets', () => { + expect(gen.next(node).value).toEqual(select(getWallets)); + }); + + it('should get balance for all wallets', () => { + expect(gen.next(wallets).value).toEqual( + all([ + apply(node, node.getBalance, [walletData1.address]), + apply(node, node.getBalance, [walletData2.address]) + ]) + ); + }); + + it('should put updateDeterministicWallet for wallet1', () => { + expect(gen.next(balances).value).toEqual( + put( + dWalletActions.updateDeterministicWallet({ + ...walletData1, + value: balances[0] + }) + ) + ); + }); + + it('should put updateDeterministicWallet for wallet2', () => { + expect(gen.next(balances).value).toEqual( + put( + dWalletActions.updateDeterministicWallet({ + ...walletData2, + value: balances[1] + }) + ) + ); + }); + + it('should be done', () => { + expect(gen.next().done).toEqual(true); + }); +}); + +describe('updateWalletTokenValues*', () => { + const walletData1 = genWalletData1(); + const walletData2 = genWalletData2(); + const wallets: dWalletActions.DeterministicWalletData[] = [ + walletData1, + walletData2 + ]; + const node: INode = new RpcNode(''); + const token1: Token = { + address: '0x2', + symbol: 'OMG', + decimal: 16 + }; + const token2: Token = { + address: '0x3', + symbol: 'BAT', + decimal: 16 + }; + const tokens = [token1, token2]; + const tokenBalances = [TokenValue('100'), TokenValue('200')]; + const desiredToken = 'OMG'; + const data = {} as any; + data.gen = cloneableGenerator(updateWalletTokenValues)(); + + it('should select getDesiredToken', () => { + expect(data.gen.next().value).toEqual(select(getDesiredToken)); + }); + + it('should return if desired token is falsey', () => { + data.clone1 = data.gen.clone(); + data.clone1.next(); + expect(data.clone1.next().done).toEqual(true); + }); + + it('should select getTokens', () => { + data.clone2 = data.gen.clone(); + expect(data.gen.next(desiredToken).value).toEqual(select(getTokens)); + }); + + it('should return if desired token is not amongst tokens', () => { + data.clone2.next('fakeDesiredToken'); + expect(data.clone2.next(tokens).done).toEqual(true); + }); + + it('should select getNodeLib', () => { + expect(data.gen.next(tokens).value).toEqual(select(getNodeLib)); + }); + + it('should select getWallets', () => { + expect(data.gen.next(node).value).toEqual(select(getWallets)); + }); + + it('should match snapshot of wallet token balances', () => { + expect(data.gen.next(wallets).value).toMatchSnapshot(); + }); + + it('should match snapshot for put wallet1 update', () => { + expect(data.gen.next(tokenBalances)).toMatchSnapshot(); + }); + + it('should match snapshot for put wallet2 update', () => { + expect(data.gen.next()).toMatchSnapshot(); + }); + + it('should be done', () => { + expect(data.gen.next().done).toEqual(true); + }); +}); diff --git a/spec/sagas/notifications.spec.ts b/spec/sagas/notifications.spec.ts new file mode 100644 index 00000000..37a05acc --- /dev/null +++ b/spec/sagas/notifications.spec.ts @@ -0,0 +1,44 @@ +import { delay } from 'redux-saga'; +import { call, put } from 'redux-saga/effects'; +import { handleNotification } from 'sagas/notifications'; +import { + ShowNotificationAction, + showNotification, + closeNotification +} from 'actions/notifications'; + +describe('handleNotification*', () => { + const level = 'success'; + const msg = 'msg'; + const duration = 10; + const notificationAction1: ShowNotificationAction = showNotification( + level, + msg, + duration + ); + const notificationAction2: ShowNotificationAction = showNotification( + level, + msg, + 0 + ); + const gen1 = handleNotification(notificationAction1); + const gen2 = handleNotification(notificationAction2); + + it('should call delay with duration', () => { + expect(gen1.next(notificationAction1).value).toEqual(call(delay, duration)); + }); + + it('should return when duration is zero', () => { + expect(gen2.next(notificationAction2).done).toEqual(true); + }); + + it('should put closeNotification', () => { + expect(gen1.next(notificationAction1).value).toEqual( + put(closeNotification(notificationAction1.payload)) + ); + }); + + it('should be done', () => { + expect(gen1.next().done).toEqual(true); + }); +}); diff --git a/spec/sagas/swap/orders.spec.ts b/spec/sagas/swap/orders.spec.ts new file mode 100644 index 00000000..5553e1d9 --- /dev/null +++ b/spec/sagas/swap/orders.spec.ts @@ -0,0 +1,371 @@ +import { showNotification } from 'actions/notifications'; +import { + bityOrderCreateFailedSwap, + bityOrderCreateSucceededSwap, + bityOrderCreateRequestedSwap, + BityOrderPostResponse, + BityOrderInput, + BityOrderOutput, + BityOrderResponse, + changeStepSwap, + orderStatusRequestedSwap, + orderStatusSucceededSwap, + orderTimeSwap, + startOrderTimerSwap, + startPollBityOrderStatus, + stopLoadBityRatesSwap, + stopPollBityOrderStatus +} from 'actions/swap'; +import { getOrderStatus, postOrder } from 'api/bity'; +import { + State as SwapState, + INITIAL_STATE as INITIAL_SWAP_STATE +} from 'reducers/swap'; +import { delay } from 'redux-saga'; +import { + call, + cancel, + cancelled, + fork, + put, + select, + take, + takeEvery +} from 'redux-saga/effects'; +import { + getSwap, + pollBityOrderStatus, + pollBityOrderStatusSaga, + postBityOrderCreate, + postBityOrderSaga, + bityTimeRemaining, + BITY_TIMEOUT_MESSAGE +} from 'sagas/swap/orders'; +import { cloneableGenerator, createMockTask } from 'redux-saga/utils'; + +const ONE_SECOND = 1000; +const TEN_SECONDS = ONE_SECOND * 10; +const ELEVEN_SECONDS = ONE_SECOND * 11; + +const orderInput: BityOrderInput = { + amount: 'amount', + currency: 'currency', + reference: 'reference', + status: 'status' +}; +const orderOutput: BityOrderOutput = { + amount: 'amount', + currency: 'currency', + reference: 'reference', + status: 'status' +}; + +describe('pollBityOrderStatus*', () => { + const data = {} as any; + data.gen = cloneableGenerator(pollBityOrderStatus)(); + const fakeSwap: SwapState = { + ...INITIAL_SWAP_STATE, + orderId: '1' + }; + const orderResponse: BityOrderResponse = { + input: orderInput, + output: orderOutput, + status: 'status' + }; + const cancelledSwap = 'CANC'; + const successStatus = { + error: null, + data: orderResponse + }; + const errorStatus = { + error: true, + msg: 'error message' + }; + let random; + + beforeAll(() => { + random = Math.random; + Math.random = () => 0.001; + }); + + afterAll(() => { + Math.random = random; + }); + + it('should select getSwap', () => { + expect(data.gen.next().value).toEqual(select(getSwap)); + }); + + it('should put orderStatusRequestedSwap', () => { + expect(data.gen.next(fakeSwap).value).toEqual( + put(orderStatusRequestedSwap()) + ); + }); + + it('should call getOrderStatus with swap.orderId', () => { + expect(data.gen.next().value).toEqual( + call(getOrderStatus, fakeSwap.orderId) + ); + }); + + it('should put showNotfication on error', () => { + data.clone = data.gen.clone(); + expect(data.clone.next(errorStatus).value).toEqual( + put( + showNotification( + 'danger', + `Bity Error: ${errorStatus.msg}`, + TEN_SECONDS + ) + ) + ); + }); + + it('should put orderStatusSucceededSwap', () => { + expect(data.gen.next(successStatus).value).toEqual( + put(orderStatusSucceededSwap(successStatus.data)) + ); + }); + + it('should call delay for 5 seconds', () => { + expect(data.gen.next().value).toEqual(call(delay, ONE_SECOND * 5)); + }); + + it('should select getSwap', () => { + expect(data.gen.next().value).toEqual(select(getSwap)); + }); + + it('should break loop if swap is cancelled', () => { + data.clone2 = data.gen.clone(); + expect(data.clone2.next(cancelledSwap).value).toEqual(cancelled()); + expect(data.clone2.next().done).toEqual(true); + }); + + it('should restart loop', () => { + expect(data.gen.next(fakeSwap).value).toEqual( + put(orderStatusRequestedSwap()) + ); + }); +}); + +describe('pollBityOrderStatusSaga*', () => { + const data = {} as any; + data.gen = cloneableGenerator(pollBityOrderStatusSaga)(); + const mockedTask = createMockTask(); + + it('should take SWAP_START_POLL_BITY_ORDER_STATUS', () => { + expect(data.gen.next().value).toEqual( + take('SWAP_START_POLL_BITY_ORDER_STATUS') + ); + }); + + it('should be done if order status is false', () => { + data.clone = data.gen.clone(); + expect(data.clone.next(false).done).toEqual(true); + }); + + it('should fork pollBityOrderStatus', () => { + expect(data.gen.next(true).value).toEqual(fork(pollBityOrderStatus)); + }); + + it('should take SWAP_STOP_POLL_BITY_ORDER_STATUS', () => { + expect(data.gen.next(mockedTask).value).toEqual( + take('SWAP_STOP_POLL_BITY_ORDER_STATUS') + ); + }); + + it('should cancel pollBityOrderStatusTask', () => { + expect(data.gen.next().value).toEqual(cancel(mockedTask)); + }); +}); + +describe('postBityOrderCreate*', () => { + const amount = 100; + const destinationAddress = '0x0'; + const pair = 'BTC_ETH'; + const action = bityOrderCreateRequestedSwap(amount, destinationAddress, pair); + const orderResp: BityOrderPostResponse = { + payment_address: '0x0', + status: 'status', + input: orderInput, + output: orderOutput, + timestamp_created: 'timestamp_created', + validFor: 10, + id: '0' + }; + const successOrder = { error: false, data: orderResp }; + const errorOrder = { error: true, msg: 'error msg' }; + const connectionErrMsg = + 'Connection Error. Please check the developer console for more details and/or contact support'; + + const data = {} as any; + data.gen = cloneableGenerator(postBityOrderCreate)(action); + + let random; + beforeAll(() => { + random = Math.random; + Math.random = () => 0.001; + }); + + afterAll(() => { + Math.random = random; + }); + + it('should put stopLoadBityRatesSwap', () => { + expect(data.gen.next().value).toEqual(put(stopLoadBityRatesSwap())); + }); + + it('should call postOrder', () => { + data.clone1 = data.gen.clone(); + expect(data.gen.next().value).toEqual( + call(postOrder, amount, destinationAddress, action.payload.mode, pair) + ); + }); + + it('should put bityOrderCreateSucceededSwap', () => { + data.clone2 = data.gen.clone(); + expect(data.gen.next(successOrder).value).toEqual( + put(bityOrderCreateSucceededSwap(successOrder.data)) + ); + }); + + it('should put changeStepSwap', () => { + expect(data.gen.next().value).toEqual(put(changeStepSwap(3))); + }); + + it('should put startOrderTimerSwap', () => { + expect(data.gen.next().value).toEqual(put(startOrderTimerSwap())); + }); + + it('should put startPollBityOrderStatus', () => { + expect(data.gen.next().value).toEqual(put(startPollBityOrderStatus())); + }); + + // failure modes + it('should handle a connection exeception', () => { + expect(data.clone1.throw().value).toEqual( + put(showNotification('danger', connectionErrMsg, TEN_SECONDS)) + ); + expect(data.clone1.next().value).toEqual(put(bityOrderCreateFailedSwap())); + expect(data.clone1.next().done).toEqual(true); + }); + + it('should handle an errored order', () => { + expect(data.clone2.next(errorOrder).value).toEqual( + put( + showNotification('danger', `Bity Error: ${errorOrder.msg}`, TEN_SECONDS) + ) + ); + expect(data.clone2.next().value).toEqual(put(bityOrderCreateFailedSwap())); + }); +}); + +describe('postBityOrderSaga*', () => { + const gen = postBityOrderSaga(); + + it('should takeEvery SWAP_ORDER_CREATE_REQUESTED', () => { + expect(gen.next().value).toEqual( + takeEvery('SWAP_ORDER_CREATE_REQUESTED', postBityOrderCreate) + ); + }); +}); + +describe('bityTimeRemaining*', () => { + const orderTime = new Date().toISOString(); + const orderTimeExpired = new Date().getTime() - ELEVEN_SECONDS; + const swapValidFor = 10; //seconds + const swapOrder = { + ...INITIAL_SWAP_STATE, + orderTimestampCreatedISOString: orderTime, + validFor: swapValidFor + }; + const swapOrderExpired = { + ...INITIAL_SWAP_STATE, + orderTimestampCreatedISOString: new Date(orderTimeExpired).toISOString(), + validFor: swapValidFor + }; + let random; + + const data = {} as any; + data.gen = cloneableGenerator(bityTimeRemaining)(); + + beforeAll(() => { + random = Math.random; + Math.random = () => 0.001; + }); + + afterAll(() => { + Math.random = random; + }); + + it('should take SWAP_ORDER_START_TIMER', () => { + expect(data.gen.next().value).toEqual(take('SWAP_ORDER_START_TIMER')); + }); + + it('should break while loop when take SWAP_ORDER_START_TIMER is false', () => { + data.clone1 = data.gen.clone(); + expect(data.clone1.next().done).toEqual(true); + }); + + it('should call delay of one second', () => { + expect(data.gen.next(true).value).toEqual(call(delay, ONE_SECOND)); + }); + + it('should select getSwap', () => { + expect(data.gen.next().value).toEqual(select(getSwap)); + }); + + it('should handle if isValidUntil.isAfter(now)', () => { + data.clone2 = data.gen.clone(); + const result = data.clone2.next(swapOrder).value; + expect(result).toHaveProperty('PUT'); + expect(result.PUT.action.type).toEqual('SWAP_ORDER_TIME'); + expect(result.PUT.action.payload).toBeGreaterThan(0); + }); + + it('should handle an OPEN order state', () => { + const openOrder = { ...swapOrderExpired, orderStatus: 'OPEN' }; + data.OPEN = data.gen.clone(); + expect(data.OPEN.next(openOrder).value).toEqual(put(orderTimeSwap(0))); + expect(data.OPEN.next().value).toEqual(put(stopPollBityOrderStatus())); + expect(data.OPEN.next().value).toEqual( + put({ type: 'SWAP_STOP_LOAD_BITY_RATES' }) + ); + expect(data.OPEN.next().value).toEqual( + put(showNotification('danger', BITY_TIMEOUT_MESSAGE, Infinity)) + ); + }); + + it('should handle a CANC order state', () => { + const cancOrder = { ...swapOrderExpired, orderStatus: 'CANC' }; + data.CANC = data.gen.clone(); + expect(data.CANC.next(cancOrder).value).toEqual( + put(stopPollBityOrderStatus()) + ); + expect(data.CANC.next().value).toEqual( + put({ type: 'SWAP_STOP_LOAD_BITY_RATES' }) + ); + expect(data.CANC.next().value).toEqual( + put(showNotification('danger', BITY_TIMEOUT_MESSAGE, Infinity)) + ); + }); + + it('should handle a RCVE order state', () => { + const rcveOrder = { ...swapOrderExpired, orderStatus: 'RCVE' }; + data.RCVE = data.gen.clone(); + expect(data.RCVE.next(rcveOrder).value).toEqual( + put(showNotification('warning', BITY_TIMEOUT_MESSAGE, Infinity)) + ); + }); + + it('should handle a FILL order state', () => { + const fillOrder = { ...swapOrderExpired, orderStatus: 'FILL' }; + data.FILL = data.gen.clone(); + expect(data.FILL.next(fillOrder).value).toEqual( + put(stopPollBityOrderStatus()) + ); + expect(data.FILL.next().value).toEqual( + put({ type: 'SWAP_STOP_LOAD_BITY_RATES' }) + ); + }); +}); diff --git a/spec/sagas/swap/rates.spec.ts b/spec/sagas/swap/rates.spec.ts new file mode 100644 index 00000000..d84f2d7f --- /dev/null +++ b/spec/sagas/swap/rates.spec.ts @@ -0,0 +1,86 @@ +import { showNotification } from 'actions/notifications'; +import { loadBityRatesSucceededSwap } from 'actions/swap'; +import { getAllRates } from 'api/bity'; +import { delay } from 'redux-saga'; +import { call, cancel, fork, put, take, takeLatest } from 'redux-saga/effects'; +import { createMockTask } from 'redux-saga/utils'; +import { Pairs } from 'actions/swap/actionTypes'; +import { + loadBityRates, + handleBityRates, + getBityRatesSaga +} from 'sagas/swap/rates'; + +describe('loadBityRates*', () => { + const gen1 = loadBityRates(); + const gen2 = loadBityRates(); + const rates: Pairs = { + ETHBTC: 1, + ETHREP: 2, + BTCETH: 3, + BTCREP: 4 + }; + let random; + + beforeAll(() => { + random = Math.random; + Math.random = () => 0.001; + }); + + afterAll(() => { + Math.random = random; + }); + + it('should call getAllRates', () => { + expect(gen1.next().value).toEqual(call(getAllRates)); + }); + + it('should put loadBityRatesSucceededSwap', () => { + expect(gen1.next(rates).value).toEqual( + put(loadBityRatesSucceededSwap(rates)) + ); + }); + + it('should call delay for 5 seconds', () => { + expect(gen1.next().value).toEqual(call(delay, 30000)); + }); + + it('should handle an exception', () => { + const err = { message: 'error' }; + gen2.next(); + expect((gen2 as any).throw(err).value).toEqual( + put(showNotification('danger', err.message)) + ); + }); +}); + +describe('handleBityRates*', () => { + const gen = handleBityRates(); + const mockTask = createMockTask(); + + it('should fork loadBityRates', () => { + expect(gen.next().value).toEqual(fork(loadBityRates)); + }); + + it('should take SWAP_STOP_LOAD_BITY_RATES', () => { + expect(gen.next(mockTask).value).toEqual(take('SWAP_STOP_LOAD_BITY_RATES')); + }); + + it('should cancel loadBityRatesTask', () => { + expect(gen.next().value).toEqual(cancel(mockTask)); + }); + + it('should be done', () => { + expect(gen.next().done).toEqual(true); + }); +}); + +describe('getBityRatesSaga*', () => { + const gen = getBityRatesSaga(); + + it('should takeLatest SWAP_LOAD_RATES_REQUESTED', () => { + expect(gen.next().value).toEqual( + takeLatest('SWAP_LOAD_BITY_RATES_REQUESTED', handleBityRates) + ); + }); +}); diff --git a/spec/sagas/wallet.spec.tsx b/spec/sagas/wallet.spec.tsx new file mode 100644 index 00000000..92532dae --- /dev/null +++ b/spec/sagas/wallet.spec.tsx @@ -0,0 +1,327 @@ +import { configuredStore } from 'store'; +import RpcNode from 'libs/nodes/rpc'; +import { + broadcastTxSucceded, + setBalanceFullfilled, + setBalancePending, + unlockPrivateKey as unlockPrivateKeyActionGen, + unlockKeystore as unlockKeystoreActionGen, + unlockMnemonic as unlockMnemonicActionGen, + broadcastTx as broadcastTxActionGen +} from 'actions/wallet'; +import { Wei } from 'libs/units'; +import { changeNodeIntent } from 'actions/config'; +import { INode } from 'libs/nodes/INode'; +import { initWeb3Node, Token } from 'config/data'; +import { apply, call, cps, fork, put, select } from 'redux-saga/effects'; +import { getNetworkConfig, getNodeLib } from 'selectors/config'; +import { getTokens, getWalletInst } from 'selectors/wallet'; +import { + updateAccountBalance, + updateTokenBalances, + updateBalances, + unlockPrivateKey, + unlockKeystore, + unlockMnemonic, + unlockWeb3, + broadcastTx +} from 'sagas/wallet'; +import { PrivKeyWallet } from 'libs/wallet/non-deterministic'; + +// init module +configuredStore.getState(); + +const pkey = '31e97f395cabc6faa37d8a9d6bb185187c35704e7b976c7a110e2f0eab37c344'; +const wallet = PrivKeyWallet(Buffer.from(pkey, 'hex')); +const address = '0xe2EdC95134bbD88443bc6D55b809F7d0C2f0C854'; +const balance = Wei('100'); +const node: INode = new RpcNode(''); + +const token1: Token = { + address: '0x2', + symbol: 'OMG', + decimal: 16 +}; +const token2: Token = { + address: '0x3', + symbol: 'BAT', + decimal: 16 +}; +const tokens = [token1, token2]; +const balances = [Wei('100'), Wei('200')]; + +const utcKeystore = { + version: 3, + id: 'cb788af4-993d-43ad-851b-0d2031e52c61', + address: '25a24679f35e447f778cf54a3823facf39904a63', + Crypto: { + ciphertext: + '4193915c560835d00b2b9ff5dd20f3e13793b2a3ca8a97df649286063f27f707', + cipherparams: { + iv: 'dccb8c009b11d1c6226ba19b557dce4c' + }, + cipher: 'aes-128-ctr', + kdf: 'scrypt', + kdfparams: { + dklen: 32, + salt: '037a53e520f2d00fb70f02f39b31b77374de9e0e1d35fd7cbe9c8a8b21d6b0ab', + n: 1024, + r: 8, + p: 1 + }, + mac: '774fbe4bf35e7e28df15cd6c3546e74ce6608e9ab68a88d50227858a3b05769a' + } +}; + +// necessary so we can later inject a mocked web3 to the window +declare var window: any; + +describe('updateAccountBalance*', () => { + const gen1 = updateAccountBalance(); + const gen2 = updateAccountBalance(); + + it('should put setBalancePending', () => { + expect(gen1.next().value).toEqual(put(setBalancePending())); + }); + + it('should select getWalletInst', () => { + expect(gen1.next().value).toEqual(select(getWalletInst)); + }); + + it('should return if wallet is falsey', () => { + gen2.next(); + gen2.next(); + gen2.next(null); + expect(gen2.next().done).toBe(true); + }); + + it('should select getNodeLib', () => { + expect(gen1.next(wallet).value).toEqual(select(getNodeLib)); + }); + + it('should apply wallet.getAddressString', () => { + expect(gen1.next(node).value).toEqual( + apply(wallet, wallet.getAddressString) + ); + }); + + it('should apply node.getBalance', () => { + expect(gen1.next(address).value).toEqual( + apply(node, node.getBalance, [address]) + ); + }); + + it('should put setBalanceFulfilled', () => { + expect(gen1.next(balance).value).toEqual( + put(setBalanceFullfilled(balance)) + ); + }); + + it('should be done', () => { + expect(gen1.next().done).toEqual(true); + }); +}); + +describe('updateTokenBalances*', () => { + const gen1 = updateTokenBalances(); + const gen2 = updateTokenBalances(); + const gen3 = updateTokenBalances(); + + it('should select getNodeLib', () => { + expect(gen1.next().value).toEqual(select(getNodeLib)); + }); + + it('should select getWalletInst', () => { + expect(gen1.next(node).value).toEqual(select(getWalletInst)); + }); + + it('should select getTokens', () => { + expect(gen1.next(wallet).value).toEqual(select(getTokens)); + }); + + it('should return if wallet is falsey', () => { + gen2.next(); + gen2.next(node); + gen2.next(null); + expect(gen2.next().done).toEqual(true); + }); + + it('should return if node is falsey', () => { + gen3.next(); + gen3.next(null); + gen3.next(wallet); + expect(gen3.next().done).toEqual(true); + }); + + it('should apply wallet.getAddressString', () => { + expect(gen1.next(tokens).value).toEqual( + apply(wallet, wallet.getAddressString) + ); + }); + + it('should apply node.getTokenBalances', () => { + expect(gen1.next(address).value).toEqual( + apply(node, node.getTokenBalances, [address, tokens]) + ); + }); + + it('should match put setTokenBalances snapshot', () => { + expect(gen1.next(balances).value).toMatchSnapshot(); + }); + + it('should be done', () => { + expect(gen1.next().done).toEqual(true); + }); +}); + +describe('updateBalances*', () => { + const gen = updateBalances(); + + it('should fork updateAccountBalance', () => { + expect(gen.next().value).toEqual(fork(updateAccountBalance)); + }); + + it('should fork updateTokenBalances', () => { + expect(gen.next().value).toEqual(fork(updateTokenBalances)); + }); + + it('should be done', () => { + expect(gen.next().done).toEqual(true); + }); +}); + +describe('unlockPrivateKey', () => { + const value = { + key: pkey, + password: '' + }; + const action = unlockPrivateKeyActionGen(value); + const gen = unlockPrivateKey(action); + + it('should match put setWallet snapshot', () => { + expect(gen.next().value).toMatchSnapshot(); + }); + + it('should be done', () => { + expect(gen.next().done).toEqual(true); + }); +}); + +describe('unlockKeystore*', () => { + const action = unlockKeystoreActionGen({ + file: JSON.stringify(utcKeystore), + password: 'testtesttest' + }); + const gen = unlockKeystore(action); + + it('should match put setWallet snapshot', () => { + expect(gen.next().value).toMatchSnapshot(); + }); + + it('should be done', () => { + expect(gen.next().done).toEqual(true); + }); +}); + +describe('unlockMnemonic*', () => { + const action = unlockMnemonicActionGen({ + phrase: + 'first catalog away faculty jelly now life kingdom pigeon raise gain accident', + pass: '', + path: "m/44'/60'/0'/0/8", + address: '0xe2EdC95134bbD88443bc6D55b809F7d0C2f0C854' + }); + const gen = unlockMnemonic(action); + + it('should match put setWallet snapshot', () => { + expect(gen.next().value).toMatchSnapshot(); + }); + + it('should be done', () => { + expect(gen.next().done).toEqual(true); + }); +}); + +describe('unlockWeb3*', () => { + const gen = unlockWeb3(); + const accounts = [address]; + + window.web3 = { + eth: { + getAccounts: jest.fn(cb => cb(undefined, accounts)) + }, + version: { + getNetwork: jest.fn(cb => cb(undefined, '1')) + }, + network: '1' + }; + + beforeAll(async done => { + await initWeb3Node(); + done(); + }); + + afterAll(() => { + delete window.web3; + }); + + it('should call initWeb3Node', () => { + expect(gen.next().value).toEqual(call(initWeb3Node)); + }); + + it('should cps web3.eth.getAccounts', () => { + expect(gen.next().value).toEqual(cps(window.web3.eth.getAccounts)); + }); + + it('should put changeNodeIntent', () => { + expect(gen.next(accounts).value).toEqual(put(changeNodeIntent('web3'))); + }); + + it('should match setWallet snapshot', () => { + expect(gen.next().value).toMatchSnapshot(); + }); +}); + +describe('broadcastTx*', () => { + const signedTx = 'signedTx'; + const txHash = 'txHash'; + const action = broadcastTxActionGen(signedTx); + const gen = broadcastTx(action); + const networkConfig = { + blockExplorer: 'foo' + }; + let random; + + beforeAll(() => { + random = Math.random; + Math.random = jest.fn(() => 0.001); + }); + + afterAll(() => { + Math.random = random; + }); + + it('should select getNodeLib', () => { + expect(gen.next().value).toEqual(select(getNodeLib)); + }); + + it('should select getNetworkConfig', () => { + expect(gen.next(node).value).toEqual(select(getNetworkConfig)); + }); + + it('should apply node.sendRawTx', () => { + expect(gen.next(networkConfig).value).toEqual( + apply(node, node.sendRawTx, [signedTx]) + ); + }); + + it('should match put showNotifiction snapshot', () => { + expect(gen.next(txHash).value).toMatchSnapshot(); + }); + + it('should put broadcastTxSucceded', () => { + expect(gen.next().value).toEqual( + put(broadcastTxSucceded(txHash, signedTx)) + ); + }); +});