Commit 71f6e279 authored by Tim Kinnane's avatar Tim Kinnane
Browse files

feat(asteroid): Connection and cache methods and tests

parent 696ea544
TN:
SF:/Volumes/x/code/rocketchat-bot-driver/src/index.ts
FN:2,(anonymous_0)
FNF:1
FNH:0
FNDA:0,(anonymous_0)
DA:2,0
DA:3,0
DA:4,0
DA:5,0
DA:6,0
DA:7,0
DA:9,0
DA:10,0
DA:11,0
DA:12,0
LF:10
LH:0
BRDA:2,0,0,0
BRDA:2,0,1,0
BRDA:2,0,2,0
BRDA:3,1,0,0
BRDA:3,1,1,0
BRDA:3,2,0,0
BRDA:3,2,1,0
BRDA:5,3,0,0
BRDA:5,3,1,0
BRDA:5,4,0,0
BRDA:5,4,1,0
BRF:11
BRH:0
end_of_record
TN:
SF:/Volumes/x/code/rocketchat-bot-driver/src/lib/asteroidInterfaces.ts
FNF:0
FNH:0
DA:1,1
DA:2,0
LF:1
LH:1
LH:0
BRF:0
BRH:0
end_of_record
TN:
SF:/Volumes/x/code/rocketchat-bot-driver/src/lib/Driver.ts
FN:19,(anonymous_1)
FN:25,(anonymous_2)
FN:26,(anonymous_3)
FNF:3
FNH:2
FNDA:2,(anonymous_1)
FNDA:2,(anonymous_2)
FNDA:0,(anonymous_3)
SF:/Volumes/x/code/rocketchat-bot-driver/src/lib/driver.ts
FN:62,connect
FN:63,(anonymous_3)
FN:70,(anonymous_4)
FN:71,(anonymous_5)
FN:73,(anonymous_6)
FN:80,(anonymous_7)
FNF:6
FNH:5
FNDA:7,connect
FNDA:7,(anonymous_3)
FNDA:4,(anonymous_4)
FNDA:0,(anonymous_5)
FNDA:4,(anonymous_6)
FNDA:4,(anonymous_7)
DA:2,1
DA:3,1
DA:4,1
DA:5,1
DA:11,1
DA:19,2
DA:20,2
DA:21,2
DA:25,2
DA:26,2
LF:10
LH:10
BRDA:19,0,0,1
BRF:1
BRH:1
DA:7,1
DA:18,1
DA:37,1
DA:62,1
DA:63,7
DA:64,7
DA:65,7
DA:69,7
DA:70,7
DA:71,7
DA:72,7
DA:73,7
DA:74,4
DA:75,4
DA:78,4
DA:80,7
DA:82,4
DA:83,3
DA:84,3
LF:23
LH:23
BRDA:62,0,0,1
BRDA:78,1,0,3
BRDA:78,1,1,1
BRDA:82,2,0,1
BRDA:82,2,1,3
BRDA:84,3,0,2
BRDA:84,3,1,1
BRF:7
BRH:7
end_of_record
TN:
SF:/Volumes/x/code/rocketchat-bot-driver/src/lib/methodCache.ts
FN:15,use
FN:25,create
FN:36,call
FN:57,get
FN:66,clear
FN:76,clearAll
FN:77,(anonymous_7)
FNF:7
FNH:7
FNDA:18,use
FNDA:6,create
FNDA:18,call
FNDA:1,get
FNDA:1,clear
FNDA:15,clearAll
FNDA:46,(anonymous_7)
DA:1,1
DA:5,1
DA:6,1
DA:15,1
DA:16,18
DA:25,1
DA:26,6
DA:27,6
DA:28,6
DA:36,1
DA:37,18
DA:38,18
DA:41,18
DA:43,2
DA:46,16
DA:47,14
DA:49,16
DA:57,1
DA:58,1
DA:66,1
DA:67,1
DA:68,1
DA:69,0
DA:76,1
DA:77,46
LF:25
LH:24
BRDA:25,0,0,4
BRDA:37,1,0,3
BRDA:37,1,1,15
BRDA:41,2,0,2
BRDA:41,2,1,16
BRDA:58,3,0,1
BRDA:58,3,1,0
BRDA:67,4,0,1
BRDA:67,4,1,0
BRDA:68,5,0,1
BRDA:68,5,1,0
BRF:11
BRH:8
end_of_record
import { expect } from 'chai'
import { Driver } from './index'
import exported from './index'
describe('Index', () => {
describe('Driver', () => {
it('class is accessible', () => {
expect(Driver).to.have.property('name', 'Driver')
})
describe('index:', () => {
it('exports all lib members', () => {
expect(Object.keys(exported)).to.eql([
'driver',
'methodCache'
])
})
})
export * from './lib/Driver'
import * as driver from './lib/driver'
import * as methodCache from './lib/methodCache'
export default {
driver,
methodCache
}
import { expect } from 'chai'
import { Driver } from './Driver'
describe('lib:', () => {
describe('Driver', () => {
describe('#constuctor', () => {
context('without url', () => {
it('takes localhost as default', () => {
const d: Driver = new Driver()
expect(d.host).to.equal('localhost:3000')
})
})
context('with localhost url', () => {
it('emits connected event', (done) => {
new Driver('localhost:3000').on('connected', () => done())
})
})
})
})
})
// @ts-ignore // Asteroid is not typed
import { createClass } from 'asteroid'
import { EventEmitter } from 'events'
import ws from 'ws'
const Asteroid = createClass()
/**
* Main interface for interacting with Rocket.Chat
* @param asteroid An Asteroid instance to connect to Meteor server
*/
export class Driver extends EventEmitter {
private asteroid: any /** @TODO update with Asteroid type (submit to tsd) */
/**
* Creates a new driver instance with given options or defaults
* @param host Rocket.Chat instance Host URL:PORT (without protocol)
*/
// @ts-ignore // host is unused (doesn't notice use in template literal)
constructor (public host = 'localhost:3000') {
super()
this.asteroid = new Asteroid({
endpoint: `ws://${host}/websocket`,
SocketConstructor: ws
})
this.asteroid.on('connected', () => this.emit('connected'))
this.asteroid.on('reconnected', () => this.emit('reconnected'))
}
}
import EventEmitter from 'events'
/**
* Patch in mock Asteroid type
* @todo Update with typing from definately typed (when available)
*/
export interface IAsteroid extends EventEmitter {
ddp: { on: (event: string, func: (doc: any) => void) => void }
connect: () => void,
disconnect: () => void,
call: (method: string, params: any) => any
apply: (method: string, params: any[]) => any
subscribe: (name: string, params: any) => any
subscriptions: ISubscription[],
unsubscribe: (id: string) => void,
createUser: (options: IUserOptions) => Promise<string>,
loginWithPassword: (options: IUserOptions) => Promise<string>,
login: (params: any) => Promise<string>,
logout: () => void
}
/**
* Patch in Asteroid subscription type
* @todo Update with typing from definately typed (when available)
*/
export interface ISubscription extends EventEmitter {
id: string
}
/**
* Patch in Asteroid user options type
* @todo Update with typing from definately typed (when available)
*/
export interface IUserOptions {
username?: string,
email?: string,
password: string
}
import sinon from 'sinon'
import { expect } from 'chai'
import * as driver from './driver'
let clock
describe('lib:', function () {
this.timeout(5000)
describe('driver', () => {
describe('.connect', () => {
context('with localhost connection', () => {
it('without args, returns a promise', () => {
const promise = driver.connect()
expect(promise.then).to.be.a('function')
promise.catch((err) => console.error(err))
return promise
})
it('accepts an error-first callback, providing asteroid', (done) => {
driver.connect({}, (err, asteroid) => {
expect(err).to.equal(null)
expect(asteroid).to.be.an('object')
done()
})
})
it('without url takes localhost as default', (done) => {
driver.connect({}, (err, asteroid) => {
expect(err).to.eql(null)
expect(asteroid.endpoint).to.contain('localhost:3000')
done()
})
})
})
context('with timeout, on expiry', () => {
beforeEach(() => clock = sinon.useFakeTimers())
afterEach(() => clock.restore())
it('returns error', (done) => {
let opts = { host: 'localhost:3000', timeout: 10 }
driver.connect(opts, (err) => {
expect(err).to.be.an('error')
done()
})
clock.tick(20)
})
it('with url, attempts connection at URL', (done) => {
let opts = { host: 'localhost:9999', timeout: 10 }
driver.connect(opts, (err, asteroid) => {
expect(err).to.be.an('error')
expect(asteroid.endpoint).to.contain('localhost:9999')
expect(asteroid.ddp.status).to.equal('disconnected')
done()
})
clock.tick(20)
})
it('without callback, triggers promise catch', () => {
const promise = driver.connect({ host: 'localhost:9999', timeout: 20 })
.catch((err) => expect(err).to.be.an('error'))
clock.tick(30)
return promise
})
it('with callback, provides error to callback', (done) => {
driver.connect({ host: 'localhost:9999', timeout: 10 }, (err) => {
expect(err).to.be.an('error')
done()
})
clock.tick(30)
})
})
})
})
})
// @ts-ignore // Asteroid is not typed
import { createClass } from 'asteroid'
import EventEmitter from 'events'
import ws from 'ws'
import * as methodCache from './methodCache'
import { IAsteroid } from './asteroidInterfaces'
const Asteroid = createClass()
/**
* Connection options type
* @param host Rocket.Chat instance Host URL:PORT (without protocol)
* @param timeout How long to wait (ms) before abandonning connection
*/
export interface IOptions {
host?: string,
timeout?: number
}
export const defaults: IOptions = {
host: 'localhost:3000',
timeout: 20 * 1000 // 20 seconds
}
/**
* Error-first callback param type
*/
export interface ICallback {
(error: Error | null, result?: any): void
}
/**
* Event Emitter for listening to connection
* @example
* import { driver } from 'rocketchat-bot-driver'
* driver.connect()
* driver.events.on('connected', () => console.log('driver connected'))
*/
export const events = new EventEmitter()
/**
* An Asteroid instance for interacting with Rocket.Chat
*/
export let asteroid: IAsteroid
/**
* Initialise asteroid instance with given options or defaults
* @example <caption>Use with callback</caption>
* import { driver } from 'rocketchat-bot-driver'
* driver.connect({}, (err, asteroid) => {
* if (err) throw err
* else constole.log(asteroid)
* })
* @example <caption>Using promise</caption>
* import { driver } from 'rocketchat-bot-driver'
* driver.connect()
* .then((asteroid) => {
* console.log(asteroid)
* })
* .catch((err) => {
* console.error(err)
* })
*/
export function connect (options: IOptions = {}, callback?: ICallback): Promise<any> {
return new Promise<IAsteroid>((resolve, reject) => {
options = Object.assign(defaults, options)
asteroid = new Asteroid({
endpoint: `ws://${options.host}/websocket`,
SocketConstructor: ws
})
methodCache.use(asteroid) // init instance for later caching method calls
asteroid.on('connected', () => events.emit('connected'))
asteroid.on('reconnected', () => events.emit('reconnected'))
let cancelled = false
const rejectionTimeout = setTimeout(() => {
cancelled = true
const err = new Error('Asteroid connection timeout')
// if no callback available, reject the promise
// else, return callback using "error-first-pattern"
return callback ? callback(err, asteroid) : reject(err)
}, options.timeout)
asteroid.once('connected', () => {
// cancel connection and don't resolve if already rejected
if (cancelled) return asteroid.disconnect()
clearTimeout(rejectionTimeout)
return (callback !== undefined) ? callback(null, asteroid) : resolve(asteroid)
})
})
}
import sinon from 'sinon'
import { expect } from 'chai'
import LRU from 'lru-cache'
import * as methodCache from './methodCache'
// Instance method variance for testing cache
const mockInstance = { methodOne: sinon.stub(), methodTwo: sinon.stub() }
mockInstance.methodOne.onCall(0).returns('foo')
mockInstance.methodOne.onCall(1).returns('bar')
mockInstance.methodTwo.withArgs('key').returns('value')
describe('lib:', () => {
beforeEach(() => {
mockInstance.methodOne.resetHistory()
mockInstance.methodTwo.resetHistory()
})
afterEach(() => {
methodCache.clearAll()
})
describe('methodCache', () => {
describe('use', () => {
it('calls apply to instance', () => {
methodCache.use(mockInstance)
methodCache.call('methodOne', 'key')
expect(mockInstance.methodOne.callCount).to.equal(1)
})
it('accepts a class instance', () => {
class MyClass {}
const myInstance = new MyClass()
const shouldWork = () => methodCache.use(myInstance)
expect(shouldWork).to.not.throw()
})
})
describe('create', () => {
it('returns a cache for method calls', () => {
expect(methodCache.create('anyMethod')).to.be.instanceof(LRU)
})
it('accepts options overriding defaults', () => {
const cache = methodCache.create('methodOne', { maxAge: 3000 })
expect(cache.max).to.equal(methodCache.defaults.max)
expect(cache.maxAge).to.equal(3000)
})
})
describe('call', () => {
it('throws if instance not in use', () => {
const badUse = () => methodCache.call('methodOne', 'key')
expect(badUse).to.throw()
})
it('throws if method does not exist', () => {
methodCache.use(mockInstance)
const badUse = () => methodCache.call('bad', 'key')
expect(badUse).to.throw()
})
it('returns a promise', () => {
methodCache.use(mockInstance)
expect(methodCache.call('methodOne', 'key').then).to.be.a('function')
})
it('calls the method with the key', () => {
methodCache.use(mockInstance)
return methodCache.call('methodTwo', 'key').then((result) => {
expect(result).to.equal('value')
})
})
it('only calls the method once', () => {
methodCache.use(mockInstance)
methodCache.call('methodOne', 'key')
methodCache.call('methodOne', 'key')
expect(mockInstance.methodOne.callCount).to.equal(1)
})
it('returns cached result on subsequent calls', () => {
methodCache.use(mockInstance)
return Promise.all([
methodCache.call('methodOne', 'key'),
methodCache.call('methodOne', 'key')
]).then((results) => {
expect(results[0]).to.equal(results[1])
})
})
it('calls again if cache expired', () => {
const clock = sinon.useFakeTimers()
methodCache.use(mockInstance)
methodCache.create('methodOne', { maxAge: 10 })
const result1 = methodCache.call('methodOne', 'key')
clock.tick(20)
const result2 = methodCache.call('methodOne', 'key')
clock.restore()
return Promise.all([result1, result2]).then((results) => {
expect(mockInstance.methodOne.callCount).to.equal(2)
expect(results[0]).to.not.equal(results[1])
})
})
})
describe('get', () => {
it('returns cached result from last call', () => {
methodCache.use(mockInstance)
return methodCache.call('methodOne', 'key').then((result) => {
expect(methodCache.get('methodOne', 'key')).to.equal(result)
})
})
})
describe('clear', () => {
it('removes cached results for a method', () => {
methodCache.use(mockInstance)
const result1 = methodCache.call('methodOne', 'key')
methodCache.clear('methodOne', 'key')
const result2 = methodCache.call('methodOne', 'key')
expect(result1).not.to.equal(result2)
})
})
describe('clearAll', () => {
it('clears all cached methods', () => {
methodCache.use(mockInstance)
methodCache.call('methodOne', 'key')
methodCache.call('methodTwo', 'key')
methodCache.clearAll()
methodCache.call('methodOne', 'key')
methodCache.call('methodTwo', 'key')
expect(mockInstance.methodOne.callCount).to.equal(2)
expect(mockInstance.methodTwo.callCount).to.equal(2)
})
})
})
})
import LRU, { Cache } from 'lru-cache'
/** @TODO: Remove ! post-fix expression when TypeScript #9619 resolved */
let instance: any
export const results: Map<string, Cache<string, any>> = new Map()
export const defaults: LRU.Options = {
max: 100,
maxAge: 300 * 1000
}
/**
* Set the instance to call methods on, with cached results
* @param instanceToUse Instance of a class
*/
export function use (instanceToUse: object) {
instance = instanceToUse
}
/**
* Setup a cache for a method call
* @param method Method name, for index of cached results
* @param max Maximum size of cache
* @param maxAge Maximum age of cache
*/
export function create (method: string, options: LRU.Options = {}) {
options = Object.assign(defaults, options)
results.set(method, new LRU(options))
return results.get(method)
}
/**
* Get results of a prior method call or call and cache - always a promise
* @param method Method name, to call on instance in use
* @param key Key to pass to method call and save results against
*/
export function call (method: string, key: string) {
if (!results.has(method)) create(method) // create as needed
const methodCache = results.get(method)!
let callResults
if (methodCache.has(key)) {
// return from cache if key has been used on method before
callResults = methodCache.get(key)
} else {