hotcoffee
Version:
Brew you some hot micro servers
544 lines (438 loc) • 17.2 kB
text/coffeescript
# file: test/unit/test_hotcoffee.coffee
should = require 'should'
sinon = require 'sinon'
EventEmitter = require('events').EventEmitter
endpoint = 'http://localhost:1337'
toOutput = (result, href)->
if result? and result.length > 0
resource = result[0].type
result = result.map (item)->
item.href = [endpoint, resource, 'id', item.props.id].join '/' unless item.href
return item
output = {
success: true
items: result
href: href
}
output.success = false if result? and result.length > 0 and result[0]['type'] == 'error'
JSON.stringify(output, null, 2) + '\n'
describe 'Hotcoffee', ->
beforeEach ->
@process = sinon.stub new EventEmitter()
@process.env = {}
@process.exit = sinon.stub()
@req = new EventEmitter()
@req.method = 'GET'
@req.url = "/turtles/name/Donatello"
@req.end = sinon.stub()
@req.headers = {}
@req.body = {}
@req.send = (data)=>
@req.emit 'data', data
@req.emit 'end'
@res =
endpoint: endpoint
result: []
end: sinon.stub()
writeHead: sinon.stub()
setHeader: sinon.stub()
req: @req
@links = links = [ { rel: 'friend', type: 'application/json', href: [@res.endpoint, 'id', 3].join('/') } ]
@log =
info: sinon.stub()
error: sinon.stub()
warn: sinon.stub()
@hotcoffee = require("#{__dirname}/../../index")(process: @process, log: @log)
@hotcoffee.server =
listen: sinon.stub()
close: sinon.stub()
@hotcoffee.db =
resource1: [
{ type: 'resource1', props: { id: 1 }, links: [] }
{ type: 'resource1', props: { id: 2, name: 'hello' }, links: [] }
{ type: 'resource1', props: { id: 3, name: 'world' }, links: [] }
]
resource2: []
@plugin = (app, opts)=>
plugin = new EventEmitter()
plugin.name = 'Superplugin'
plugin.opts = opts
return plugin
@mygreatformat.reset() if @mygreatformat?.reset?
@mygreatformat = sinon.spy (res)->
output = res.result.map (x)-> "#{JSON.stringify(x)}"
res.setHeader 'Content-Type', 'text/mygreatformat; charset=utf-8'
res.end output
@format =
'mgf': @mygreatformat # file extension
'text/mygreatformat': @mygreatformat #Mime type
describe 'init(config, done)', ->
beforeEach ->
@config =
port: 8888
log: @log
it 'should initialize with a valid config', (done)->
@hotcoffee.init @config, (err)=>
@hotcoffee.config.port.should.equal @config.port
done err
it 'should emit an "init" event with config', (done)->
@hotcoffee.on 'init', (config)=>
config.should.equal @config
done null
@hotcoffee.init @config
describe 'use(fn, opts)', ->
it 'should register new plugins', ->
@hotcoffee.use @plugin
@hotcoffee.plugins.should.have.property 'Superplugin'
it 'should emit a "use" event with fn and opts', (done)->
options =
test: 1
@hotcoffee.on 'use', (fn, opts)=>
fn.should.be.Function
opts.should.equal options
done null
@hotcoffee.use @plugin, options
it 'should log info events from the plugin', ->
@hotcoffee.use @plugin
@hotcoffee.plugins['Superplugin'].emit 'info', 'info event'
@log.info.calledOnce.should.be.true
@log.info.calledWith({plugin: 'Superplugin'}, 'info event').should.be.true
it 'should log error events from the plugin', ->
@hotcoffee.use @plugin
@hotcoffee.plugins['Superplugin'].emit 'error', 'error event'
@log.error.calledOnce.should.be.true
@log.error.calledWith({plugin: 'Superplugin'}, 'error event').should.be.true
it 'should not try to log for non emit plugins', ->
@hotcoffee.use (app, config) ->
return {name: 'test'}
@log.warn.calledOnce.should.be.true
@log.warn.calledWith({plugin: 'test'}, 'not an event emitter').should.be.true
describe 'accept(format)', ->
it 'should extend accepted formats', ->
@hotcoffee.accept @format
@hotcoffee.formats.should.have.property 'mgf'
@hotcoffee.formats.should.have.property 'text/mygreatformat'
it 'should overwrite existing formats', ->
new_json_format = (res)->
res.end 'lala'
formats =
'json': new_json_format
@hotcoffee.accept formats
@hotcoffee.formats.json.should.equal new_json_format
describe 'isRoot(url)', ->
it 'should return true if req.url is "/"', ->
req = url: '/'
@hotcoffee.isRoot(req.url).should.be.ok
it 'should return false if req.url is not "/"', ->
req = url: '/hello'
@hotcoffee.isRoot(req.url).should.be.false
describe 'onExit()', ->
beforeEach ->
@process.exit.restore() if @process.exit.restore?
it 'should emit an "exit" event', (done)->
@hotcoffee.on 'exit', done
@hotcoffee.onExit()
describe 'onSIGINT()', ->
beforeEach ->
@process.exit.restore() if @process.exit.restore?
@hotcoffee.onExit.reset() if @hotcoffee.onExit.reset?
@onExit = sinon.spy @hotcoffee, 'onExit'
it 'should call onExit()', ->
@hotcoffee.onSIGINT()
@onExit.calledOnce.should.be.ok
it 'should exit the process with 0', ->
@hotcoffee.onSIGINT()
@process.exit.calledOnce.should.be.ok
@process.exit.calledWith(0).should.be.ok
describe 'merge(dest, source)', ->
beforeEach ->
@dest =
props:
test: 0
@source =
test: 1
hello: 'world'
it 'should replace all `props` values from dest if source has the same keys', ->
@hotcoffee.merge @dest, @source
@dest.props.test.should.equal 1
it 'should add new keys to dest that are present in source', ->
@hotcoffee.merge @dest, @source
@dest.props.should.have.property 'hello', @source['hello']
describe 'writeHead(res)', ->
it 'should write HTTP headers to res', ->
@hotcoffee.writeHead @res
@res.setHeader.called.should.be.ok
describe 'parseURL(url)', ->
it 'should split the url.pathname by "/" to return 3-items array', ->
expected = ['turtles', 'name', 'Donatello']
arr = @hotcoffee.parseURL @req.url
arr.should.have.lengthOf 3
arr[i].should.equal(expected[i]) for i in [0..2]
it 'should leave out the extension if there is one', ->
extension = '.json'
@req.url = @req.url+extension
expected = ['turtles', 'name', 'Donatello']
arr = @hotcoffee.parseURL @req.url
arr[2].should.equal 'Donatello'
describe 'parseBody(req, done)', ->
it 'should parse the body of a req stream to an object', (done)->
@req.headers['content-type'] = 'application/x-www-form-urlencoded'
@hotcoffee.parseBody @req, @res, (err, body)=>
body.should.have.property 'hello', 'world'
done err
@req.send 'hello=world'
describe 'mapResult(res)', ->
it 'should execute the right format function from HTTP accept header', ->
@mygreatformat.reset()
@hotcoffee.accept @format
@res.req.headers['accept'] = 'text/mygreatformat'
@res.result = @hotcoffee.db.resource1
@hotcoffee.mapResult @res
@mygreatformat.calledOnce.should.be.ok
it 'should execute the right format function from file extension', ->
@mygreatformat.reset()
@hotcoffee.accept @format
@req.url = @req.url+'.mgf'
@req.extension = @hotcoffee.getExtension @req.url
@hotcoffee.mapResult @res, @hotcoffee.db.resource1
@mygreatformat.calledOnce.should.be.ok
describe 'onGET(req, res)', ->
it 'should emit a "GET" event with req and res', (done)->
@hotcoffee.on 'GET', (req, res)=>
req.url.should.equal @req.url
should(res).be.ok
done null
@hotcoffee.onGET @req, @res
it 'should response all items of a resource type', ->
resource = 'resource1'
@req.url = '/' + resource
output = toOutput @hotcoffee.db[resource], @res.endpoint + @req.url
@hotcoffee.onGET @req, @res
@res.end.calledOnce.should.be.ok
@res.end.calledWith(output).should.be.ok
it 'should response items that have a specific key', ->
resource = 'resource1'
key = 'name'
@req.url = "/#{resource}/#{key}"
output = toOutput (@hotcoffee.db[resource].filter (x)-> x.props[key]?), @res.endpoint + @req.url
@hotcoffee.onGET @req, @res
@res.end.calledOnce.should.be.ok
@res.end.calledWith(output).should.be.ok
it 'should response items that match a specific value of a key', ->
resource = 'resource1'
key = 'name'
value = 'hello'
@req.url = "/#{resource}/#{key}/#{value}"
output = toOutput (@hotcoffee.db[resource].filter (x)-> String(x.props[key])==String(value)), @res.endpoint + @req.url
@hotcoffee.onGET @req, @res
@res.end.calledOnce.should.be.ok
@res.end.calledWith(output).should.be.ok
it 'should populate all resources if req.url == "/"', ->
resources = ({ type:'resource', href:[@res.endpoint, name].join('/'), props: { name: name } } for name of @hotcoffee.db)
@req.url = '/'
output = toOutput resources, @res.endpoint + @req.url
@hotcoffee.onGET @req, @res
@res.end.calledOnce.should.be.ok
@res.end.calledWith(output).should.be.ok
describe 'onPOST(req, res)', ->
it 'should response an empty array if resource name is empty', (done)->
@req.url = "/"
output = toOutput [], @res.endpoint + @req.url
@hotcoffee.on 'render', (res)=>
res.result.should.be.empty
@res.end.calledOnce.should.be.ok
@res.end.calledWith(output).should.be.ok
done null
@hotcoffee.onPOST @req, @res
@req.send()
it 'should emit a "POST" event with req and res', (done)->
resource = 'resource2'
@req.url = "/#{resource}"
@hotcoffee.db[resource].should.be.empty
@hotcoffee.on 'POST', (req, res)=>
@hotcoffee.getResource(req.url).should.equal resource
@hotcoffee.db[resource].should.have.lengthOf 1
res.result[0].props.should.have.property 'hello', 'world'
done null
# simulate body parser that gets executed only in onRequest function
@req.body =
hello: 'world'
@hotcoffee.onPOST @req, @res
@req.send 'hello=world'
describe 'onPATCH(req, res)', ->
it 'should emit a "PATCH" event with req and res', (done)->
resource = 'resource1'
key = 'id'
@req.url = "/#{resource}/#{key}"
@hotcoffee.on 'PATCH', (req, res)=>
@hotcoffee.getResource(req.url).should.equal resource
done null
@hotcoffee.onPATCH @req, @res
@req.send 'hello=world'
it 'should create an empty array if resource does not exist', (done)->
resource = 'turtle'
@req.url = "/#{resource}"
output = toOutput [], @res.endpoint + @req.url
@hotcoffee.on 'render', (res)=>
res.result.should.be.empty
@res.end.calledOnce.should.be.ok
@res.end.calledWith(output).should.be.ok
done null
@hotcoffee.onPATCH @req, @res
@req.send 'hello=world'
it 'should modify the items that match a specific value of a key', (done)->
resource = 'resource1'
key = 'id'
value = 2
@req.url = "/#{resource}/#{key}/#{value}"
@hotcoffee.on 'render', (res)=>
res.result.should.have.lengthOf 1
output = toOutput (@hotcoffee.db[resource].filter (x)-> String(x.props[key])==String(value)), @res.endpoint + @req.url
@res.end.calledOnce.should.be.ok
@res.end.calledWith(output).should.be.ok
done null
@hotcoffee.onPATCH @req, @res
@req.send 'name=goodbye'
describe 'onPUT(req, res)', ->
it 'should link resources to other resources', (done)->
resource = 'resource1'
key = 'id'
value = 2
@req.url = "/#{resource}/#{key}/#{value}"
@hotcoffee.on 'render', (res)=>
res.result.should.have.lengthOf 1
res.result[0].links.should.eql @links
done null
# simulate body parser
@req.body =
links: @links
@hotcoffee.onPUT @req, @res
@req.send "links=#{JSON.stringify(@links)}"
it 'should create an empty array if resource does not exist', (done)->
resource = 'does_not_exist'
@req.url = "/#{resource}"
output = toOutput [], @res.endpoint + @req.url
@hotcoffee.on 'render', (res)=>
res.result.should.be.empty
@res.end.calledOnce.should.be.ok
@res.end.calledWith(output).should.be.ok
done null
@hotcoffee.onPUT @req, @res
@req.send "links=#{JSON.stringify(@links)}"
describe 'onDELETE(req, res)', ->
it 'should emit a "DELETE" event with req and res', (done)->
resource = 'resource1'
key = 'id'
value = 3
@req.url = "/#{resource}/#{key}/#{value}"
@hotcoffee.on 'DELETE', (req, res)=>
@hotcoffee.getResource(req.url).should.equal resource
res.result.should.have.lengthOf 1
res.result[0].props.should.have.property 'id', value
done null
@hotcoffee.onDELETE @req, @res
it 'should delete a resource collection if requested', (done)->
resource = 'resource1'
@hotcoffee.db[resource].should.have.lengthOf 3
@req.url = "/#{resource}"
output = toOutput @hotcoffee.db[resource], @res.endpoint + @req.url
@hotcoffee.on 'render', (res)=>
@res.end.calledOnce.should.be.ok
@res.end.calledWith(output).should.be.ok
@hotcoffee.db[resource].should.be.empty
done null
@hotcoffee.onDELETE @req, @res
it 'should create an empty array if resource does not exist', (done)->
resource = 'turtles'
@req.url = "/#{resource}"
output = toOutput [], @res.endpoint + @req.url
@hotcoffee.on 'render', (res)=>
res.result.should.be.empty
@res.end.calledOnce.should.be.ok
@res.end.calledWith(output).should.be.ok
done null
@hotcoffee.onDELETE @req, @res
describe 'onRequest(req, res)', ->
it 'should emit a "request" event with req and res', (done)->
@req.url = '/'
@hotcoffee.on 'request', (req, res)=>
req.url.should.equal '/'
done null
@hotcoffee.onRequest @req, @res
@req.send()
it 'should respond an error if HTTP method is not supported', (done)->
@req.method = 'STUPID'
errorMessage = "Method not supported."
@hotcoffee.on 'error', (err)=>
@res.end.calledOnce.should.be.ok
@res.end.calledWith(errorMessage).should.be.ok
done null
@hotcoffee.onRequest @req, @res
@req.send()
it 'should respond any error from a hook', (done)->
errorMessage = 'Any error'
output = toOutput [{ type: 'error', props: { message: errorMessage }}], @res.endpoint + @req.url
@hotcoffee.hook (req, res, next)->
next new Error errorMessage
@hotcoffee.on 'error', (err)=>
@res.end.calledOnce.should.be.ok
@res.end.calledWith(output).should.be.ok
done null
@hotcoffee.onRequest @req, @res
@req.send()
describe 'start()', ->
beforeEach ->
@hotcoffee.server.listen.callsArg 1
it 'should emit the running port', (done) ->
@hotcoffee.on 'start', =>
@log.info.calledWith({port: 1337}, "server started").should.be.true
done()
@hotcoffee.start()
it 'should emit a "start" event', (done)->
@hotcoffee.on 'start', ->
done null
@hotcoffee.start()
it 'should listen on the configured port', ->
port = @hotcoffee.config.port
@hotcoffee.start()
@hotcoffee.server.listen.calledOnce.should.be.ok
@hotcoffee.server.listen.calledWith(port).should.be.ok
describe 'stop()', ->
it 'should emit a "stop" event', (done)->
@hotcoffee.on 'stop', ->
done null
@hotcoffee.stop()
describe 'hook(fn)', ->
it 'should add a function to hooks', ->
fn = (req, res, next)->
hooksBefore = @hotcoffee.hooks.length
@hotcoffee.hook fn
@hotcoffee.hooks.length.should.equal hooksBefore + 1
describe 'runHooks(req, res, arr, done)', (done)->
it 'should run all passed hooks', (done)->
counter = 0
hook1 = (req, res, next)->
counter+=1
next null
hook2 = (req, res, next)->
counter+=1
next null
hooks = [hook1, hook2]
@hotcoffee.runHooks @req, @res, hooks, (err)->
counter.should.equal 2
done err
it 'should propagate any error inside a hook', (done)->
err1 = new Error 'An error'
hook1 = (req, res, next)-> next null
hook2 = (req, res, next)-> next err1
hooks = [hook1, hook2]
@hotcoffee.runHooks @req, @res, hooks, (err)->
err.should.equal err1
done null
it 'should stop executing further hooks if any error occurs', (done)->
hook1 = (req, res, next)-> next new Error('Any error')
hook2 = sinon.spy (req, res, next)-> next null
hooks = [hook1, hook2]
@hotcoffee.runHooks @req, @res, hooks, (err)->
hook2.called.should.not.be.ok
done null