diff options
| author | Erik Larkö <erik@mullvad.net> | 2017-06-30 10:14:36 +0200 |
|---|---|---|
| committer | Erik Larkö <erik@mullvad.net> | 2017-07-03 15:26:08 +0200 |
| commit | d06281eb566330a38b13a56bb9fdadbd7ef9aa99 (patch) | |
| tree | e1bc465e4e1b54b9ccbb8c500ad9b921fd2e16f9 | |
| parent | 005a6094aa48738f54e97d809de6e11f3b966da0 (diff) | |
| download | mullvadvpn-d06281eb566330a38b13a56bb9fdadbd7ef9aa99.tar.xz mullvadvpn-d06281eb566330a38b13a56bb9fdadbd7ef9aa99.zip | |
Add first login workflow test
| -rw-r--r-- | app/app.js | 2 | ||||
| -rw-r--r-- | app/lib/backend.js | 39 | ||||
| -rw-r--r-- | test/helpers/IpcChain.js | 84 | ||||
| -rw-r--r-- | test/helpers/ipc-helpers.js | 51 | ||||
| -rw-r--r-- | test/login.spec.js | 30 | ||||
| -rw-r--r-- | test/routing.spec.js | 2 |
6 files changed, 200 insertions, 8 deletions
diff --git a/app/app.js b/app/app.js index 499ffe18c4..bff4c7f6fc 100644 --- a/app/app.js +++ b/app/app.js @@ -21,7 +21,7 @@ import type { TrayIconType } from './lib/tray-icon-manager'; const initialState = null; const memoryHistory = createMemoryHistory(); const store = configureStore(initialState, memoryHistory); -const backend = new Backend(); +const backend = new Backend(store); // reset login state if user quit the app during login if((['connecting', 'failed']: Array<LoginState>).includes(store.getState().account.status)) { diff --git a/app/lib/backend.js b/app/lib/backend.js index 076a49ca32..4cd2cf49e9 100644 --- a/app/lib/backend.js +++ b/app/lib/backend.js @@ -3,6 +3,9 @@ import log from 'electron-log'; import EventEmitter from 'events'; import { servers } from '../config'; import { IpcFacade, RealIpc } from './ipc-facade'; +import accountActions from '../redux/account/actions'; +import type { ReduxStore } from '../redux/store'; +import { push } from 'react-router-redux'; export type EventType = 'connect' | 'connecting' | 'disconnect' | 'login' | 'logging' | 'logout' | 'updatedIp' | 'updatedLocation' | 'updatedReachability'; export type ErrorType = 'NO_CREDIT' | 'NO_INTERNET' | 'INVALID_ACCOUNT'; @@ -61,9 +64,11 @@ export class BackendError extends Error { export class Backend { _ipc: IpcFacade; + _store: ReduxStore; _eventEmitter = new EventEmitter(); - constructor(ipc: ?IpcFacade) { + constructor(store: ReduxStore, ipc: ?IpcFacade) { + this._store = store; this._ipc = ipc || new RealIpc(''); this._registerIpcListeners(); this._startReachability(); @@ -131,17 +136,24 @@ export class Backend { }; } - login(account: string) { - log.info('Attempting to login with account number', account); + + login(accountNumber: string) { + log.info('Attempting to login with account number', accountNumber); // emit: logging in - this._emit('logging', { accountNumber: account }, null); + this._emit('logging', { accountNumber: accountNumber }, null); + + this._store.dispatch(accountActions.loginChange({ + accountNumber: accountNumber, + status: 'connecting', + error: null, + })); - this._ipc.getAccountData(account) + this._ipc.getAccountData(accountNumber) .then( response => { log.info('Account exists', response); - return this._ipc.setAccount(account) + return this._ipc.setAccount(accountNumber) .then( () => response ); }).then( accountData => { @@ -151,13 +163,28 @@ export class Backend { paidUntil: accountData.paid_until, }, undefined); + this._store.dispatch(accountActions.loginChange({ + status: 'ok', + paidUntil: accountData.paid_until, + error: null, + })); + + this._store.dispatch(push('/connect')); }).catch(e => { log.error('Failed to log in', e); + + // TODO: This is not true. If there is a communication link failure the promise will be rejected too const err = new BackendError('INVALID_ACCOUNT'); + this._store.dispatch(accountActions.loginChange({ + status: 'failed', + error: err, + })); + this._emit('login', {}, err); }); } + logout() { // @TODO: What does it mean for a logout to be successful or failed? this._ipc.setAccount('') diff --git a/test/helpers/IpcChain.js b/test/helpers/IpcChain.js new file mode 100644 index 0000000000..247ea0526b --- /dev/null +++ b/test/helpers/IpcChain.js @@ -0,0 +1,84 @@ +// @flow + +import { expect } from 'chai'; +import { check, failFast } from './ipc-helpers'; + +export class IpcChain { + _expectedCalls: Array<string>; + _recordedCalls: Array<string>; + _mockIpc: {}; + _done: (*) => void; + + constructor(mockIpc: {}, done: (*) => void) { + this._expectedCalls = []; + this._recordedCalls = []; + this._mockIpc = mockIpc; + this._done = done; + } + + addRequiredStep(ipcCall: string): StepBuilder { + this._expectedCalls.push(ipcCall); + return new StepBuilder(ipcCall, this._addStep.bind(this)); + } + + _addStep(step: StepBuilder) { + const me = this; + this._mockIpc[step.ipcCall] = function() { + return new Promise(r => me._stepPromiseCallback(step, r, arguments)); + }; + } + + _stepPromiseCallback(step, resolve, args) { + this._registerCall(step.ipcCall); + + if (step.inputValidation) { + failFast(() => step.inputValidation(...args), this._done); + } + + if (this._isLastCall()) { + this._ensureChainCalledCorrectly(); + } + + resolve(step.returnValue); + } + + _isLastCall(): boolean { + return this._recordedCalls.length === this._expectedCalls.length; + } + + _ensureChainCalledCorrectly() { + check(() => { + expect(this._expectedCalls).to.deep.equal(this._recordedCalls); + }, this._done); + } + + _registerCall(ipcCall: string) { + this._recordedCalls.push(ipcCall); + } +} + +class StepBuilder { + ipcCall: string; + inputValidation: () => void; + returnValue: *; + _cb: (StepBuilder) => void; + + constructor(ipcCall: string, cb: (StepBuilder)=> void) { + this.ipcCall = ipcCall; + this._cb = cb; + } + + withInputValidation(iv: () => void): StepBuilder { + this.inputValidation = iv; + return this; + } + + withReturnValue(rv: *): StepBuilder { + this.returnValue = rv; + return this; + } + + done() { + this._cb(this); + } +} diff --git a/test/helpers/ipc-helpers.js b/test/helpers/ipc-helpers.js new file mode 100644 index 0000000000..39fc9fba06 --- /dev/null +++ b/test/helpers/ipc-helpers.js @@ -0,0 +1,51 @@ +// @flow + +import { Backend } from '../../app/lib/backend'; +import { newMockIpc } from '../mocks/ipc'; +import configureStore from '../../app/redux/store'; +import { createMemoryHistory } from 'history'; + +type DoneCallback = (?mixed) => void; +type Check = () => void; + +// Mock localStorage because redux-localstorage has no test helpers +// We use redux-localstorage when we setup the redux store to have the +// store persist when the application is shut down. +global.localStorage = {getItem: ()=>'{}', setItem: ()=>{}}; + +export function setupBackendAndStore() { + + const memoryHistory = createMemoryHistory(); + const store = configureStore(null, memoryHistory); + + const mockIpc = newMockIpc(); + + const backend = new Backend(store, mockIpc); + + return { store, mockIpc, backend }; +} + +// chai and async aren't the best of friends. To allow us +// to get the assertion error in the output of failed async +// tests we need to do this try-catch thing. +export function check(fn: Check, done: DoneCallback) { + try { + fn(); + done(); + } catch (e) { + done(e); + } +} + + +// In async tests where we want to test a chain of IPC messages +// we can only invoke `done` for the last message. This function +// is for the intermediate messages. +export function failFast(fn: Check, done: DoneCallback) { + try { + fn(); + } catch(e) { + done(e); + } +} + diff --git a/test/login.spec.js b/test/login.spec.js new file mode 100644 index 0000000000..84ca0f431a --- /dev/null +++ b/test/login.spec.js @@ -0,0 +1,30 @@ +// @flow + +import { expect } from 'chai'; +import { setupBackendAndStore } from './helpers/ipc-helpers'; +import { IpcChain } from './helpers/IpcChain'; +import accountActions from '../app/redux/account/actions'; + +describe('Logging in', () => { + + it('should validate the account number and then set it in the backend', (done) => { + const { store, mockIpc, backend } = setupBackendAndStore(); + + const chain = new IpcChain(mockIpc, done); + chain.addRequiredStep('getAccountData') + .withInputValidation((an) => { + expect(an).to.equal('123'); + }) + .done(); + + chain.addRequiredStep('setAccount') + .withInputValidation((an) => { + expect(an).to.equal('123'); + }) + .done(); + + const action: any = accountActions.login(backend, '123'); + store.dispatch(action); + }); +}); + diff --git a/test/routing.spec.js b/test/routing.spec.js index 3e1f80be3c..ccb702de8b 100644 --- a/test/routing.spec.js +++ b/test/routing.spec.js @@ -24,7 +24,7 @@ describe('routing', function() { }); const store = mockStore(state); - const backend = new Backend(newMockIpc()); + const backend = new Backend(store, newMockIpc()); mapBackendEventsToRouter(backend, store); store.dispatch(accountActions.logout(backend)); |
