diff --git a/packages/fether-react/package.json b/packages/fether-react/package.json index 8915aad56a967c73f853483f1257d5d91b20c8e1..8cf42e8c59b24e1754c27d9558ed7dc0575d6edf 100644 --- a/packages/fether-react/package.json +++ b/packages/fether-react/package.json @@ -31,7 +31,7 @@ "start": "npm-run-all -p start-*", "start-css": "npm run build-css -- --watch --recursive", "start-js": "react-app-rewired start", - "test": "echo Skipped." + "test": "react-app-rewired test --env=jsdom" }, "dependencies": { "@parity/api": "^2.1.22", @@ -56,6 +56,7 @@ }, "devDependencies": { "babel-plugin-transform-decorators-legacy": "^1.3.5", + "capitalize": "^1.0.0", "node-sass": "^4.9.0", "node-sass-chokidar": "^1.2.2", "npm-run-all": "^4.1.2", diff --git a/packages/fether-react/src/App/App.test.js b/packages/fether-react/src/App/App.test.js deleted file mode 100644 index a858f91e19731f54dcc3da1f5be8aa8c148c0b7e..0000000000000000000000000000000000000000 --- a/packages/fether-react/src/App/App.test.js +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2015-2018 Parity Technologies (UK) Ltd. -// This file is part of Parity. -// -// SPDX-License-Identifier: BSD-3-Clause - -/* eslint-env mocha */ - -import React from 'react'; -import ReactDOM from 'react-dom'; -import App from './App'; - -it('renders without crashing', () => { - const div = document.createElement('div'); - ReactDOM.render(, div); - ReactDOM.unmountComponentAtNode(div); -}); diff --git a/packages/fether-react/src/Send/Send.js b/packages/fether-react/src/Send/Send.js index 41efc09a8536f5916004e45b19938f11ee31d916..f460e2724f0bd28f221bbef50c06739cb7408c07 100644 --- a/packages/fether-react/src/Send/Send.js +++ b/packages/fether-react/src/Send/Send.js @@ -16,12 +16,12 @@ import TxForm from './TxForm'; class Send extends Component { render () { const { - sendStore: { token } + sendStore: { tokenAddress } } = this.props; // We only show then Send components if we have already selected a token to // send. - if (!token) { + if (!tokenAddress) { return ; } diff --git a/packages/fether-react/src/Send/Signer/Signer.js b/packages/fether-react/src/Send/Signer/Signer.js index 3793ed5dcc3e3ad41828d0a2ac8bb9463e90a69d..c91324f215b41abed11d39b66fd1f35b99d9da29 100644 --- a/packages/fether-react/src/Send/Signer/Signer.js +++ b/packages/fether-react/src/Send/Signer/Signer.js @@ -12,7 +12,7 @@ import ReactTooltip from 'react-tooltip'; import TokenBalance from '../../Tokens/TokensList/TokenBalance'; -@inject('sendStore') +@inject('sendStore', 'tokensStore') @observer class Signer extends Component { state = { @@ -58,9 +58,11 @@ class Signer extends Component { render () { const { - sendStore: { token, tx } + sendStore: { tokenAddress, tx }, + tokensStore } = this.props; const { error, isSending, password } = this.state; + const token = tokensStore.tokens[tokenAddress]; return (
diff --git a/packages/fether-react/src/Send/TxForm/TxForm.js b/packages/fether-react/src/Send/TxForm/TxForm.js index 0ce5df404d62c2b34be04a5dcfe1e3bc9ac3c341..14f6283842375f5df7469b6e86bc5f968a909cfe 100644 --- a/packages/fether-react/src/Send/TxForm/TxForm.js +++ b/packages/fether-react/src/Send/TxForm/TxForm.js @@ -18,8 +18,11 @@ import withBalance from '../../utils/withBalance'; const MAX_GAS_PRICE = 40; // In Gwei const MIN_GAS_PRICE = 3; // Safelow gas price from GasStation, in Gwei -@inject('sendStore') -@withBalance(({ sendStore: { token } }) => token) +@inject('sendStore', 'tokensStore') +@withBalance( + ({ sendStore: { tokenAddress }, tokensStore }) => + tokensStore.tokens[tokenAddress] +) @observer class Send extends Component { state = { @@ -106,10 +109,12 @@ class Send extends Component { render () { const { - sendStore: { token } + sendStore: { tokenAddress }, + tokensStore } = this.props; const { amount, gasPrice, maxAmount, to } = this.state; + const token = tokensStore.tokens[tokenAddress]; const error = this.hasError(); return ( diff --git a/packages/fether-react/src/stores/createAccountStore.js b/packages/fether-react/src/stores/createAccountStore.js index 0c605a2fc443312232dd4b5961734e170178b0f9..c8bc77fb9d12fe7794f0364f47705fd7fe2e6ed1 100644 --- a/packages/fether-react/src/stores/createAccountStore.js +++ b/packages/fether-react/src/stores/createAccountStore.js @@ -7,7 +7,7 @@ import { action, observable } from 'mobx'; import parityStore from './parityStore'; -class CreateAccountStore { +export class CreateAccountStore { @observable address = null; @observable isImport = false; // Are we creating a new account, or importing via phrase? @observable name = ''; // Account name diff --git a/packages/fether-react/src/stores/createAccountStore.spec.js b/packages/fether-react/src/stores/createAccountStore.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..b977bbfe981fdff6a9de3ec29379d73944327227 --- /dev/null +++ b/packages/fether-react/src/stores/createAccountStore.spec.js @@ -0,0 +1,78 @@ +// Copyright 2015-2018 Parity Technologies (UK) Ltd. +// This file is part of Parity. +// +// SPDX-License-Identifier: BSD-3-Clause + +/* eslint-env jest */ + +import { CreateAccountStore } from './createAccountStore'; +import parityStore from './parityStore'; +import * as storeTests from '../utils/testHelpers/storeTests'; + +jest.mock('./parityStore', () => ({ + api: { + parity: { + generateSecretPhrase: jest.fn(() => Promise.resolve('foo')), + newAccountFromPhrase: jest.fn(() => Promise.resolve()), + phraseToAddress: jest.fn(() => Promise.resolve('0x123')), + setAccountName: jest.fn(() => Promise.resolve()), + setAccountMeta: jest.fn(() => Promise.resolve()) + } + } +})); + +let createAccountStore; // Will hold the newly created instance of createAccountStore in each test +beforeEach(() => { + createAccountStore = new CreateAccountStore(); +}); + +describe('method clear', () => { + test('should call setAddress and setName', () => { + createAccountStore.setAddress = jest.fn(); + createAccountStore.setName = jest.fn(); + createAccountStore.clear(); + expect(createAccountStore.setAddress).toHaveBeenCalledWith(null); + expect(createAccountStore.setName).toHaveBeenCalledWith(''); + }); +}); + +describe('method generateNewAccount', () => { + test('should call api.parity.generateSecretPhrase', () => { + createAccountStore.generateNewAccount(); + expect(parityStore.api.parity.generateSecretPhrase).toHaveBeenCalledWith(); + }); +}); + +describe('method saveAccountToParity', () => { + beforeAll(() => { + createAccountStore.setPhrase('foo'); + createAccountStore.saveAccountToParity('bar'); + }); + + test('should call api.parity.newAccountFromPhrase', () => { + expect(parityStore.api.parity.newAccountFromPhrase).toHaveBeenCalledWith( + 'foo', + 'bar' + ); + }); + + test('should call api.parity.setAccountName', () => { + expect(parityStore.api.parity.setAccountName).toHaveBeenCalled(); + }); + + test('should call api.parity.setAccountMeta', () => { + expect(parityStore.api.parity.setAccountMeta).toHaveBeenCalled(); + }); +}); + +storeTests.setterTest(CreateAccountStore, 'address'); +storeTests.setterTest(CreateAccountStore, 'isImport'); +storeTests.setterTest(CreateAccountStore, 'name'); + +describe('setter phrase', () => { + test('should set correct value and call api.parity.phraseToAddress', () => { + createAccountStore.setPhrase('foo'); + expect(parityStore.api.parity.phraseToAddress).toHaveBeenCalledWith('foo'); + expect(createAccountStore.phrase).toEqual('foo'); + }); +}); diff --git a/packages/fether-react/src/stores/healthStore.js b/packages/fether-react/src/stores/healthStore.js index 99c340a02cbc13407104981c69b6bef05f2bf26c..a0457e850352791c5d46732ea993d6e25f1ad487 100644 --- a/packages/fether-react/src/stores/healthStore.js +++ b/packages/fether-react/src/stores/healthStore.js @@ -22,7 +22,7 @@ export const STATUS = { SYNCING: 'SYNCING' // Obvious }; -class HealthStore { +export class HealthStore { @observable nodeHealth; @observable syncing; diff --git a/packages/fether-react/src/stores/onboardingStore.js b/packages/fether-react/src/stores/onboardingStore.js index ab1fd7f3fd6df4bb0e960a7a410399eb329d2430..a5270ce7de66d64a3df51d33bc231e4d40b2db8d 100644 --- a/packages/fether-react/src/stores/onboardingStore.js +++ b/packages/fether-react/src/stores/onboardingStore.js @@ -10,7 +10,7 @@ import LS_PREFIX from './utils/lsPrefix'; const LS_KEY = `${LS_PREFIX}::firstRun`; -class OnboardingStore { +export class OnboardingStore { @observable isFirstRun; // If it's the 1st time the user is running the app constructor () { diff --git a/packages/fether-react/src/stores/parityStore.js b/packages/fether-react/src/stores/parityStore.js index d8f4ac8f5810d2a0ae21b98bb9ada4f931a97d29..dd0adbb3096457be0b83703eb966b5fbe6a40f36 100644 --- a/packages/fether-react/src/stores/parityStore.js +++ b/packages/fether-react/src/stores/parityStore.js @@ -17,7 +17,7 @@ const electron = isElectron() ? window.require('electron') : null; const LS_KEY = `${LS_PREFIX}::secureToken`; -class ParityStore { +export class ParityStore { @observable downloadProgress = 0; @observable isApiConnected = false; @observable isParityRunning = false; diff --git a/packages/fether-react/src/stores/sendStore.js b/packages/fether-react/src/stores/sendStore.js index 76a0d48e2beaae7c71cee597c11568140ad8d787..65ac2a07e84eec119ef2e4b0a450dc78141d7d58 100644 --- a/packages/fether-react/src/stores/sendStore.js +++ b/packages/fether-react/src/stores/sendStore.js @@ -19,7 +19,7 @@ const debug = Debug('sendStore'); const DEFAULT_GAS = new BigNumber(21000); // Default gas amount to show const GAS_MULT_FACTOR = 1.33; // Since estimateGas is not always accurate, we add a 33% factor for buffer. -class SendStore { +export class SendStore { @observable blockNumber; // Current block number, used to calculate tx confirmations. @observable estimated = DEFAULT_GAS; // Estimated gas amount for this transaction. @observable tokenAddress; // 'ETH', or the token contract address @@ -70,6 +70,10 @@ class SendStore { * Estimate the amount of gas for our transaction. */ estimateGas = () => { + if (!this.tx || !Object.keys(this.tx).length) { + return; + } + if (this.tokenAddress === 'ETH') { return this.estimateGasForEth(this.txForEth); } else { @@ -130,11 +134,6 @@ class SendStore { }); }; - @computed - get token () { - return tokensStore.tokens[this.tokenAddress]; - } - /** * This.tx is a user-friendly tx object. We convert it now as it can be * passed to makeContract$(...). @@ -145,7 +144,7 @@ class SendStore { args: [ this.tx.to, new BigNumber(this.tx.amount).mul( - new BigNumber(10).pow(this.token.decimals) + new BigNumber(10).pow(tokensStore.tokens[this.tokenAddress].decimals) ) ], options: { @@ -175,6 +174,7 @@ class SendStore { @action setEstimated = estimated => { this.estimated = estimated.mul(GAS_MULT_FACTOR); + debug('Estimated gas.', +estimated); }; @action diff --git a/packages/fether-react/src/stores/sendStore.spec.js b/packages/fether-react/src/stores/sendStore.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..5fcbd556a120da58ab471cabe2d9e459d4325409 --- /dev/null +++ b/packages/fether-react/src/stores/sendStore.spec.js @@ -0,0 +1,258 @@ +// Copyright 2015-2018 Parity Technologies (UK) Ltd. +// This file is part of Parity. +// +// SPDX-License-Identifier: BSD-3-Clause + +/* eslint-env jest */ + +import abi from '@parity/shared/lib/contracts/abi/eip20'; +import BigNumber from 'bignumber.js'; +import lightJs from '@parity/light.js'; // Mocked + +import parityStore from './parityStore'; +import { SendStore } from './sendStore'; +import * as storeTests from '../utils/testHelpers/storeTests'; + +jest.mock('@parity/light.js', () => ({ + blockNumber$: jest.fn(() => ({ + subscribe: () => + jest.fn(() => ({ + unsubscribe: jest.fn() + })) + })), + makeContract$: jest.fn(() => ({ + contractObject: { + instance: { + transfer: { estimateGas: jest.fn(() => Promise.resolve(123)) } + } + }, + transfer$: jest.fn(() => ({ subscribe: jest.fn() })) + })), + post$: jest.fn(() => ({ + subscribe: jest.fn(callback => { + setTimeout(callback({ estimating: true }), 100); // eslint-disable-line standard/no-callback-literal + setTimeout(callback({ requested: 1 }), 200); // eslint-disable-line standard/no-callback-literal + }) + })) +})); + +jest.mock('./parityStore', () => ({ + api: { + eth: { + estimateGas: jest.fn(() => Promise.resolve(123)) + }, + signer: { + confirmRequest: jest.fn(() => Promise.resolve(true)) + } + } +})); + +jest.mock('./tokensStore', () => ({ + tokens: { + ETH: { decimals: 18 }, + foo: { decimals: 18 } + } +})); + +const mockTx = { + amount: 0.01, // In Ether or in token + gasPrice: 4, // in Gwei + to: '0x123' +}; + +let sendStore; // Will hold the newly created instance of SendStore in each test +beforeEach(() => { + sendStore = new SendStore(); +}); + +describe('method acceptRequest', () => { + test('should call api.signer.confirmRequest', () => { + sendStore.acceptRequest(1, 'foo'); + expect(parityStore.api.signer.confirmRequest).toHaveBeenCalledWith( + 1, + null, + 'foo' + ); + }); + + test('should set a subscription on blockNumber$', () => { + sendStore.acceptRequest(1, 'foo'); + expect(lightJs.blockNumber$).toHaveBeenCalled(); + }); +}); + +describe('method clear', () => { + test('should clear tx', () => { + sendStore.setTx(mockTx); + sendStore.clear(); + expect(sendStore.tx).toEqual({}); + }); + + test('should unsubscribe', () => { + sendStore.subscription = { unsubscribe: jest.fn() }; + sendStore.clear(); + expect(sendStore.subscription.unsubscribe).toHaveBeenCalled(); + }); +}); + +describe('@computed confirmations', () => { + test('should return correct value if txStatus is not set', () => { + sendStore.setTxStatus(null); + expect(sendStore.confirmations).toBe(-1); + }); + + test('should return correct value if txStatus is not `confirmed`', () => { + sendStore.setTxStatus({ estimating: true }); + expect(sendStore.confirmations).toBe(-1); + }); + + test('should return correct value if txStatus is `confirmed`', () => { + sendStore.setBlockNumber(5); + sendStore.setTxStatus({ confirmed: { blockNumber: 4 } }); + expect(sendStore.confirmations).toBe(1); + }); +}); + +describe('@computed contract', () => { + test('should create a contract with correct token address if the current token Erc20', () => { + sendStore.setTokenAddress('foo'); + sendStore.contract; // eslint-disable-line + expect(lightJs.makeContract$).toHaveBeenCalledWith('foo', abi); + }); + + test('should return null if the current token is ETH', () => { + sendStore.setTokenAddress('ETH'); + expect(sendStore.contract).toBe(null); + }); +}); + +describe('method estimateGas', () => { + test('should not estimate if no tx is set', () => { + sendStore.estimateGasForErc20 = jest.fn(); + sendStore.estimateGasForEth = jest.fn(); + expect(sendStore.estimateGas()).toBe(undefined); + expect(sendStore.estimateGasForErc20).not.toHaveBeenCalled(); + expect(sendStore.estimateGasForEth).not.toHaveBeenCalled(); + }); + + test('should call estimateGasForErc20 if the current token is Erc20', () => { + sendStore.estimateGasForErc20 = jest.fn(() => 'estimateGasForErc20'); + sendStore.setTokenAddress('foo'); + sendStore.setTx(mockTx); + expect(sendStore.estimateGas()).toBe('estimateGasForErc20'); + expect(sendStore.estimateGasForErc20).toHaveBeenCalled(); + }); + + test('should call estimateGasForEth if the current token is ETH', () => { + sendStore.estimateGasForEth = jest.fn(() => 'estimateGasForEth'); + sendStore.setTokenAddress('ETH'); + sendStore.setTx(mockTx); + expect(sendStore.estimateGas()).toBe('estimateGasForEth'); + expect(sendStore.estimateGasForEth).toHaveBeenCalled(); + }); +}); + +describe('method estimateGasForErc20', () => { + beforeEach(() => { + sendStore.setTokenAddress('foo'); + }); + + test.skip('should call the transfer method on the contract', () => { + sendStore.estimateGasForErc20(mockTx); + expect( + sendStore.contract.contractObject.instance.transfer.estimateGas + ).toHaveBeenCalledWith(mockTx); + }); + + test('should memoize result', () => { + const a = sendStore.estimateGasForErc20(mockTx); + const b = sendStore.estimateGasForErc20(mockTx); + expect(a).toBe(b); + }); +}); + +describe('method estimateGasForEth', () => { + beforeEach(() => { + sendStore.setTokenAddress('ETH'); + }); + + test('should call api.eth.estimateGas', () => { + sendStore.estimateGasForEth(mockTx); + expect(parityStore.api.eth.estimateGas).toHaveBeenCalledWith(mockTx); + }); + + test('should memoize result', () => { + const a = sendStore.estimateGasForEth(mockTx); + const b = sendStore.estimateGasForEth(mockTx); + expect(a).toBe(b); + }); +}); + +describe('method send', () => { + beforeEach(() => { + sendStore.setTx(mockTx); + }); + + test.skip('should call transfer$ if the token is Erc20 and subscribe to it', () => { + sendStore.setTokenAddress('foo'); + sendStore.send(); + expect(sendStore.contract.transfer$).toHaveBeenCalledWith( + sendStore.txForErc20 + ); + }); + + test('should call post$ if the token is ETH and subscribe to it', () => { + sendStore.setTokenAddress('ETH'); + sendStore.send(); + expect(lightJs.post$).toHaveBeenCalledWith(sendStore.txForEth); + }); + + test('should update txStatus', () => { + sendStore.setTxStatus = jest.fn(); + sendStore.setTokenAddress('ETH'); + sendStore.send(); + expect(sendStore.setTxStatus).toHaveBeenCalledWith({ estimating: true }); + }); + + test('should call acceptRequest when txStatus is requested', () => { + sendStore.acceptRequest = jest.fn(() => Promise.resolve(true)); + sendStore.setTokenAddress('ETH'); + sendStore.send('foo'); + expect(sendStore.acceptRequest).toHaveBeenCalledWith(1, 'foo'); + }); +}); + +describe('setter setEstimated', () => { + test('should add a 1.33 factor', () => { + sendStore.setEstimated(new BigNumber(2)); + expect(sendStore.estimated).toEqual(new BigNumber(2 * 1.33)); + }); +}); + +describe('@computed txForErc20', () => { + test('should return correct value', () => { + sendStore.setTokenAddress('foo'); + sendStore.setTx(mockTx); + expect(sendStore.txForErc20).toEqual({ + args: ['0x123', new BigNumber('10000000000000000')], + options: { gasPrice: new BigNumber('4000000000') } + }); + }); +}); + +describe('@computed txForEth', () => { + test('should return correct value', () => { + sendStore.setTokenAddress('foo'); + sendStore.setTx(mockTx); + expect(sendStore.txForEth).toEqual({ + gasPrice: new BigNumber('4000000000'), + to: '0x123', + value: new BigNumber('10000000000000000') + }); + }); +}); + +storeTests.setterTest(SendStore, 'blockNumber'); +storeTests.setterTest(SendStore, 'tokenAddress'); +storeTests.setterTest(SendStore, 'tx'); +storeTests.setterTest(SendStore, 'txStatus'); diff --git a/packages/fether-react/src/stores/tokensStore.js b/packages/fether-react/src/stores/tokensStore.js index 937f86f36367450969cefc90da0db508d729622b..637731426ffe969c6a8cdc3159bb1a15b3a5b899 100644 --- a/packages/fether-react/src/stores/tokensStore.js +++ b/packages/fether-react/src/stores/tokensStore.js @@ -13,7 +13,7 @@ import LS_PREFIX from './utils/lsPrefix'; const LS_KEY = `${LS_PREFIX}::tokens`; -class TokensStore { +export class TokensStore { @observable tokens = {}; constructor () { diff --git a/packages/fether-react/src/utils/testHelpers/storeTests.js b/packages/fether-react/src/utils/testHelpers/storeTests.js new file mode 100644 index 0000000000000000000000000000000000000000..6788a4dba66e05ebe27cc22162a24b159ade0a46 --- /dev/null +++ b/packages/fether-react/src/utils/testHelpers/storeTests.js @@ -0,0 +1,22 @@ +// Copyright 2015-2018 Parity Technologies (UK) Ltd. +// This file is part of Parity. +// +// SPDX-License-Identifier: BSD-3-Clause + +/* eslint-env jest */ + +import capitalize from 'capitalize'; + +let store; + +export const setterTest = (Store, variableName) => + describe(`setter ${variableName}`, () => { + beforeEach(() => { + store = new Store(); + }); + + test(`should correctly set ${variableName}`, () => { + store[`set${capitalize(variableName)}`]('foo'); + expect(store[variableName]).toEqual('foo'); + }); + }); diff --git a/yarn.lock b/yarn.lock index bdc3dfe2694bcd3102be2040520b67d55d4f9306..90f7dd459b7db5c6477e6a27b126b7732d335527 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2866,6 +2866,10 @@ caniuse-lite@^1.0.30000748, caniuse-lite@^1.0.30000792, caniuse-lite@^1.0.300008 version "1.0.30000858" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000858.tgz#f6f203a9128bac507136de1cf6cfd966d2df027c" +capitalize@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/capitalize/-/capitalize-1.0.0.tgz#dc802c580aee101929020d2ca14b4ca8a0ae44be" + capture-stack-trace@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.0.tgz#4a6fa07399c26bba47f0b2496b4d0fb408c5550d"