diff --git a/src/actions/transactions.js b/src/actions/transactions.js index bf88a67c2..04c24bfa9 100644 --- a/src/actions/transactions.js +++ b/src/actions/transactions.js @@ -10,6 +10,15 @@ export const transactionAdded = data => ({ type: actionTypes.transactionAdded, }); +/** + * An action to dispatch transactionsFailed + * + */ +export const transactionsFailed = data => ({ + data, + type: actionTypes.transactionsFailed, +}); + /** * An action to dispatch transactionsUpdated * diff --git a/src/actions/transactions.test.js b/src/actions/transactions.test.js index fcc9f87f5..ab9378407 100644 --- a/src/actions/transactions.test.js +++ b/src/actions/transactions.test.js @@ -1,7 +1,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import actionTypes from '../constants/actions'; -import { transactionAdded, transactionsUpdated, +import { transactionAdded, transactionsUpdated, transactionsFailed, transactionsLoaded, transactionsRequested } from './transactions'; import * as accountApi from '../utils/api/account'; @@ -20,6 +20,20 @@ describe('actions: transactions', () => { }); }); + describe('transactionsFailed', () => { + it('should create an action to transactionsFailed', () => { + const data = { + id: 'dummy', + }; + const expectedAction = { + data, + type: actionTypes.transactionsFailed, + }; + + expect(transactionsFailed(data)).to.be.deep.equal(expectedAction); + }); + }); + describe('transactionsUpdated', () => { it('should create an action to transactionsUpdated', () => { const data = { diff --git a/src/constants/actions.js b/src/constants/actions.js index 6cb29238d..7750db36f 100644 --- a/src/constants/actions.js +++ b/src/constants/actions.js @@ -22,6 +22,7 @@ const actionTypes = { loadingStarted: 'LOADING_STARTED', loadingFinished: 'LOADING_FINISHED', transactionAdded: 'TRANSACTION_ADDED', + transactionsFailed: 'TRANSACTIONS_FAILED', transactionsUpdated: 'TRANSACTIONS_UPDATED', transactionsLoaded: 'TRANSACTIONS_LOADED', transactionsReset: 'TRANSACTIONS_RESET', diff --git a/src/store/middlewares/addedTransaction.js b/src/store/middlewares/addedTransaction.js deleted file mode 100644 index 5667846c8..000000000 --- a/src/store/middlewares/addedTransaction.js +++ /dev/null @@ -1,24 +0,0 @@ -import i18next from 'i18next'; -import actionTypes from '../../constants/actions'; -import { successAlertDialogDisplayed } from '../../actions/dialog'; -import { fromRawLsk } from '../../utils/lsk'; -import transactionTypes from '../../constants/transactionTypes'; - -const addedTransactionMiddleware = store => next => (action) => { - next(action); - if (action.type === actionTypes.transactionAdded) { - const texts = { - [transactionTypes.setSecondPassphrase]: i18next.t('Second passphrase registration was successfully submitted. It can take several seconds before it is processed.'), - [transactionTypes.registerDelegate]: i18next.t('Delegate registration was successfully submitted with username: "{{username}}". It can take several seconds before it is processed.', - { username: action.data.username }), - [transactionTypes.vote]: i18next.t('Your votes were successfully submitted. It can take several seconds before they are processed.'), - [transactionTypes.send]: i18next.t('Your transaction of {{amount}} LSK to {{recipientAddress}} was accepted and will be processed in a few seconds.', - { amount: fromRawLsk(action.data.amount), recipientAddress: action.data.recipientId }), - }; - const text = texts[action.data.type]; - const newAction = successAlertDialogDisplayed({ text }); - store.dispatch(newAction); - } -}; - -export default addedTransactionMiddleware; diff --git a/src/store/middlewares/addedTransaction.test.js b/src/store/middlewares/addedTransaction.test.js deleted file mode 100644 index 93150c9a2..000000000 --- a/src/store/middlewares/addedTransaction.test.js +++ /dev/null @@ -1,58 +0,0 @@ -import { expect } from 'chai'; -import { spy, stub } from 'sinon'; -import i18next from 'i18next'; -import { successAlertDialogDisplayed } from '../../actions/dialog'; -import middleware from './addedTransaction'; -import actionTypes from '../../constants/actions'; - -describe('addedTransaction middleware', () => { - let store; - let next; - - beforeEach(() => { - store = stub(); - store.getState = () => ({ - peers: { - data: {}, - }, - account: {}, - }); - store.dispatch = spy(); - next = spy(); - }); - - it('should passes the action to next middleware', () => { - const givenAction = { - type: 'TEST_ACTION', - }; - - middleware(store)(next)(givenAction); - expect(next).to.have.been.calledWith(givenAction); - }); - - it('fire success dialog action with appropriate text ', () => { - const givenAction = { - type: actionTypes.transactionAdded, - data: { - username: 'test', - amount: 1e8, - recipientId: '16313739661670634666L', - }, - }; - - const expectedMessages = [ - 'Your transaction of 1 LSK to 16313739661670634666L was accepted and will be processed in a few seconds.', - 'Second passphrase registration was successfully submitted. It can take several seconds before it is processed.', - 'Delegate registration was successfully submitted with username: "test". It can take several seconds before it is processed.', - 'Your votes were successfully submitted. It can take several seconds before they are processed.', - ]; - - for (let i = 0; i < 4; i++) { - givenAction.data.type = i; - middleware(store)(next)(givenAction); - const expectedAction = successAlertDialogDisplayed({ text: i18next.t(expectedMessages[i]) }); - expect(store.dispatch).to.have.been.calledWith(expectedAction); - } - }); -}); - diff --git a/src/store/middlewares/index.js b/src/store/middlewares/index.js index 41a5f13fe..a732e0146 100644 --- a/src/store/middlewares/index.js +++ b/src/store/middlewares/index.js @@ -2,7 +2,7 @@ import thunk from 'redux-thunk'; import metronomeMiddleware from './metronome'; import accountMiddleware from './account'; import loginMiddleware from './login'; -import addedTransactionMiddleware from './addedTransaction'; +import transactionsMiddleware from './transactions'; import loadingBarMiddleware from './loadingBar'; import offlineMiddleware from './offline'; import notificationMiddleware from './notification'; @@ -11,7 +11,7 @@ import savedAccountsMiddleware from './savedAccounts'; export default [ thunk, - addedTransactionMiddleware, + transactionsMiddleware, loginMiddleware, metronomeMiddleware, accountMiddleware, diff --git a/src/store/middlewares/transactions.js b/src/store/middlewares/transactions.js new file mode 100644 index 000000000..3980afbce --- /dev/null +++ b/src/store/middlewares/transactions.js @@ -0,0 +1,48 @@ +import i18next from 'i18next'; + +import { fromRawLsk } from '../../utils/lsk'; +import { unconfirmedTransactions } from '../../utils/api/account'; +import { successAlertDialogDisplayed } from '../../actions/dialog'; +import { transactionsFailed } from '../../actions/transactions'; +import actionTypes from '../../constants/actions'; +import transactionTypes from '../../constants/transactionTypes'; + +const transactionAdded = (store, action) => { + const texts = { + [transactionTypes.setSecondPassphrase]: i18next.t('Second passphrase registration was successfully submitted. It can take several seconds before it is processed.'), + [transactionTypes.registerDelegate]: i18next.t('Delegate registration was successfully submitted with username: "{{username}}". It can take several seconds before it is processed.', + { username: action.data.username }), + [transactionTypes.vote]: i18next.t('Your votes were successfully submitted. It can take several seconds before they are processed.'), + [transactionTypes.send]: i18next.t('Your transaction of {{amount}} LSK to {{recipientAddress}} was accepted and will be processed in a few seconds.', + { amount: fromRawLsk(action.data.amount), recipientAddress: action.data.recipientId }), + }; + const text = texts[action.data.type]; + const newAction = successAlertDialogDisplayed({ text }); + store.dispatch(newAction); +}; + +const transactionsUpdated = (store) => { + const { transactions, account, peers } = store.getState(); + if (transactions.pending.length) { + unconfirmedTransactions(peers.data, account.address) + .then(response => store.dispatch(transactionsFailed({ + failed: transactions.pending.filter(tx => + response.transactions.filter(unconfirmedTx => tx.id === unconfirmedTx.id).length === 0), + }))); + } +}; + +const transactionsMiddleware = store => next => (action) => { + next(action); + switch (action.type) { + case actionTypes.transactionAdded: + transactionAdded(store, action); + break; + case actionTypes.transactionsUpdated: + transactionsUpdated(store, action); + break; + default: break; + } +}; + +export default transactionsMiddleware; diff --git a/src/store/middlewares/transactions.test.js b/src/store/middlewares/transactions.test.js new file mode 100644 index 000000000..5c464bc3f --- /dev/null +++ b/src/store/middlewares/transactions.test.js @@ -0,0 +1,107 @@ +import { expect } from 'chai'; +import { spy, stub, mock } from 'sinon'; +import i18next from 'i18next'; +import * as accountApi from '../../utils/api/account'; +import { successAlertDialogDisplayed } from '../../actions/dialog'; +import { transactionsFailed } from '../../actions/transactions'; +import middleware from './transactions'; +import actionTypes from '../../constants/actions'; + +describe('transaction middleware', () => { + let store; + let next; + let state; + let accountApiMock; + const mockTransaction = { + username: 'test', + amount: 1e8, + recipientId: '16313739661670634666L', + }; + + beforeEach(() => { + store = stub(); + state = { + peers: { + data: {}, + }, + account: { + address: '8096217735672704724L', + }, + transactions: { + pending: [], + }, + }; + store.getState = () => (state); + store.dispatch = spy(); + next = spy(); + accountApiMock = mock(accountApi); + }); + + afterEach(() => { + accountApiMock.restore(); + }); + + it('should passes the action to next middleware', () => { + const givenAction = { + type: 'TEST_ACTION', + }; + + middleware(store)(next)(givenAction); + expect(next).to.have.been.calledWith(givenAction); + }); + + it('should fire success dialog action with appropriate text if action.type is transactionAdded', () => { + const givenAction = { + type: actionTypes.transactionAdded, + data: mockTransaction, + }; + + const expectedMessages = [ + 'Your transaction of 1 LSK to 16313739661670634666L was accepted and will be processed in a few seconds.', + 'Second passphrase registration was successfully submitted. It can take several seconds before it is processed.', + 'Delegate registration was successfully submitted with username: "test". It can take several seconds before it is processed.', + 'Your votes were successfully submitted. It can take several seconds before they are processed.', + ]; + + for (let i = 0; i < 4; i++) { + givenAction.data.type = i; + middleware(store)(next)(givenAction); + const expectedAction = successAlertDialogDisplayed({ text: i18next.t(expectedMessages[i]) }); + expect(store.dispatch).to.have.been.calledWith(expectedAction); + } + }); + + it('should do nothing if state.transactions.pending.length === 0 and action.type is transactionsUpdated', () => { + const givenAction = { + type: actionTypes.transactionsUpdated, + data: [mockTransaction], + }; + + middleware(store)(next)(givenAction); + expect(store.dispatch).to.not.have.been.calledWith(); + }); + + it('should call unconfirmedTransactions and then dispatch transactionsFailed if state.transactions.pending.length > 0 and action.type is transactionsUpdated', () => { + const transactions = [ + mockTransaction, + ]; + accountApiMock.expects('unconfirmedTransactions') + .withExactArgs(state.peers.data, state.account.address) + .returnsPromise().resolves({ transactions }); + store.getState = () => ({ + ...state, + transactions: { + pending: transactions, + }, + }); + const givenAction = { + type: actionTypes.transactionsUpdated, + data: [], + }; + + middleware(store)(next)(givenAction); + const expectedAction = transactionsFailed({ failed: [] }); + expect(store.dispatch).to.have.been.calledWith(expectedAction); + }); +}); + diff --git a/src/store/reducers/transactions.js b/src/store/reducers/transactions.js index 84dac0442..27e2815e0 100644 --- a/src/store/reducers/transactions.js +++ b/src/store/reducers/transactions.js @@ -11,6 +11,13 @@ const transactions = (state = { pending: [], confirmed: [], count: null }, actio return Object.assign({}, state, { pending: [action.data, ...state.pending], }); + case actionTypes.transactionsFailed: + return Object.assign({}, state, { + // Filter any failed transaction from pending + pending: state.pending.filter( + pendingTransaction => action.data.failed.filter( + transaction => transaction.id === pendingTransaction.id).length === 0), + }); case actionTypes.transactionsLoaded: return Object.assign({}, state, { confirmed: [ diff --git a/src/store/reducers/transactions.test.js b/src/store/reducers/transactions.test.js index 8aa4e43df..cd72e0c5b 100644 --- a/src/store/reducers/transactions.test.js +++ b/src/store/reducers/transactions.test.js @@ -3,6 +3,10 @@ import transactions from './transactions'; import actionTypes from '../../constants/actions'; describe('Reducer: transactions(state, action)', () => { + const defaultState = { + pending: [], + confirmed: [], + }; const mockTransactions = [{ amount: 100000000000, id: '16295820046284152875', @@ -19,8 +23,8 @@ describe('Reducer: transactions(state, action)', () => { it('should prepend action.data to state.pending if action.type = actionTypes.transactionAdded', () => { const state = { + ...defaultState, pending: [mockTransactions[1]], - confirmed: [], }; const action = { type: actionTypes.transactionAdded, @@ -31,10 +35,7 @@ describe('Reducer: transactions(state, action)', () => { }); it('should concat action.data to state.confirmed if action.type = actionTypes.transactionsLoaded', () => { - const state = { - pending: [], - confirmed: [], - }; + const state = { ...defaultState }; const action = { type: actionTypes.transactionsLoaded, data: { @@ -43,7 +44,7 @@ describe('Reducer: transactions(state, action)', () => { }, }; const expectedState = { - pending: [], + ...defaultState, confirmed: action.data.confirmed, count: action.data.count, }; @@ -53,6 +54,7 @@ describe('Reducer: transactions(state, action)', () => { it('should prepend newer transactions from action.data to state.confirmed and remove from state.pending if action.type = actionTypes.transactionsUpdated', () => { const state = { + ...defaultState, pending: [mockTransactions[0]], confirmed: [mockTransactions[1], mockTransactions[2]], count: mockTransactions[1].length + mockTransactions[2].length, @@ -66,7 +68,7 @@ describe('Reducer: transactions(state, action)', () => { }; const changedState = transactions(state, action); expect(changedState).to.deep.equal({ - pending: [], + ...defaultState, confirmed: mockTransactions, count: mockTransactions.length, }); @@ -74,8 +76,7 @@ describe('Reducer: transactions(state, action)', () => { it('should action.data to state.confirmed if state.confirmed is empty and action.type = actionTypes.transactionsUpdated', () => { const state = { - pending: [], - confirmed: [], + ...defaultState, }; const action = { type: actionTypes.transactionsUpdated, @@ -86,7 +87,7 @@ describe('Reducer: transactions(state, action)', () => { }; const changedState = transactions(state, action); expect(changedState).to.deep.equal({ - pending: [], + ...defaultState, confirmed: mockTransactions, count: mockTransactions.length, }); @@ -94,6 +95,7 @@ describe('Reducer: transactions(state, action)', () => { it('should reset all data if action.type = actionTypes.accountLoggedOut', () => { const state = { + ...defaultState, pending: [{ amount: 110000000000, id: '16295820046284152275', @@ -104,8 +106,7 @@ describe('Reducer: transactions(state, action)', () => { const action = { type: actionTypes.accountLoggedOut }; const changedState = transactions(state, action); expect(changedState).to.deep.equal({ - pending: [], - confirmed: [], + ...defaultState, count: 0, }); }); diff --git a/src/utils/api/account.js b/src/utils/api/account.js index 0ffdfdb42..20ed14460 100644 --- a/src/utils/api/account.js +++ b/src/utils/api/account.js @@ -35,6 +35,15 @@ export const transactions = (activePeer, address, limit = 20, offset = 0, orderB orderBy, }); +export const unconfirmedTransactions = (activePeer, address, limit = 20, offset = 0, orderBy = 'timestamp:desc') => + requestToActivePeer(activePeer, 'transactions/unconfirmed', { + senderId: address, + recipientId: address, + limit, + offset, + orderBy, + }); + export const extractPublicKey = passphrase => Lisk.crypto.getKeys(passphrase).publicKey; diff --git a/src/utils/api/account.test.js b/src/utils/api/account.test.js index 24de50fb6..46816e5af 100644 --- a/src/utils/api/account.test.js +++ b/src/utils/api/account.test.js @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { mock } from 'sinon'; -import { getAccount, setSecondPassphrase, send, transactions, +import { getAccount, setSecondPassphrase, send, transactions, unconfirmedTransactions, extractPublicKey, extractAddress } from './account'; import { activePeerSet } from '../../actions/peers'; @@ -82,6 +82,13 @@ describe('Utils: Account', () => { }); }); + describe('unconfirmedTransactions', () => { + it('should return a promise', () => { + const promise = unconfirmedTransactions(); + expect(typeof promise.then).to.be.equal('function'); + }); + }); + describe('extractPublicKey', () => { it('should return a Hex string from any given string', () => { const passphrase = 'field organ country moon fancy glare pencil combine derive fringe security pave';