Add tests for actions & reducers

This commit is contained in:
Jerko Steiner 2017-06-17 09:49:48 -04:00
parent 46ec853280
commit 300afd6b2f
21 changed files with 384 additions and 51 deletions

View File

@ -4,7 +4,8 @@
"rules": { "rules": {
"max-len": [2, 80, 4], "max-len": [2, 80, 4],
"jsx-quotes": ["error", "prefer-double"], "jsx-quotes": ["error", "prefer-double"],
"padded-blocks": 0 "padded-blocks": 0,
"import/first": 0
}, },
"globals": { "globals": {
"expect": true, "expect": true,

View File

@ -52,7 +52,7 @@ lint-fix:
.PHONY: test .PHONY: test
test: test:
jest --verbose jest --forceExit
.PHONY: testify .PHONY: testify
testify: testify:
@ -62,7 +62,7 @@ testify:
.PHONY: coverage .PHONY: coverage
coverage: coverage:
jest --coverage jest --coverage --forceExit
.PHONY: server .PHONY: server
server: server:

View File

@ -1,8 +1,9 @@
import EventEmitter from 'events' import EventEmitter from 'events'
const Peer = jest.genMockFunction().mockImplementation(() => { const Peer = jest.genMockFunction().mockImplementation(() => {
let peer = new EventEmitter() let peer = new EventEmitter()
peer.destroy = jest.genMockFunction() peer.destroy = jest.fn()
peer.signal = jest.genMockFunction() peer.signal = jest.fn()
peer.send = jest.fn()
Peer.instances.push(peer) Peer.instances.push(peer)
return peer return peer
}) })

View File

@ -6,7 +6,7 @@
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
"test": "jest --coverage", "test": "jest --coverage --forceExit",
"testify": "jest --watch", "testify": "jest --watch",
"lint": "eslint ./index.js ./src/js" "lint": "eslint ./index.js ./src/js"
}, },

View File

@ -0,0 +1,2 @@
import EventEmitter from 'events'
export default new EventEmitter()

View File

@ -6,21 +6,23 @@ import getUserMedia from '../window/getUserMedia.js'
import handshake from '../peer/handshake.js' import handshake from '../peer/handshake.js'
import socket from '../socket.js' import socket from '../socket.js'
export const init = () => dispatch => ({ export const init = () => dispatch => {
type: constants.INIT, return dispatch({
payload: Promise.all([ type: constants.INIT,
connect()(dispatch), payload: Promise.all([
getCameraStream()(dispatch) connect()(dispatch),
]) getCameraStream()(dispatch)
.spread((socket, stream) => { ])
handshake.init({ socket, callId, stream }) .spread((socket, stream) => {
handshake({ socket, callId, stream })
})
}) })
}) }
export const connect = () => dispatch => { export const connect = () => dispatch => {
return new Promise(resolve => { return new Promise(resolve => {
socket.once('connect', () => { socket.once('connect', () => {
dispatch(NotifyActions.warn('Connected to server socket')) dispatch(NotifyActions.warning('Connected to server socket'))
resolve(socket) resolve(socket)
}) })
socket.on('disconnect', () => { socket.on('disconnect', () => {
@ -35,9 +37,9 @@ export const getCameraStream = () => dispatch => {
dispatch(addStream({ stream, userId: constants.ME })) dispatch(addStream({ stream, userId: constants.ME }))
return stream return stream
}) })
.catch(() => { .catch(err => {
dispatch(NotifyActions.alert('Could not get access to microphone & camera')) dispatch(NotifyActions.alert('Could not get access to microphone & camera'))
return null throw err
}) })
} }

View File

@ -1,4 +1,5 @@
import * as constants from '../constants.js' import * as constants from '../constants.js'
import Immutable from 'seamless-immutable'
const TIMEOUT = 5000 const TIMEOUT = 5000
@ -11,7 +12,7 @@ function format (string, args) {
const _notify = (type, args) => dispatch => { const _notify = (type, args) => dispatch => {
let string = args[0] || '' let string = args[0] || ''
let message = format(string, Array.prototype.slice.call(args, 1)) let message = format(string, Array.prototype.slice.call(args, 1))
const payload = { type, message } const payload = Immutable({ type, message })
dispatch({ dispatch({
type: constants.NOTIFY, type: constants.NOTIFY,
payload payload
@ -24,18 +25,22 @@ const _notify = (type, args) => dispatch => {
}, TIMEOUT) }, TIMEOUT)
} }
export const info = function() { export const info = function () {
return dispatch => _notify('info', arguments)(dispatch) return dispatch => _notify('info', arguments)(dispatch)
} }
export const warn = function() { export const warning = function () {
return dispatch => _notify('warning', arguments)(dispatch) return dispatch => _notify('warning', arguments)(dispatch)
} }
export const error = function() { export const error = function () {
return dispatch => _notify('error', arguments)(dispatch) return dispatch => _notify('error', arguments)(dispatch)
} }
export const clear = () => ({
type: constants.NOTIFY_CLEAR
})
export function alert (message, dismissable) { export function alert (message, dismissable) {
return { return {
type: constants.ALERT, type: constants.ALERT,
@ -48,9 +53,15 @@ export function alert (message, dismissable) {
} }
} }
export const dismiss = alert => { export const dismissAlert = alert => {
return { return {
type: constants.ALERT_DISMISS, type: constants.ALERT_DISMISS,
payload: alert payload: alert
} }
} }
export const clearAlerts = () => {
return {
type: constants.ALERT_CLEAR
}
}

View File

@ -0,0 +1,93 @@
jest.mock('../../callId.js')
jest.mock('../../iceServers.js')
jest.mock('../../peer/handshake.js')
jest.mock('../../socket.js')
jest.mock('../../window/getUserMedia.js')
jest.mock('../../store.js')
import * as CallActions from '../CallActions.js'
import * as constants from '../../constants.js'
import * as getUserMediaMock from '../../window/getUserMedia.js'
import callId from '../../callId.js'
import handshake from '../../peer/handshake.js'
import socket from '../../socket.js'
import store from '../../store.js'
jest.useFakeTimers()
describe('reducers/alerts', () => {
beforeEach(() => {
store.clearActions()
getUserMediaMock.fail(false)
})
afterEach(() => {
jest.runAllTimers()
socket.removeAllListeners('connect')
socket.removeAllListeners('disconnect')
})
describe('init', () => {
it('calls handshake.init when connected & got camera stream', done => {
const promise = store.dispatch(CallActions.init())
socket.emit('connect')
expect(store.getActions()).toEqual([{
type: constants.INIT_PENDING
}, {
type: constants.NOTIFY,
payload: {
message: 'Connected to server socket',
type: 'warning'
}
}])
promise.then(() => {
expect(handshake.mock.calls).toEqual([[{
socket,
callId,
stream: getUserMediaMock.stream
}]])
})
.then(done)
.catch(done.fail)
})
it('calls dispatches disconnect message on disconnect', done => {
const promise = store.dispatch(CallActions.init())
socket.emit('connect')
socket.emit('disconnect')
expect(store.getActions()).toEqual([{
type: constants.INIT_PENDING
}, {
type: constants.NOTIFY,
payload: {
message: 'Connected to server socket',
type: 'warning'
}
}, {
type: constants.NOTIFY,
payload: {
message: 'Server socket disconnected',
type: 'error'
}
}])
promise.then(done).catch(done.fail)
})
it('dispatches alert when failed to get media stream', done => {
getUserMediaMock.fail(true)
const promise = store.dispatch(CallActions.init())
socket.emit('connect')
promise
.then(done.fail)
.catch(err => {
expect(err.message).toEqual('test')
done()
})
})
})
})

View File

@ -8,6 +8,7 @@ import _ from 'underscore'
export default class App extends React.Component { export default class App extends React.Component {
static propTypes = { static propTypes = {
dismissAlert: PropTypes.func.isRequired,
streams: PropTypes.objectOf(StreamPropType).isRequired, streams: PropTypes.objectOf(StreamPropType).isRequired,
alerts: PropTypes.arrayOf(AlertPropType).isRequired, alerts: PropTypes.arrayOf(AlertPropType).isRequired,
activate: PropTypes.func.isRequired, activate: PropTypes.func.isRequired,
@ -21,11 +22,11 @@ export default class App extends React.Component {
} }
render () { render () {
const { const {
active, activate, alerts, dismiss, notify, notifications, streams active, activate, alerts, dismissAlert, notify, notifications, streams
} = this.props } = this.props
return (<div className="app"> return (<div className="app">
<Alerts alerts={alerts} dismiss={dismiss} /> <Alerts alerts={alerts} dismiss={dismissAlert} />
<Notifications notifications={notifications} /> <Notifications notifications={notifications} />
<Input notify={notify} /> <Input notify={notify} />
<div className="videos"> <div className="videos">

View File

@ -2,9 +2,9 @@ import { PENDING, FULFILLED, REJECTED } from 'redux-promise-middleware'
export const ME = '_me_' export const ME = '_me_'
export const INIT = 'INIT' export const INIT = 'INIT'
export const INIT_PENDING = `${INIT}${PENDING}` export const INIT_PENDING = `${INIT}_${PENDING}`
export const INIT_FULFILLED = `${INIT}${FULFILLED}` export const INIT_FULFILLED = `${INIT}_${FULFILLED}`
export const INIT_REJECTED = `${INIT}${REJECTED}` export const INIT_REJECTED = `${INIT}_${REJECTED}`
export const ALERT = 'ALERT' export const ALERT = 'ALERT'
export const ALERT_DISMISS = 'ALERT_DISMISS' export const ALERT_DISMISS = 'ALERT_DISMISS'

View File

@ -18,7 +18,7 @@ function mapStateToProps(state) {
function mapDispatchToProps(dispatch) { function mapDispatchToProps(dispatch) {
return { return {
activate: bindActionCreators(CallActions.activateStream, dispatch), activate: bindActionCreators(CallActions.activateStream, dispatch),
dismiss: bindActionCreators(NotifyActions.dismiss, dispatch), dismissAlert: bindActionCreators(NotifyActions.dismissAlert, dispatch),
init: bindActionCreators(CallActions.init, dispatch), init: bindActionCreators(CallActions.init, dispatch),
notify: bindActionCreators(NotifyActions.info, dispatch) notify: bindActionCreators(NotifyActions.info, dispatch)
} }

View File

@ -4,7 +4,7 @@ jest.mock('../../callId.js')
jest.mock('../../iceServers.js') jest.mock('../../iceServers.js')
import * as constants from '../../constants.js' import * as constants from '../../constants.js'
import * as handshake from '../handshake.js' import handshake from '../handshake.js'
import Peer from 'simple-peer' import Peer from 'simple-peer'
import peers from '../peers.js' import peers from '../peers.js'
import store from '../../store.js' import store from '../../store.js'
@ -25,7 +25,7 @@ describe('handshake', () => {
describe('socket events', () => { describe('socket events', () => {
describe('users', () => { describe('users', () => {
it('add a peer for each new user and destroy peers for missing', () => { it('add a peer for each new user and destroy peers for missing', () => {
handshake.init(socket, 'bla') handshake(socket, 'bla')
// given // given
let payload = { let payload = {
@ -53,7 +53,7 @@ describe('handshake', () => {
let data let data
beforeEach(() => { beforeEach(() => {
data = {} data = {}
handshake.init(socket, 'bla') handshake(socket, 'bla')
socket.emit('users', { socket.emit('users', {
initiator: 'a', initiator: 'a',
users: [{ id: 'a' }, { id: 'b' }] users: [{ id: 'a' }, { id: 'b' }]
@ -88,7 +88,7 @@ describe('handshake', () => {
let ready = false let ready = false
socket.once('ready', () => { ready = true }) socket.once('ready', () => { ready = true })
handshake.init(socket, 'bla') handshake(socket, 'bla')
socket.emit('users', { socket.emit('users', {
initiator: 'a', initiator: 'a',

View File

@ -26,8 +26,6 @@ import store from '../../store.js'
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import { play } from '../../window/video.js' import { play } from '../../window/video.js'
const { dispatch } = store
describe('peers', () => { describe('peers', () => {
function createSocket () { function createSocket () {
const socket = new EventEmitter() const socket = new EventEmitter()
@ -71,7 +69,6 @@ describe('peers', () => {
peers.create({ socket, user, initiator: 'user2', stream }) peers.create({ socket, user, initiator: 'user2', stream })
expect(store.getActions()).toEqual([actions.connecting]) expect(store.getActions()).toEqual([actions.connecting])
// expect(notify.warn.mock.calls).toEqual([[ 'Connecting to peer...' ]])
expect(Peer.instances.length).toBe(1) expect(Peer.instances.length).toBe(1)
expect(Peer.mock.calls.length).toBe(1) expect(Peer.mock.calls.length).toBe(1)
@ -121,6 +118,34 @@ describe('peers', () => {
expect(play.mock.calls.length).toBe(1) expect(play.mock.calls.length).toBe(1)
}) })
}) })
describe('data', () => {
beforeEach(() => {
window.TextDecoder = class TextDecoder {
constructor(encoding) {
this.encoding = encoding
}
decode(object) {
return object.toString(this.encoding)
}
}
})
it('decodes a message', () => {
store.clearActions()
const message = 'test'
const object = JSON.stringify({ message })
peer.emit('data', Buffer.from(object, 'utf-8'))
expect(store.getActions()).toEqual([{
type: constants.NOTIFY,
payload: {
type: 'info',
message: `${user.id}: ${message}`
}
}])
})
})
}) })
describe('get', () => { describe('get', () => {
@ -138,10 +163,10 @@ describe('peers', () => {
describe('getIds', () => { describe('getIds', () => {
it('returns ids of all peers', () => { it('returns ids of all peers', () => {
peers.create({ peers.create({
socket, user: {id: 'user2' }, initiator: 'user2', stream socket, user: { id: 'user2' }, initiator: 'user2', stream
}) })
peers.create({ peers.create({
socket, user: {id: 'user3' }, initiator: 'user3', stream socket, user: { id: 'user3' }, initiator: 'user3', stream
}) })
expect(peers.getIds()).toEqual([ 'user2', 'user3' ]) expect(peers.getIds()).toEqual([ 'user2', 'user3' ])
@ -165,10 +190,10 @@ describe('peers', () => {
describe('clear', () => { describe('clear', () => {
it('destroys all peers and removes them', () => { it('destroys all peers and removes them', () => {
peers.create({ peers.create({
socket, user: {id: 'user2' }, initiator: 'user2', stream socket, user: { id: 'user2' }, initiator: 'user2', stream
}) })
peers.create({ peers.create({
socket, user: {id: 'user3' }, initiator: 'user3', stream socket, user: { id: 'user3' }, initiator: 'user3', stream
}) })
peers.clear() peers.clear()
@ -179,4 +204,22 @@ describe('peers', () => {
expect(peers.getIds()).toEqual([]) expect(peers.getIds()).toEqual([])
}) })
}) })
describe('message', () => {
it('sends a message to all peers', () => {
peers.create({
socket, user: { id: 'user2' }, initiator: 'user2', stream
})
peers.create({
socket, user: { id: 'user3' }, initiator: 'user3', stream
})
peers.message('test')
expect(peers.get('user2').send.mock.calls)
.toEqual([[ '{"message":"test"}' ]])
expect(peers.get('user3').send.mock.calls)
.toEqual([[ '{"message":"test"}' ]])
})
})
}) })

View File

@ -7,7 +7,7 @@ import store from '../store.js'
const debug = _debug('peercalls') const debug = _debug('peercalls')
const { dispatch } = store const { dispatch } = store
export function init (socket, roomName, stream) { export default function init (socket, roomName, stream) {
function createPeer (user, initiator) { function createPeer (user, initiator) {
return peers.create({ socket, user, initiator, stream }) return peers.create({ socket, user, initiator, stream })
} }

View File

@ -22,7 +22,7 @@ let peers = {}
function create ({ socket, user, initiator, stream }) { function create ({ socket, user, initiator, stream }) {
debug('create peer: %s, stream:', user.id, stream) debug('create peer: %s, stream:', user.id, stream)
dispatch( dispatch(
NotifyActions.warn('Connecting to peer...') NotifyActions.warning('Connecting to peer...')
) )
if (peers[user.id]) { if (peers[user.id]) {
@ -56,7 +56,7 @@ function create ({ socket, user, initiator, stream }) {
peer.once('connect', () => { peer.once('connect', () => {
debug('peer: %s, connect', user.id) debug('peer: %s, connect', user.id)
dispatch( dispatch(
NotifyActions.warn('Peer connection established') NotifyActions.warning('Peer connection established')
) )
play() play()
}) })
@ -86,9 +86,7 @@ function create ({ socket, user, initiator, stream }) {
CallActions.removeStream(user.id) CallActions.removeStream(user.id)
) )
// make sure some other peer with same id didn't take place between calling delete peers[user.id]
// `destroy()` and `close` event
if (peers[user.id] === peer) delete peers[user.id]
}) })
} }

View File

@ -0,0 +1,95 @@
import * as NotifyActions from '../../actions/NotifyActions.js'
import { applyMiddleware, createStore } from 'redux'
import { create } from '../../middlewares.js'
import reducers from '../index.js'
jest.useFakeTimers()
describe('reducers/alerts', () => {
let store
beforeEach(() => {
store = createStore(
reducers,
applyMiddleware.apply(null, create())
)
})
describe('clearAlert', () => {
const actions = {
true: 'Dismiss',
false: ''
}
;[true, false].forEach(dismissable => {
beforeEach(() => {
store.dispatch(NotifyActions.clearAlerts())
})
it('adds alert to store', () => {
store.dispatch(NotifyActions.alert('test', dismissable))
expect(store.getState().alerts).toEqual([{
action: actions[dismissable],
dismissable,
message: 'test',
type: 'warning'
}])
})
})
})
describe('dismissAlert', () => {
it('removes an alert', () => {
store.dispatch(NotifyActions.alert('test', true))
expect(store.getState().alerts.length).toBe(1)
store.dispatch(NotifyActions.dismissAlert(store.getState().alerts[0]))
expect(store.getState().alerts.length).toBe(0)
})
it('does not remove an alert when not found', () => {
store.dispatch(NotifyActions.alert('test', true))
expect(store.getState().alerts.length).toBe(1)
store.dispatch(NotifyActions.dismissAlert({}))
expect(store.getState().alerts.length).toBe(1)
})
})
;['info', 'warning', 'error'].forEach(type => {
describe(type, () => {
beforeEach(() => {
store.dispatch(NotifyActions[type]('Hi {0}!', 'John'))
})
it('adds a notification', () => {
expect(store.getState().notifications).toEqual([{
message: 'Hi John!',
type
}])
})
it('dismisses notification after a timeout', () => {
jest.runAllTimers()
expect(store.getState().notifications).toEqual([])
})
})
})
describe('clear', () => {
it('clears all alerts', () => {
store.dispatch(NotifyActions.info('Hi {0}!', 'John'))
store.dispatch(NotifyActions.warning('Hi {0}!', 'John'))
store.dispatch(NotifyActions.error('Hi {0}!', 'John'))
store.dispatch(NotifyActions.clear())
expect(store.getState().notifications).toEqual([])
})
})
})

View File

@ -0,0 +1,70 @@
jest.mock('../../callId.js')
jest.mock('../../iceServers.js')
jest.mock('../../window/createObjectURL.js')
import * as CallActions from '../../actions/CallActions.js'
import { applyMiddleware, createStore } from 'redux'
import { create } from '../../middlewares.js'
import reducers from '../index.js'
describe('reducers/alerts', () => {
class MediaStream {}
let store, stream, userId
beforeEach(() => {
store = createStore(
reducers,
applyMiddleware.apply(null, create())
)
userId = 'test id'
stream = new MediaStream()
})
describe('defaultState', () => {
it('should have default state set', () => {
expect(store.getState().streams).toEqual({
active: null,
all: {}
})
})
})
describe('addStream', () => {
it('adds a stream', () => {
store.dispatch(CallActions.addStream({ userId, stream }))
expect(store.getState().streams).toEqual({
active: userId,
all: {
[userId]: {
userId,
stream,
url: jasmine.any(String)
}
}
})
})
})
describe('removeStream', () => {
it('removes a stream', () => {
store.dispatch(CallActions.addStream({ userId, stream }))
store.dispatch(CallActions.removeStream(userId))
expect(store.getState().streams).toEqual({
active: userId,
all: {}
})
})
})
describe('activateStream', () => {
it('activates a stream', () => {
store.dispatch(CallActions.activateStream(userId))
expect(store.getState().streams).toEqual({
active: userId,
all: {}
})
})
})
})

View File

@ -6,7 +6,9 @@ const defaultState = Immutable([])
export default function alerts (state = defaultState, action) { export default function alerts (state = defaultState, action) {
switch (action && action.type) { switch (action && action.type) {
case constants.ALERT: case constants.ALERT:
return Immutable(state.asMutable().push(action.payload)) const alerts = state.asMutable()
alerts.push(action.payload)
return Immutable(alerts)
case constants.ALERT_DISMISS: case constants.ALERT_DISMISS:
return state.filter(a => a !== action.payload) return state.filter(a => a !== action.payload)
case constants.ALERT_CLEAR: case constants.ALERT_CLEAR:

View File

@ -9,19 +9,19 @@ const defaultState = Immutable({
function addStream (state, action) { function addStream (state, action) {
const { userId, stream } = action.payload const { userId, stream } = action.payload
const streams = state.all.merge({ const all = state.all.merge({
[userId]: { [userId]: {
userId, userId,
stream, stream,
url: createObjectURL(stream) url: createObjectURL(stream)
} }
}) })
return { active: userId, streams } return state.merge({ active: userId, all })
} }
function removeStream (state, action) { function removeStream (state, action) {
const streams = state.all.without(action.payload.userId) const all = state.all.without(action.payload.userId)
return state.merge({ streams }) return state.merge({ all })
} }
export default function streams (state = defaultState, action) { export default function streams (state = defaultState, action) {

View File

@ -0,0 +1 @@
export default object => 'blob://' + String(object)

View File

@ -0,0 +1,13 @@
import Promise from 'bluebird'
class MediaStream {}
let shouldFail
export const fail = _fail => shouldFail = !!_fail
export const stream = new MediaStream()
export default function getUserMedia () {
return !shouldFail
? Promise.resolve(stream)
: Promise.reject(new Error('test'))
}