Refactor /db/article to use generators

This commit is contained in:
Scott Erickson 2015-12-16 17:09:22 -08:00
parent 1d57199707
commit 7fb08f343a
23 changed files with 1450 additions and 135 deletions

View file

@ -56,6 +56,8 @@
"async": "0.2.x",
"aws-sdk": "~2.0.0",
"bayesian-battle": "0.0.7",
"bluebird": "^3.2.1",
"co-express": "^1.2.1",
"coffee-script": "1.9.x",
"connect": "2.7.x",
"express": "~3.0.6",

View file

@ -36,4 +36,8 @@ ArticleSchema.plugin(plugins.SearchablePlugin, {searchable: ['body', 'name']})
ArticleSchema.plugin(plugins.TranslationCoveragePlugin)
ArticleSchema.plugin(plugins.PatchablePlugin)
ArticleSchema.postEditableProperties = []
ArticleSchema.editableProperties = ['body', 'name', 'i18n', 'i18nCoverage']
ArticleSchema.jsonSchema = require '../../app/schemas/models/article'
module.exports = mongoose.model('article', ArticleSchema)

View file

@ -1,10 +1,12 @@
# TODO: Remove once mapping.coffee is refactored out
Article = require './Article'
Handler = require '../commons/Handler'
ArticleHandler = class ArticleHandler extends Handler
modelClass: Article
editableProperties: ['body', 'name', 'i18n']
jsonSchema: require '../../app/schemas/models/article'
editableProperties: Article.schema.editableProperties
jsonSchema: Article.schema.jsonSchema
hasAccess: (req) ->
req.method is 'GET' or req.user?.isAdmin() or req.user?.isArtisan()

View file

@ -1,4 +1,5 @@
log = require 'winston'
_ = require 'lodash'
module.exports.custom = (res, code=500, message='Internal Server Error') ->
log.debug "#{code}: #{message}"
@ -56,3 +57,85 @@ module.exports.clientTimeout = (res, message='The server did not receive the cli
log.debug "408: #{message}"
res.send 408, message
res.end()
# Objects
errorResponseSchema = {
type: 'object'
required: ['errorName', 'code', 'message']
properties: {
error: {
description: 'Error object which the callback returned'
}
errorName: {
type: 'string'
description: 'Human readable error code name'
}
code: {
type: 'integer'
description: 'HTTP error code'
}
validationErrors: {
type: 'array'
description: 'TV4 array of validation error objects'
}
message: {
type: 'string'
description: 'Human readable descripton of the error'
}
property: {
type: 'string'
description: 'Property which is related to the error (conflict, validation).'
}
}
}
errorProps = _.keys(errorResponseSchema.properties)
class NetworkError
code: 0
constructor: (@message, options) ->
@stack = (new Error()).stack
_.assign(@, options)
toJSON: ->
_.pick(@, errorProps...)
module.exports.NetworkError = NetworkError
module.exports.Unauthorized = class Unauthorized extends NetworkError
code: 401
name: 'Unauthorized'
module.exports.Forbidden = class Forbidden extends NetworkError
code: 403
name: 'Forbidden'
module.exports.NotFound = class NotFound extends NetworkError
code: 404
name: 'Not Found'
module.exports.MethodNotAllowed = class MethodNotAllowed extends NetworkError
code: 405
name: 'Method Not Allowed'
module.exports.RequestTimeout = class RequestTimeout extends NetworkError
code: 407
name: 'Request Timeout'
module.exports.Conflict = class Conflict extends NetworkError
code: 409
name: 'Conflict'
module.exports.UnprocessableEntity = class UnprocessableEntity extends NetworkError
code: 422
name: 'Unprocessable Entity'
module.exports.InternalServerError = class InternalServerError extends NetworkError
code: 500
name: 'Internal Server Error'
module.exports.GatewayTimeout = class GatewayTimeout extends NetworkError
code: 504
name: 'Gateway Timeout'

View file

@ -2,8 +2,12 @@ AnalyticsString = require '../analytics/AnalyticsString'
log = require 'winston'
mongoose = require 'mongoose'
config = require '../../server_config'
errors = require '../commons/errors'
_ = require 'lodash'
Promise = require 'bluebird'
deltasLib = require '../../app/core/deltas'
module.exports =
module.exports = utils =
isID: (id) -> _.isString(id) and id.length is 24 and id.match(/[a-f0-9]/gi)?.length is 24
getCodeCamel: (numWords=3) ->
@ -92,3 +96,219 @@ module.exports =
@analyticsStringCache[str] = document._id
return callback @analyticsStringCache[str]
insertString()
getLimitFromReq: (req, options) ->
options = _.extend({
max: 1000
default: 100
}, options)
limit = options.default
if req.query.limit
limit = parseInt(req.query.limit)
valid = tv4.validate(limit, {
type: 'integer'
maximum: options.max
minimum: 1
})
if not valid
throw new errors.UnprocessableEntity('Invalid limit parameter.')
return limit
getSkipFromReq: (req, options) ->
options = _.extend({
max: 1000000
default: 0
}, options)
skip = options.default
if req.query.skip
skip = parseInt(req.query.skip)
valid = tv4.validate(skip, {
type: 'integer'
maximum: options.max
minimum: 0
})
if not valid
throw new errors.UnprocessableEntity('Invalid sort parameter.')
return skip
getProjectFromReq: (req, options) ->
options = _.extend({}, options)
return null unless req.query.project
projection = {}
if req.query.project is 'true'
projection = {original: 1, name: 1, version: 1, description: 1, slug: 1, kind: 1, created: 1, permissions: 1}
else
for field in req.query.project.split(',')
projection[field] = 1
return projection
applyCustomSearchToDBQ: (req, dbq) ->
specialParameters = ['term', 'project', 'conditions']
return unless req.user?.isAdmin()
return unless req.query.filter or req.query.conditions
# admins can send any sort of query down the wire
# Example URL: http://localhost:3000/db/user?filter[anonymous]=true
filter = {}
if 'filter' of req.query
for own key, val of req.query.filter
if key not in specialParameters
try
filter[key] = JSON.parse(val)
catch SyntaxError
throw new errors.UnprocessableEntity("Could not parse filter for key '#{key}'.")
dbq.find(filter)
# Conditions are chained query functions, for example: query.find().limit(20).sort('-dateCreated')
# Example URL: http://localhost:3000/db/user?conditions[limit]=20&conditions[sort]="-dateCreated"
for own key, val of req.query.conditions
if not dbq[key]
throw new errors.UnprocessableEntity("No query condition '#{key}'.")
try
val = JSON.parse(val)
dbq[key](val)
catch SyntaxError
throw new errors.UnprocessableEntity("Could not parse condition for key '#{key}'.")
viewSearch: (dbq, req, done) ->
Model = dbq.model
# TODO: Make this function only alter dbq or returns a find. It should not also execute the query.
term = req.query.term
matchedObjects = []
filters = if Model.schema.uses_coco_versions or Model.schema.uses_coco_permissions then [filter: {index: true}] else [filter: {}]
if Model.schema.uses_coco_permissions and req.user
filters.push {filter: {index: req.user.get('id')}}
for filter in filters
callback = (err, results) ->
return done(new errors.InternalServerError('Error fetching search results.', {err: err})) if err
for r in results.results ? results
obj = r.obj ? r
continue if obj in matchedObjects # TODO: probably need a better equality check
matchedObjects.push obj
filters.pop() # doesn't matter which one
unless filters.length
done(null, matchedObjects)
if term
filter.filter.$text = $search: term
else if filters.length is 1 and filters[0].filter?.index is true
# All we are doing is an empty text search, but that doesn't hit the index,
# so we'll just look for the slug.
filter.filter = slug: {$exists: true}
# This try/catch is here to handle when a custom search tries to find by slug. TODO: Fix this more gracefully.
try
dbq.find filter.filter
catch
dbq.exec callback
assignBody: (req, doc, options={}) ->
if _.isEmpty(req.body)
throw new errors.UnprocessableEntity('No input')
props = doc.schema.editableProperties.slice()
if doc.isNew
props = props.concat doc.schema.postEditableProperties
if doc.schema.uses_coco_permissions and req.user
isOwner = doc.getAccessForUserObjectId(req.user._id) is 'owner'
if doc.isNew or isOwner or req.user?.isAdmin()
props.push 'permissions'
props.push 'commitMessage' if doc.schema.uses_coco_versions
props.push 'allowPatches' if doc.schema.is_patchable
for prop in props
if (val = req.body[prop])?
doc.set prop, val
else if options.unsetMissing and doc.get(prop)?
doc.set prop, undefined
validateDoc: (doc) ->
obj = doc.toObject()
# Hack to get saving of Users to work. Probably should replace these props with strings
# so that validation doesn't get hung up on Date objects in the documents.
delete obj.dateCreated
tv4 = require('tv4').tv4
result = tv4.validateMultiple(obj, doc.schema.jsonSchema)
if not result.valid
throw new errors.UnprocessableEntity('JSON-schema validation failed', { validationErrors: result.errors })
getDocFromHandle: (req, Model, options, done) ->
if _.isFunction(options)
done = options
options = {}
dbq = Model.find()
handle = req.params.handle
if not handle
return done(new errors.UnprocessableEntity('No handle provided.'))
if utils.isID(handle)
dbq.findOne({ _id: handle })
else
dbq.findOne({ slug: handle })
dbq.exec(done)
initDoc: (req, Model) ->
# TODO: Move to model superclass or plugins?
doc = new Model({})
if Model.schema.is_patchable
watchers = [req.user.get('_id')]
if req.user.isAdmin() # https://github.com/codecombat/codecombat/issues/1105
nick = mongoose.Types.ObjectId('512ef4805a67a8c507000001')
watchers.push nick unless _.find watchers, (id) -> id.equals nick
doc.set 'watchers', watchers
if Model.schema.uses_coco_versions
doc.set('original', doc._id)
doc.set('creator', req.user._id)
hasAccessToDocument: (req, doc, method) ->
method = method or req.method
return true if req.user?.isAdmin()
if doc.schema.uses_coco_translation_coverage and method in ['post', 'put']
return true if @isJustFillingTranslations(req, doc)
if doc.schema.uses_coco_permissions
return doc.hasPermissionsForMethod?(req.user, method)
return true
isJustFillingTranslations: (req, doc) ->
differ = deltasLib.makeJSONDiffer()
omissions = ['original'].concat(deltasLib.DOC_SKIP_PATHS)
delta = differ.diff(_.omit(doc.toObject(), omissions), _.omit(req.body, omissions))
flattened = deltasLib.flattenDelta(delta)
_.all flattened, (delta) ->
# sometimes coverage gets moved around... allow other changes to happen to i18nCoverage
return false unless _.isArray(delta.o)
return true if 'i18nCoverage' in delta.dataPath
return false unless delta.o.length is 1
index = delta.deltaPath.indexOf('i18n')
return false if index is -1
return false if delta.deltaPath[index+1] in ['en', 'en-US', 'en-GB'] # English speakers are most likely just spamming, so always treat those as patches, not saves.
return true
Promise.promisifyAll(module.exports)

View file

@ -0,0 +1,31 @@
# Middleware for both authentication and authorization
errors = require '../commons/errors'
module.exports = {
checkDocumentPermissions: (req, res, next) ->
return next() if req.user?.isAdmin()
if not req.doc.hasPermissionsForMethod(req.user, req.method)
if req.user
return next new errors.Forbidden('You do not have permissions necessary.')
return next new errors.Unauthorized('You must be logged in.')
next()
checkLoggedIn: ->
return (req, res, next) ->
if not req.user
return next new errors.Unauthorized('You must be logged in.')
next()
checkHasPermission: (permissions) ->
if _.isString(permissions)
permissions = [permissions]
return (req, res, next) ->
if not req.user
return next new errors.Unauthorized('You must be logged in.')
if not _.size(_.intersection(req.user.get('permissions'), permissions))
return next new errors.Forbidden('You do not have permissions necessary.')
next()
}

View file

@ -0,0 +1,20 @@
utils = require '../lib/utils'
errors = require '../commons/errors'
wrap = require 'co-express'
Grid = require 'gridfs-stream'
Promise = require 'bluebird'
module.exports =
files: (Model, options={}) -> wrap (req, res) ->
doc = yield utils.getDocFromHandleAsync(req, Model)
if not doc
throw new errors.NotFound('Document not found.')
module = options.module or req.path[4..].split('/')[0]
query = { 'metadata.path': "db/#{module}/#{doc.id}" }
c = Grid.gfs.collection('media')
c.findAsync = Promise.promisify(c.find)
cursor = yield c.findAsync(query)
cursor.toArrayAsync = Promise.promisify(cursor.toArray)
files = yield cursor.toArrayAsync()
res.status(200).send(files)

View file

@ -0,0 +1,7 @@
module.exports =
auth: require './auth'
files: require './files'
named: require './named'
patchable: require './patchable'
rest: require './rest'
versions: require './versions'

View file

@ -0,0 +1,31 @@
utils = require '../lib/utils'
errors = require '../commons/errors'
wrap = require 'co-express'
Promise = require 'bluebird'
module.exports =
names: (Model, options={}) -> wrap (req, res) ->
# TODO: migrate to /db/collection?ids=...&project=... and /db/collection?originals=...&project=...
ids = req.query.ids or req.body.ids
ids = ids.split(',') if _.isString ids
ids = _.uniq ids
# Hack: levels loading thang types need the components returned as well.
# Need a way to specify a projection for a query.
project = {name: 1, original: 1, kind: 1, components: 1, prerenderedSpriteSheetData: 1}
sort = if Model.schema.uses_coco_versions then {'version.major': -1, 'version.minor': -1} else {}
for id in ids
if not utils.isID(id)
throw new errors.UnprocessableEntity('Invalid MongoDB id given')
ids = (mongoose.Types.ObjectId(id) for id in ids)
promises = []
for id in ids
q = if Model.schema.uses_coco_versions then { original: id } else { _id: id }
promises.push Model.findOne(q).select(project).sort(sort).exec()
documents = yield promises
res.status(200).send(documents)

View file

@ -0,0 +1,50 @@
utils = require '../lib/utils'
errors = require '../commons/errors'
wrap = require 'co-express'
Promise = require 'bluebird'
Patch = require '../models/Patch'
module.exports =
patches: (options={}) -> wrap (req, res) ->
dbq = Patch.find()
dbq.limit(utils.getLimitFromReq(req))
dbq.skip(utils.getSkipFromReq(req))
dbq.select(utils.getProjectFromReq(req))
id = req.params.handle
if not utils.isID(id)
throw new errors.UnprocessableEntity('Invalid ID')
query =
$or: [
{'target.original': id+''}
{'target.original': mongoose.Types.ObjectId(id)}
]
status: req.query.status or 'pending'
patches = yield dbq.find(query).sort('-created')
res.status(200).send(patches)
joinWatchers: (Model, options={}) -> wrap (req, res) ->
doc = yield utils.getDocFromHandleAsync(req, Model)
if not doc
throw new errors.NotFound('Document not found.')
if not utils.hasAccessToDocument(req, doc, 'get')
throw new errors.Forbidden()
updateResult = yield doc.update({ $addToSet: { watchers: req.user.get('_id') }})
if updateResult.nModified
watchers = doc.get('watchers')
watchers.push(req.user.get('_id'))
doc.set('watchers', watchers)
res.status(200).send(doc)
leaveWatchers: (Model, options={}) -> wrap (req, res) ->
doc = yield utils.getDocFromHandleAsync(req, Model)
if not doc
throw new errors.NotFound('Document not found.')
updateResult = yield doc.update({ $pull: { watchers: req.user.get('_id') }})
if updateResult.nModified
watchers = doc.get('watchers')
watchers = _.filter watchers, (id) -> not id.equals(req.user._id)
doc.set('watchers', watchers)
res.status(200).send(doc)

View file

@ -0,0 +1,40 @@
utils = require '../lib/utils'
errors = require '../commons/errors'
wrap = require 'co-express'
module.exports =
get: (Model, options={}) -> wrap (req, res) ->
dbq = Model.find()
dbq.limit(utils.getLimitFromReq(req))
dbq.skip(utils.getSkipFromReq(req))
dbq.select(utils.getProjectFromReq(req))
utils.applyCustomSearchToDBQ(req, dbq)
if Model.schema.uses_coco_translation_coverage and req.query.view is 'i18n-coverage'
dbq.find({ slug: {$exists: true}, i18nCoverage: {$exists: true} })
results = yield utils.viewSearchAsync(dbq, req)
res.send(results)
post: (Model, options={}) -> wrap (req, res) ->
doc = utils.initDoc(req, Model)
utils.assignBody(req, doc)
utils.validateDoc(doc)
doc = yield doc.save()
res.status(201).send(doc.toObject())
getByHandle: (Model, options={}) -> wrap (req, res) ->
doc = yield utils.getDocFromHandleAsync(req, Model)
if not doc
throw new errors.NotFound('Document not found.')
res.status(200).send(doc.toObject())
put: (Model, options={}) -> wrap (req, res) ->
doc = yield utils.getDocFromHandleAsync(req, Model)
if not doc
throw new errors.NotFound('Document not found.')
utils.assignBody(req, doc)
utils.validateDoc(doc)
doc = yield doc.save()
res.status(200).send(doc.toObject())

View file

@ -0,0 +1,168 @@
utils = require '../lib/utils'
errors = require '../commons/errors'
User = require '../users/User'
sendwithus = require '../sendwithus'
hipchat = require '../hipchat'
_ = require 'lodash'
wrap = require 'co-express'
mongoose = require 'mongoose'
module.exports =
postNewVersion: (Model, options={}) -> wrap (req, res) ->
parent = yield utils.getDocFromHandleAsync(req, Model)
if not parent
throw new errors.NotFound('Parent not found.')
# TODO: Figure out a better way to do this
if options.hasPermissionsOrTranslations
permissions = options.hasPermissionsOrTranslations
permissions = [permissions] if _.isString(permissions)
permissions = ['admin'] if not _.isArray(permissions)
hasPermission = _.any(req.user?.hasPermission(permission) for permission in permissions)
if not (hasPermission or utils.isJustFillingTranslations(req, parent))
throw new errors.Forbidden()
doc = utils.initDoc(req, Model)
ATTRIBUTES_NOT_INHERITED = ['_id', 'version', 'created', 'creator']
doc.set(_.omit(parent.toObject(), ATTRIBUTES_NOT_INHERITED))
utils.assignBody(req, doc, { unsetMissing: true })
# Get latest version
major = req.body.version?.major
original = parent.get('original')
if _.isNumber(major)
q1 = Model.findOne({original: original, 'version.isLatestMinor': true, 'version.major': major})
else
q1 = Model.findOne({original: original, 'version.isLatestMajor': true})
q1.select 'version'
latest = yield q1.exec()
if not latest
# handle the case where no version is marked as latest, since making new
# versions is not atomic
if _.isNumber(major)
q2 = Model.findOne({original: original, 'version.major': major})
q2.sort({'version.minor': -1})
else
q2 = Model.findOne()
q2.sort({'version.major': -1, 'version.minor': -1})
q2.select 'version'
latest = yield q2.exec()
if not latest
throw new errors.NotFound('Previous version not found.')
# Transfer latest version
major = req.body.version?.major
version = _.clone(latest.get('version'))
wasLatestMajor = version.isLatestMajor
version.isLatestMajor = false
if _.isNumber(major)
version.isLatestMinor = false
conditions = {_id: latest._id}
raw = yield Model.update(conditions, {version: version, $unset: {index: 1, slug: 1}})
if not raw.nModified
console.error('Conditions', conditions)
console.error('Doc', doc)
console.error('Raw response', raw)
throw new errors.InternalServerError('Latest version could not be modified.')
# update the new doc with version, index information
# Relying heavily on Mongoose schema default behavior here. TODO: Make explicit?
if _.isNumber(major)
doc.set({
'version.major': latest.version.major
'version.minor': latest.version.minor + 1
'version.isLatestMajor': wasLatestMajor
})
if wasLatestMajor
doc.set('index', true)
else
doc.set({index: undefined, slug: undefined})
else
doc.set('version.major', latest.version.major + 1)
doc.set('index', true)
doc.set('parent', latest._id)
doc = yield doc.save()
editPath = req.headers['x-current-path']
docLink = "http://codecombat.com#{editPath}"
# Post a message on HipChat
message = "#{req.user.get('name')} saved a change to <a href=\"#{docLink}\">#{doc.get('name')}</a>: #{doc.get('commitMessage') or '(no commit message)'}"
rooms = if /Diplomat submission/.test(message) then ['main'] else ['main', 'artisans']
hipchat.sendHipChatMessage message, rooms
# Send emails to watchers
watchers = doc.get('watchers') or []
# Don't send these emails to the person who submitted the patch, or to Nick, George, or Scott.
watchers = (w for w in watchers when not w.equals(req.user.get('_id')) and not (w + '' in ['512ef4805a67a8c507000001', '5162fab9c92b4c751e000274', '51538fdb812dd9af02000001']))
if watchers.length
User.find({_id:{$in:watchers}}).select({email:1, name:1}).exec (err, watchers) ->
for watcher in watchers
context =
email_id: sendwithus.templates.change_made_notify_watcher
recipient:
address: watcher.get('email')
name: watcher.get('name')
email_data:
doc_name: doc.get('name') or '???'
submitter_name: req.user.get('name') or '???'
doc_link: if editPath then docLink else null
commit_message: doc.get('commitMessage')
sendwithus.api.send context, _.noop
res.status(201).send(doc.toObject())
getLatestVersion: (Model, options={}) -> wrap (req, res) ->
# can get latest overall version, latest of a major version, or a specific version
original = req.params.handle
version = req.params.version
if not utils.isID(original)
throw new errors.UnprocessableEntity('Invalid MongoDB id: '+original)
query = { 'original': mongoose.Types.ObjectId(original) }
if version?
version = version.split('.')
majorVersion = parseInt(version[0])
minorVersion = parseInt(version[1])
query['version.major'] = majorVersion unless _.isNaN(majorVersion)
query['version.minor'] = minorVersion unless _.isNaN(minorVersion)
dbq = Model.findOne(query)
dbq.sort({ 'version.major': -1, 'version.minor': -1 })
# Make sure that permissions and version are fetched, but not sent back if they didn't ask for them.
projection = utils.getProjectFromReq(req)
if projection
extraProjectionProps = []
extraProjectionProps.push 'permissions' unless projection.permissions
extraProjectionProps.push 'version' unless projection.version
projection.permissions = 1
projection.version = 1
dbq.select(projection)
doc = yield dbq.exec()
throw new errors.NotFound() if not doc
throw new errors.Forbidden() unless utils.hasAccessToDocument(req, doc)
doc = _.omit doc, extraProjectionProps if extraProjectionProps?
res.status(200).send(doc.toObject())
versions: (Model, options={}) -> wrap (req, res) ->
original = req.params.handle
dbq = Model.find({'original': mongoose.Types.ObjectId(original)})
dbq.sort({'created': -1})
dbq.limit(utils.getLimitFromReq(req))
dbq.skip(utils.getSkipFromReq(req))
dbq.select(utils.getProjectFromReq(req) or 'slug name version commitMessage created creator permissions')
results = yield dbq.exec()
res.status(200).send(results)

View file

@ -0,0 +1,3 @@
# TODO: Migrate Article to here
module.exports = require '../articles/Article'

View file

@ -0,0 +1,3 @@
# TODO: Migrate Patch to here
module.exports = require '../patches/Patch'

View file

@ -179,7 +179,7 @@ module.exports.VersionedPlugin = (schema) ->
latest = latest[0]
# don't fix missing versions by default. In all likelihood, it's about to change anyway
if options.autofix
if options.autofix # not used
latest.version.isLatestMajor = true
latest.version.isLatestMinor = true
latestObject = latest.toObject()
@ -204,7 +204,7 @@ module.exports.VersionedPlugin = (schema) ->
return done(null, null) if latest.length is 0
latest = latest[0]
if options.autofix
if options.autofix # not used
latestObject = latest.toObject()
latestObject.version.isLatestMajor = true
latestObject.version.isLatestMinor = true

View file

@ -1,4 +1,22 @@
mw = require '../middleware'
module.exports.setup = (app) ->
Article = require '../models/Article'
app.get('/db/article', mw.rest.get(Article))
app.post('/db/article', mw.auth.checkHasPermission(['admin', 'artisan']), mw.rest.post(Article))
app.get('/db/article/names', mw.named.names(Article))
app.post('/db/article/names', mw.named.names(Article))
app.get('/db/article/:handle', mw.rest.getByHandle(Article))
app.put('/db/article/:handle', mw.auth.checkHasPermission(['admin']), mw.rest.put(Article))
app.patch('/db/article/:handle', mw.auth.checkHasPermission(['admin']), mw.rest.put(Article))
app.post('/db/article/:handle/new-version', mw.auth.checkLoggedIn(), mw.versions.postNewVersion(Article, { hasPermissionsOrTranslations: 'artisan' }))
app.get('/db/article/:handle/versions', mw.versions.versions(Article))
app.get('/db/article/:handle/version/?(:version)?', mw.versions.getLatestVersion(Article))
app.get('/db/article/:handle/files', mw.files.files(Article, {module: 'article'}))
app.get('/db/article/:handle/patches', mw.patchable.patches(Article))
app.post('/db/article/:handle/watchers', mw.patchable.joinWatchers(Article))
app.delete('/db/article/:handle/watchers', mw.patchable.leaveWatchers(Article))
app.get '/db/products', require('./db/product').get
app.get '/healthcheck', (req, res) ->

View file

@ -45,6 +45,14 @@ UserSchema.methods.isInGodMode = ->
UserSchema.methods.isAdmin = ->
p = @get('permissions')
return p and 'admin' in p
UserSchema.methods.hasPermission = (neededPermissions) ->
permissions = @get('permissions') or []
if _.contains(permissions, 'admin')
return true
if _.isString(neededPermissions)
neededPermissions = [neededPermissions]
return _.size(_.intersection(permissions, neededPermissions))
UserSchema.methods.isArtisan = ->
p = @get('permissions')

View file

@ -20,6 +20,7 @@ hipchat = require './server/hipchat'
global.tv4 = require 'tv4' # required for TreemaUtils to work
global.jsondiffpatch = require 'jsondiffpatch'
global.stripe = require('stripe')(config.stripe.secretKey)
errors = require './server/commons/errors'
productionLogging = (tokens, req, res) ->
@ -48,9 +49,21 @@ developmentLogging = (tokens, req, res) ->
setupErrorMiddleware = (app) ->
app.use (err, req, res, next) ->
if err
if err.name is 'MongoError' and err.code is 11000
err = new errors.Conflict('MongoDB conflict error.')
if err.code is 422 and err.response
err = new errors.UnprocessableEntity(err.response)
if err.code is 409 and err.response
err = new errors.Conflict(err.response)
# TODO: Make all errors use this
if err instanceof errors.NetworkError
return res.status(err.code).send(err.toJSON())
if err.status and 400 <= err.status < 500
res.status(err.status).send("Error #{err.status}")
return
res.status(err.status ? 500).send(error: "Something went wrong!")
message = "Express error: #{req.method} #{req.path}: #{err.message}"
log.error "#{message}, stack: #{err.stack}"

View file

@ -71,6 +71,8 @@ GLOBAL.saveModels = (models, done) ->
GLOBAL.simplePermissions = [target: 'public', access: 'owner']
GLOBAL.ObjectId = mongoose.Types.ObjectId
GLOBAL.request = require 'request'
Promise = require 'bluebird'
Promise.promisifyAll(request, {multiArgs: true})
GLOBAL.unittest = {}
unittest.users = unittest.users or {}

View file

@ -1,138 +1,679 @@
require '../common'
utils = require '../utils'
_ = require 'lodash'
Promise = require 'bluebird'
requestAsync = Promise.promisify(request, {multiArgs: true})
describe '/db/article', ->
request = require 'request'
it 'clears the db first', (done) ->
clearModels [User, Article], (err) ->
throw err if err
describe 'GET /db/article', ->
articleData1 = { name: 'Article 1', body: 'Article 1 body cow', i18nCoverage: [] }
articleData2 = { name: 'Article 2', body: 'Article 2 body moo' }
beforeEach utils.wrap (done) ->
yield utils.clearModelsAsync([Article])
@admin = yield utils.initAdminAsync({})
yield utils.loginUserAsync(@admin)
yield request.postAsync(getURL('/db/article'), { json: articleData1 })
yield request.postAsync(getURL('/db/article'), { json: articleData2 })
yield utils.logoutAsync()
done()
it 'returns an array of Article objects', utils.wrap (done) ->
[res, body] = yield request.getAsync { uri: getURL('/db/article'), json: true }
expect(body.length).toBe(2)
done()
it 'accepts a limit parameter', utils.wrap (done) ->
[res, body] = yield request.getAsync {uri: getURL('/db/article?limit=1'), json: true}
expect(body.length).toBe(1)
done()
it 'returns 422 for an invalid limit parameter', utils.wrap (done) ->
[res, body] = yield request.getAsync {uri: getURL('/db/article?limit=word'), json: true}
expect(res.statusCode).toBe(422)
done()
it 'accepts a skip parameter', utils.wrap (done) ->
[res, body] = yield request.getAsync {uri: getURL('/db/article?skip=1'), json: true}
expect(body.length).toBe(1)
[res, body] = yield request.getAsync {uri: getURL('/db/article?skip=2'), json: true}
expect(body.length).toBe(0)
done()
it 'returns 422 for an invalid skip parameter', utils.wrap (done) ->
[res, body] = yield request.getAsync {uri: getURL('/db/article?skip=???'), json: true}
expect(res.statusCode).toBe(422)
done()
it 'accepts a custom project parameter', utils.wrap (done) ->
[res, body] = yield request.getAsync {uri: getURL('/db/article?project=name,body'), json: true}
expect(body.length).toBe(2)
for doc in body
expect(_.size(_.xor(_.keys(doc), ['_id', 'name', 'body']))).toBe(0)
done()
it 'returns a default projection if project is "true"', utils.wrap (done) ->
[res, body] = yield request.getAsync {uri: getURL('/db/article?project=true'), json: true}
expect(res.statusCode).toBe(200)
expect(body.length).toBe(2)
expect(body[0].body).toBeUndefined()
expect(body[0].version).toBeDefined()
done()
it 'accepts custom filter parameters', utils.wrap (done) ->
yield utils.loginUserAsync(@admin)
[res, body] = yield request.getAsync {uri: getURL('/db/article?filter[slug]="article-1"'), json: true}
expect(body.length).toBe(1)
done()
it 'ignores custom filter parameters for non-admins', utils.wrap (done) ->
user = yield utils.initUserAsync()
yield utils.loginUserAsync(user)
[res, body] = yield request.getAsync {uri: getURL('/db/article?filter[slug]="article-1"'), json: true}
expect(body.length).toBe(2)
done()
it 'accepts custom condition parameters', utils.wrap (done) ->
yield utils.loginUserAsync(@admin)
[res, body] = yield request.getAsync {uri: getURL('/db/article?conditions[select]="slug body"'), json: true}
expect(body.length).toBe(2)
for doc in body
expect(_.size(_.xor(_.keys(doc), ['_id', 'slug', 'body']))).toBe(0)
done()
it 'ignores custom condition parameters for non-admins', utils.wrap (done) ->
user = yield utils.initUserAsync()
yield utils.loginUserAsync(user)
[res, body] = yield request.getAsync {uri: getURL('/db/article?conditions[select]="slug body"'), json: true}
expect(body.length).toBe(2)
for doc in body
expect(doc.name).toBeDefined()
done()
it 'allows non-admins to view by i18n-coverage', utils.wrap (done) ->
[res, body] = yield request.getAsync {uri: getURL('/db/article?view=i18n-coverage'), json: true}
expect(body.length).toBe(1)
expect(body[0].slug).toBe('article-1')
done()
it 'allows non-admins to search by text', utils.wrap (done) ->
[res, body] = yield request.getAsync {uri: getURL('/db/article?term=moo'), json: true}
expect(body.length).toBe(1)
expect(body[0].slug).toBe('article-2')
done()
describe 'POST /db/article', ->
articleData = { name: 'Article', body: 'Article', otherProp: 'not getting set' }
beforeEach utils.wrap (done) ->
yield utils.clearModelsAsync([Article])
@admin = yield utils.initAdminAsync({})
yield utils.loginUserAsync(@admin)
[@res, @body] = yield request.postAsync {
uri: getURL('/db/article'), json: articleData
}
done()
it 'creates a new Article, returning 201', utils.wrap (done) ->
expect(@res.statusCode).toBe(201)
article = yield Article.findById(@body._id).exec()
expect(article).toBeDefined()
done()
it 'sets creator to the user who created it', ->
expect(@res.body.creator).toBe(@admin.id)
it 'sets original to _id', ->
body = @res.body
expect(body.original).toBe(body._id)
it 'returns 422 when no input is provided', utils.wrap (done) ->
[res, body] = yield request.postAsync { uri: getURL('/db/article') }
expect(res.statusCode).toBe(422)
done()
it 'allows you to set Article\'s editableProperties', ->
expect(@body.name).toBe('Article')
it 'ignores properties not included in editableProperties', ->
expect(@body.otherProp).toBeUndefined()
it 'returns 422 when properties do not pass validation', utils.wrap (done) ->
[res, body] = yield request.postAsync {
uri: getURL('/db/article'), json: { i18nCoverage: 9001 }
}
expect(res.statusCode).toBe(422)
expect(body.validationErrors).toBeDefined()
done()
it 'allows admins to create Articles', -> # handled in beforeEach
it 'allows artisans to create Articles', utils.wrap (done) ->
yield utils.clearModelsAsync([Article])
artisan = yield utils.initArtisanAsync({})
yield utils.loginUserAsync(artisan)
[res, body] = yield request.postAsync({uri: getURL('/db/article'), json: articleData })
expect(res.statusCode).toBe(201)
done()
it 'does not allow normal users to create Articles', utils.wrap (done) ->
yield utils.clearModelsAsync([Article])
user = yield utils.initUserAsync({})
yield utils.loginUserAsync(user)
[res, body] = yield request.postAsync({uri: getURL('/db/article'), json: articleData })
expect(res.statusCode).toBe(403)
done()
it 'does not allow anonymous users to create Articles', utils.wrap (done) ->
yield utils.clearModelsAsync([Article])
yield utils.logoutAsync()
[res, body] = yield request.postAsync({uri: getURL('/db/article'), json: articleData })
expect(res.statusCode).toBe(401)
done()
it 'does not allow creating Articles with reserved words', utils.wrap (done) ->
[res, body] = yield request.postAsync { uri: getURL('/db/article'), json: { name: 'Names' } }
expect(res.statusCode).toBe(422)
done()
it 'does not allow creating a second article of the same name', utils.wrap (done) ->
[res, body] = yield request.postAsync { uri: getURL('/db/article'), json: articleData }
expect(res.statusCode).toBe(409)
done()
describe 'GET /db/article/:handle', ->
articleData = { name: 'Some Name', body: 'Article' }
beforeEach utils.wrap (done) ->
yield utils.clearModelsAsync([Article])
@admin = yield utils.initAdminAsync({})
yield utils.loginUserAsync(@admin)
[@res, @body] = yield request.postAsync {
uri: getURL('/db/article'), json: articleData
}
done()
it 'returns Article by id', utils.wrap (done) ->
[res, body] = yield request.getAsync {uri: getURL("/db/article/#{@body._id}"), json: true}
expect(res.statusCode).toBe(200)
expect(_.isObject(body)).toBe(true)
done()
it 'returns Article by slug', utils.wrap (done) ->
[res, body] = yield request.getAsync {uri: getURL("/db/article/some-name"), json: true}
expect(res.statusCode).toBe(200)
expect(_.isObject(body)).toBe(true)
done()
it 'returns not found if handle does not exist in the db', utils.wrap (done) ->
[res, body] = yield request.getAsync {uri: getURL("/db/article/dne"), json: true}
expect(res.statusCode).toBe(404)
done()
putTests = (method='PUT') ->
articleData = { name: 'Some Name', body: 'Article' }
beforeEach utils.wrap (done) ->
yield utils.clearModelsAsync([Article])
@admin = yield utils.initAdminAsync({})
yield utils.loginUserAsync(@admin)
[@res, @body] = yield request.postAsync {
uri: getURL('/db/article'), json: articleData
}
done()
it 'edits editable Article properties', utils.wrap (done) ->
[res, body] = yield requestAsync {method: method, uri: getURL("/db/article/#{@body._id}"), json: { body: 'New body' }}
expect(body.body).toBe('New body')
done()
it 'updates the slug when the name is changed', utils.wrap (done) ->
[res, body] = yield requestAsync {method: method, uri: getURL("/db/article/#{@body._id}"), json: json = { name: 'New name' }}
expect(body.name).toBe('New name')
expect(body.slug).toBe('new-name')
done()
it 'does not allow normal artisan, non-admins to make changes', utils.wrap (done) ->
artisan = yield utils.initArtisanAsync({})
yield utils.loginUserAsync(artisan)
[res, body] = yield requestAsync {method: method, uri: getURL("/db/article/#{@body._id}"), json: { name: 'Another name' }}
expect(res.statusCode).toBe(403)
done()
describe 'PUT /db/article/:handle', -> putTests('PUT')
describe 'PATCH /db/article/:handle', -> putTests('PATCH')
describe 'POST /db/article/:handle/new-version', ->
articleData = { name: 'Article name', body: 'Article body', i18n: {} }
articleID = null
beforeEach utils.wrap (done) ->
yield utils.clearModelsAsync([Article])
@admin = yield utils.initAdminAsync({})
yield utils.loginUserAsync(@admin)
[res, body] = yield request.postAsync { uri: getURL('/db/article'), json: articleData }
expect(res.statusCode).toBe(201)
articleID = body._id
done()
postNewVersion = Promise.promisify (json, expectedStatus=201, done) ->
if _.isFunction(expectedStatus)
done = expectedStatus
expectedStatus = 201
url = getURL("/db/article/#{articleID}/new-version")
request.post { uri: url, json: json }, (err, res, body) ->
expect(res.statusCode).toBe(expectedStatus)
done(err)
testArrayEqual = (given, expected) ->
expect(_.isEqual(given, expected)).toBe(true)
it 'creates a new major version, updating model and version properties', utils.wrap (done) ->
yield postNewVersion({ name: 'Article name', body: 'New body' })
yield postNewVersion({ name: 'New name', body: 'New new body' })
articles = yield Article.find()
expect(articles.length).toBe(3)
versions = (article.get('version') for article in articles)
articles = (article.toObject() for article in articles)
testArrayEqual(_.pluck(versions, 'major'), [0, 1, 2])
testArrayEqual(_.pluck(versions, 'minor'), [0, 0, 0])
testArrayEqual(_.pluck(versions, 'isLatestMajor'), [false, false, true])
testArrayEqual(_.pluck(versions, 'isLatestMinor'), [true, true, true])
testArrayEqual(_.pluck(articles, 'name'), ['Article name', 'Article name', 'New name'])
testArrayEqual(_.pluck(articles, 'body'), ['Article body', 'New body', 'New new body'])
testArrayEqual(_.pluck(articles, 'slug'), [undefined, undefined, 'new-name'])
testArrayEqual(_.pluck(articles, 'index'), [undefined, undefined, true])
done()
it 'works if there is no document with the appropriate version settings (new major)', utils.wrap (done) ->
article = yield Article.findById(articleID)
article.set({ 'version.isLatestMajor': false, 'version.isLatestMinor': false })
yield article.save()
yield postNewVersion({ name: 'Article name', body: 'New body' })
articles = yield Article.find()
expect(articles.length).toBe(2)
versions = (article.get('version') for article in articles)
articles = (article.toObject() for article in articles)
testArrayEqual(_.pluck(versions, 'major'), [0, 1])
testArrayEqual(_.pluck(versions, 'minor'), [0, 0])
testArrayEqual(_.pluck(versions, 'isLatestMajor'), [false, true])
testArrayEqual(_.pluck(versions, 'isLatestMinor'), [false, true]) # does not fix the old version's value
testArrayEqual(_.pluck(articles, 'body'), ['Article body', 'New body'])
testArrayEqual(_.pluck(articles, 'slug'), [undefined, 'article-name'])
testArrayEqual(_.pluck(articles, 'index'), [undefined, true])
done()
it 'creates a new minor version if version.major is included', utils.wrap (done) ->
yield postNewVersion({ name: 'Article name', body: 'New body', version: { major: 0 } })
yield postNewVersion({ name: 'Article name', body: 'New new body', version: { major: 0 } })
articles = yield Article.find()
expect(articles.length).toBe(3)
versions = (article.get('version') for article in articles)
articles = (article.toObject() for article in articles)
testArrayEqual(_.pluck(versions, 'major'), [0, 0, 0])
testArrayEqual(_.pluck(versions, 'minor'), [0, 1, 2])
testArrayEqual(_.pluck(versions, 'isLatestMajor'), [false, false, true])
testArrayEqual(_.pluck(versions, 'isLatestMinor'), [false, false, true])
testArrayEqual(_.pluck(articles, 'name'), ['Article name', 'Article name', 'Article name'])
testArrayEqual(_.pluck(articles, 'body'), ['Article body', 'New body', 'New new body'])
testArrayEqual(_.pluck(articles, 'slug'), [undefined, undefined, 'article-name'])
testArrayEqual(_.pluck(articles, 'index'), [undefined, undefined, true])
done()
it 'works if there is no document with the appropriate version settings (new minor)', utils.wrap (done) ->
article = yield Article.findById(articleID)
article.set({ 'version.isLatestMajor': false, 'version.isLatestMinor': false })
yield article.save()
yield postNewVersion({ name: 'Article name', body: 'New body', version: { major: 0 } })
articles = yield Article.find()
expect(articles.length).toBe(2)
versions = (article.get('version') for article in articles)
articles = (article.toObject() for article in articles)
testArrayEqual(_.pluck(versions, 'major'), [0, 0])
testArrayEqual(_.pluck(versions, 'minor'), [0, 1])
testArrayEqual(_.pluck(versions, 'isLatestMajor'), [false, false])
testArrayEqual(_.pluck(versions, 'isLatestMinor'), [false, true])
testArrayEqual(_.pluck(articles, 'body'), ['Article body', 'New body'])
testArrayEqual(_.pluck(articles, 'slug'), [undefined, 'article-name'])
testArrayEqual(_.pluck(articles, 'index'), [undefined, true])
done()
it 'allows adding new minor versions to old major versions', utils.wrap (done) ->
yield postNewVersion({ name: 'Article name', body: 'New body' })
yield postNewVersion({ name: 'Article name', body: 'New new body', version: { major: 0 } })
articles = yield Article.find()
expect(articles.length).toBe(3)
versions = (article.get('version') for article in articles)
articles = (article.toObject() for article in articles)
testArrayEqual(_.pluck(versions, 'major'), [0, 1, 0])
testArrayEqual(_.pluck(versions, 'minor'), [0, 0, 1])
testArrayEqual(_.pluck(versions, 'isLatestMajor'), [false, true, false])
testArrayEqual(_.pluck(versions, 'isLatestMinor'), [false, true, true])
testArrayEqual(_.pluck(articles, 'name'), ['Article name', 'Article name', 'Article name'])
testArrayEqual(_.pluck(articles, 'body'), ['Article body', 'New body', 'New new body'])
testArrayEqual(_.pluck(articles, 'slug'), [undefined, 'article-name', undefined])
testArrayEqual(_.pluck(articles, 'index'), [undefined, true, undefined])
done()
it 'unsets properties which are not included in the request', utils.wrap (done) ->
yield postNewVersion({ name: 'Article name', version: { major: 0 } })
articles = yield Article.find()
expect(articles.length).toBe(2)
expect(articles[1].get('body')).toBeUndefined()
done()
it 'works for artisans', utils.wrap (done) ->
yield utils.logoutAsync()
artisan = yield utils.initArtisanAsync()
yield utils.loginUserAsync(artisan)
yield postNewVersion({ name: 'Article name', body: 'New body' })
articles = yield Article.find()
expect(articles.length).toBe(2)
done()
it 'works for normal users submitting translations', utils.wrap (done) ->
yield utils.logoutAsync()
user = yield utils.initUserAsync()
yield utils.loginUserAsync(user)
yield postNewVersion({ name: 'Article name', body: 'Article body', i18n: { fr: { name: 'Le Article' }}}, 201)
articles = yield Article.find()
expect(articles.length).toBe(2)
done()
it 'does not work for normal users', utils.wrap (done) ->
yield utils.logoutAsync()
user = yield utils.initUserAsync()
yield utils.loginUserAsync(user)
yield postNewVersion({ name: 'Article name', body: 'New body' }, 403)
articles = yield Article.find()
expect(articles.length).toBe(1)
done()
it 'does not work for anonymous users', utils.wrap (done) ->
yield utils.logoutAsync()
yield postNewVersion({ name: 'Article name', body: 'New body' }, 401)
articles = yield Article.find()
expect(articles.length).toBe(1)
done()
it 'notifies watchers of changes', utils.wrap (done) ->
sendwithus = require '../../../server/sendwithus'
spyOn(sendwithus.api, 'send').and.callFake (context, cb) ->
expect(context.email_id).toBe(sendwithus.templates.change_made_notify_watcher)
expect(context.recipient.address).toBe('test@gmail.com')
done()
user = yield User({email: 'test@gmail.com', name: 'a user'}).save()
article = yield Article.findById(articleID)
article.set('watchers', article.get('watchers').concat([user.get('_id')]))
yield article.save()
yield postNewVersion({ name: 'Article name', body: 'New body', commitMessage: 'Commit message' })
it 'sends a notification to artisan and main HipChat channels', utils.wrap (done) ->
hipchat = require '../../../server/hipchat'
spyOn(hipchat, 'sendHipChatMessage')
yield postNewVersion({ name: 'Article name', body: 'New body' })
expect(hipchat.sendHipChatMessage).toHaveBeenCalled()
done()
describe 'version fetching endpoints', ->
articleData = { name: 'Original version', body: 'Article body' }
articleOriginal = null
article = {name: 'Yo', body: 'yo ma'}
article2 = {name: 'Original', body: 'yo daddy'}
postNewVersion = Promise.promisify (json, expectedStatus=201, done) ->
if _.isFunction(expectedStatus)
done = expectedStatus
expectedStatus = 201
url = getURL("/db/article/#{articleOriginal}/new-version")
request.post { uri: url, json: json }, (err, res) ->
expect(res.statusCode).toBe(expectedStatus)
done(err)
url = getURL('/db/article')
articles = {}
it 'does not allow non-admins to create Articles.', (done) ->
loginJoe ->
request.post {uri: url, json: article}, (err, res, body) ->
expect(res.statusCode).toBe(403)
done()
beforeEach utils.wrap (done) ->
yield utils.clearModelsAsync([Article])
@admin = yield utils.initAdminAsync({})
yield utils.loginUserAsync(@admin)
[res, body] = yield request.postAsync { uri: getURL('/db/article'), json: articleData }
expect(res.statusCode).toBe(201)
articleOriginal = body._id
yield postNewVersion({ name: 'Latest minor version', body: 'New body', version: {major: 0} })
yield postNewVersion({ name: 'Latest major version', body: 'New new body' })
done()
it 'allows admins to create Articles', (done) ->
loginAdmin ->
request.post {uri: url, json: article}, (err, res, body) ->
expect(res.statusCode).toBe(200)
expect(body.slug).toBeDefined()
expect(body.body).toBeDefined()
expect(body.name).toBeDefined()
expect(body.original).toBeDefined()
expect(body.creator).toBeDefined()
articles[0] = body
# Having two articles allow for testing article search and such
request.post {uri: url, json: article2}, (err, res, body) ->
expect(res.statusCode).toBe(200)
expect(body.slug).toBeDefined()
expect(body.body).toBeDefined()
expect(body.name).toBeDefined()
expect(body.original).toBeDefined()
expect(body.creator).toBeDefined()
articles[0] = body
done()
it 'allows admins to make new minor versions', (done) ->
new_article = _.clone(articles[0])
new_article.body = 'yo daddy'
request.post {uri: url, json: new_article}, (err, res, body) ->
expect(res.statusCode).toBe(200)
expect(body.version.major).toBe(0)
expect(body.version.minor).toBe(1)
expect(body._id).not.toBe(articles[0]._id)
expect(body.parent).toBe(articles[0]._id)
expect(body.creator).toBeDefined()
articles[1] = body
describe 'GET /db/article/:handle/version/:version', ->
it 'returns the latest version for the given original article when :version is empty', utils.wrap (done) ->
[res, body] = yield request.getAsync { uri: getURL("/db/article/#{articleOriginal}/version"), json: true }
expect(body.name).toBe('Latest major version')
done()
it 'allows admins to make new major versions', (done) ->
new_article = _.clone(articles[1])
delete new_article.version
request.post {uri: url, json: new_article}, (err, res, body) ->
expect(res.statusCode).toBe(200)
expect(body.version.major).toBe(1)
expect(body.version.minor).toBe(0)
expect(body._id).not.toBe(articles[1]._id)
expect(body.parent).toBe(articles[1]._id)
articles[2] = body
it 'returns the latest of a given major version when :version is X', utils.wrap (done) ->
[res, body] = yield request.getAsync { uri: getURL("/db/article/#{articleOriginal}/version/0"), json: true }
expect(body.name).toBe('Latest minor version')
done()
it 'grants access for regular users', (done) ->
loginJoe ->
request.get {uri: url+'/'+articles[0]._id}, (err, res, body) ->
body = JSON.parse(body)
expect(res.statusCode).toBe(200)
expect(body.body).toBe(articles[0].body)
done()
it 'does not allow regular users to make new versions', (done) ->
new_article = _.clone(articles[2])
request.post {uri: url, json: new_article}, (err, res, body) ->
expect(res.statusCode).toBe(403)
it 'returns a specific version when :version is X.Y', utils.wrap (done) ->
[res, body] = yield request.getAsync { uri: getURL("/db/article/#{articleOriginal}/version/0.0"), json: true }
expect(body.name).toBe('Original version')
done()
it 'allows name changes from one version to the next', (done) ->
loginAdmin ->
new_article = _.clone(articles[0])
new_article.name = 'Yo mama now is the larger'
request.post {uri: url, json: new_article}, (err, res, body) ->
expect(res.statusCode).toBe(200)
expect(body.name).toBe(new_article.name)
done()
it 'get schema', (done) ->
request.get {uri: url+'/schema'}, (err, res, body) ->
expect(res.statusCode).toBe(200)
body = JSON.parse(body)
expect(body.type).toBeDefined()
it 'returns 422 when the original value is invalid', utils.wrap (done) ->
[res, body] = yield request.getAsync { uri: getURL('/db/article/dne/version'), json: true }
expect(res.statusCode).toBe(422)
done()
it 'returns 404 when the original value cannot be found', utils.wrap (done) ->
[res, body] = yield request.getAsync { uri: getURL('/db/article/012345678901234567890123/version'), json: true }
expect(res.statusCode).toBe(404)
done()
describe 'GET /db/article/:handle/versions', ->
it 'returns an array of versions sorted by creation for the given original article', utils.wrap (done) ->
[res, body] = yield request.getAsync { uri: getURL("/db/article/#{articleOriginal}/versions"), json: true }
expect(body.length).toBe(3)
expect(body[0].name).toBe('Latest major version')
expect(body[1].name).toBe('Latest minor version')
expect(body[2].name).toBe('Original version')
done()
it 'projects most properties by default', utils.wrap (done) ->
[res, body] = yield request.getAsync { uri: getURL("/db/article/#{articleOriginal}/versions"), json: true }
expect(body[0].body).toBeUndefined()
done()
describe 'GET /db/article/:handle/files', ->
it 'returns an array of file metadata for the given original article', utils.wrap (done) ->
yield utils.clearModelsAsync([Article])
articleData = { name: 'Article', body: 'Article' }
admin = yield utils.initAdminAsync({})
yield utils.loginUserAsync(admin)
[res, article] = yield request.postAsync { uri: getURL('/db/article'), json: articleData }
expect(res.statusCode).toBe(201)
[res, body] = yield request.postAsync(getURL('/file'), { json: {
url: getURL('/assets/main.html')
filename: 'test.html'
path: 'db/article/'+article.original
mimetype: 'text/html'
}})
[res, body] = yield request.getAsync(getURL('/db/article/'+article.original+'/files'), {json: true})
expect(body.length).toBe(1)
expect(body[0].filename).toBe('test.html')
expect(body[0].metadata.path).toBe('db/article/'+article.original)
done()
describe 'GET and POST /db/article/:handle/names', ->
articleData1 = { name: 'Article 1', body: 'Article 1 body' }
articleData2 = { name: 'Article 2', body: 'Article 2 body' }
it 'does not allow naming an article a reserved word', (done) ->
loginAdmin ->
new_article = {name: 'Names', body: 'is a reserved word'}
request.post {uri: url, json: new_article}, (err, res, body) ->
expect(res.statusCode).toBe(422)
done()
it 'returns an object mapping ids to names', utils.wrap (done) ->
yield utils.clearModelsAsync([Article])
admin = yield utils.initAdminAsync({})
yield utils.loginUserAsync(admin)
[res, article1] = yield request.postAsync(getURL('/db/article'), { json: articleData1 })
[res, article2] = yield request.postAsync(getURL('/db/article'), { json: articleData2 })
yield utils.logoutAsync()
[res, body] = yield request.getAsync { uri: getURL('/db/article/names?ids='+[article1._id, article2._id].join(',')), json: true }
expect(body.length).toBe(2)
expect(body[0].name).toBe('Article 1')
[res, body] = yield request.postAsync { uri: getURL('/db/article/names?ids='+[article1._id, article2._id].join(',')), json: true }
expect(body.length).toBe(2)
expect(body[0].name).toBe('Article 1')
done()
describe 'GET /db/article/:handle/patches', ->
it 'returns pending patches for the given original article', utils.wrap (done) ->
yield utils.clearModelsAsync([Article])
articleData = { name: 'Article', body: 'Article' }
admin = yield utils.initAdminAsync({})
yield utils.loginUserAsync(admin)
[res, article] = yield request.postAsync { uri: getURL('/db/article'), json: articleData }
expect(res.statusCode).toBe(201)
[res, patch] = yield request.postAsync { uri: getURL('/db/patch'), json: {
delta: []
commitMessage: 'Test commit'
target: {
collection: 'article'
id: article._id
}
}}
[res, patches] = yield request.getAsync getURL("/db/article/#{article._id}/patches"), { json: true }
expect(res.statusCode).toBe(200)
expect(patches.length).toBe(1)
expect(patches[0]._id).toBe(patch._id)
done()
it 'returns 422 for invalid object ids', utils.wrap (done) ->
[res, body] = yield request.getAsync getURL("/db/article/invalid/patches"), { json: true }
expect(res.statusCode).toBe(422)
done()
describe 'POST /db/article/:handle/watchers', ->
it 'adds self to the list of watchers, and is idempotent', utils.wrap (done) ->
# create article
yield utils.clearModelsAsync([Article])
articleData = { name: 'Article', body: 'Article' }
admin = yield utils.initAdminAsync({})
yield utils.loginUserAsync(admin)
[res, article] = yield request.postAsync { uri: getURL('/db/article'), json: articleData }
expect(res.statusCode).toBe(201)
# add new user as watcher
yield utils.logoutAsync()
user = yield utils.initUserAsync()
yield utils.loginUserAsync(user)
[res, article] = yield request.postAsync { uri: getURL("/db/article/#{article._id}/watchers"), json: true }
expect(res.statusCode).toBe(200)
expect(_.contains(article.watchers, user.id)).toBe(true)
it 'allows regular users to get all articles', (done) ->
loginJoe ->
request.get {uri: url, json: {}}, (err, res, body) ->
expect(res.statusCode).toBe(200)
expect(body.length).toBe(2)
done()
# check idempotence, db
numWatchers = article.watchers.length
[res, article] = yield request.postAsync { uri: getURL("/db/article/#{article._id}/watchers"), json: true }
expect(res.statusCode).toBe(200)
expect(numWatchers).toBe(article.watchers.length)
article = yield Article.findById(article._id)
expect(_.last(article.get('watchers')).toString()).toBe(user.id)
done()
it 'allows regular users to get articles and use projection', (done) ->
loginJoe ->
# default projection
request.get {uri: url + '?project=true', json: {}}, (err, res, body) ->
expect(res.statusCode).toBe(200)
expect(body.length).toBe(2)
expect(body[0].body).toBeUndefined()
expect(body[0].version).toBeDefined()
describe 'DELETE /db/article/:handle/watchers', ->
it 'removes self from the list of watchers, and is idempotent', utils.wrap (done) ->
# create article
yield utils.clearModelsAsync([Article])
articleData = { name: 'Article', body: 'Article' }
admin = yield utils.initAdminAsync({})
yield utils.loginUserAsync(admin)
[res, article] = yield request.postAsync { uri: getURL('/db/article'), json: articleData }
expect(res.statusCode).toBe(201)
# custom projection
request.get {uri: url + '?project=original', json: {}}, (err, res, body) ->
expect(res.statusCode).toBe(200)
expect(body.length).toBe(2)
expect(Object.keys(body[0]).length).toBe(2)
expect(body[0].original).toBeDefined()
done()
# add new user as watcher
yield utils.logoutAsync()
user = yield utils.initUserAsync()
yield utils.loginUserAsync(user)
[res, article] = yield request.postAsync { uri: getURL("/db/article/#{article._id}/watchers"), json: true }
expect(_.contains(article.watchers, user.id)).toBe(true)
it 'allows regular users to perform a text search', (done) ->
loginJoe ->
request.get {uri: url + '?term="daddy"', json: {}}, (err, res, body) ->
expect(res.statusCode).toBe(200)
expect(body.length).toBe(1)
expect(body[0].name).toBe(article2.name)
expect(body[0].body).toBe(article2.body)
done()
# remove user as watcher
[res, article] = yield request.delAsync { uri: getURL("/db/article/#{article._id}/watchers"), json: true }
expect(res.statusCode).toBe(200)
expect(_.contains(article.watchers, user.id)).toBe(false)
# check idempotence, db
numWatchers = article.watchers.length
[res, article] = yield request.delAsync { uri: getURL("/db/article/#{article._id}/watchers"), json: true }
expect(res.statusCode).toBe(200)
expect(numWatchers).toBe(article.watchers.length)
article = yield Article.findById(article._id)
ids = (id.toString() for id in article.get('watchers'))
expect(_.contains(ids, user.id)).toBe(false)
done()

View file

@ -14,17 +14,18 @@ describe '/db/<id>/version', ->
it 'sets up', (done) ->
loginAdmin ->
request.post {uri: url, json: article}, (err, res, body) ->
expect(res.statusCode).toBe(200)
expect(res.statusCode).toBe(201)
articles[0] = body
new_article = _.clone(articles[0])
new_article.body = '...'
request.post {uri: url, json: new_article}, (err, res, body) ->
expect(res.statusCode).toBe(200)
newVersionURL = "#{url}/#{new_article._id}/new-version"
request.post {uri: newVersionURL, json: new_article}, (err, res, body) ->
expect(res.statusCode).toBe(201)
articles[1] = body
new_article = _.clone(articles[1])
delete new_article.version
request.post {uri: url, json: new_article}, (err, res, body) ->
expect(res.statusCode).toBe(200)
request.post {uri: newVersionURL, json: new_article}, (err, res, body) ->
expect(res.statusCode).toBe(201)
articles[2] = body
done()

View file

@ -385,18 +385,19 @@ describe 'Statistics', ->
expect(carl.get User.statsMapping.edits.article).toBeUndefined()
article.creator = carl.get 'id'
# Create major version 1.0
# Create major version 0.0
request.post {uri:url, json: article}, (err, res, body) ->
expect(err).toBeNull()
expect(res.statusCode).toBe 200
expect(res.statusCode).toBe 201
article = body
User.findById carl.get('id'), (err, guy) ->
expect(err).toBeNull()
expect(guy.get User.statsMapping.edits.article).toBe 1
# Create minor version 1.1
request.post {uri:url, json: article}, (err, res, body) ->
# Create minor version 0.1
newVersionURL = "#{url}/#{article._id}/new-version"
request.post {uri:newVersionURL, json: article}, (err, res, body) ->
expect(err).toBeNull()
User.findById carl.get('id'), (err, guy) ->

67
spec/server/utils.coffee Normal file
View file

@ -0,0 +1,67 @@
async = require 'async'
utils = require '../../server/lib/utils'
co = require 'co'
module.exports = mw =
getURL: (path) -> 'http://localhost:3001' + path
clearModels: (models, done) ->
funcs = []
for model in models
wrapped = (m) ->
(callback) ->
m.remove {}, (err) ->
callback(err, true)
funcs.push(wrapped(model))
async.parallel funcs, done
initUser: (options, done) ->
if _.isFunction(options)
done = options
options = {}
options = _.extend({permissions: []}, options)
doc = {
email: 'user'+_.uniqueId()+'@gmail.com'
password: 'password'
permissions: options.permissions
}
new User(doc).save (err, user) ->
expect(err).toBe(null)
done(err, user)
loginUser: (user, done) ->
form = {
username: user.get('email')
password: 'password'
}
request.post mw.getURL('/auth/login'), { form: form }, (err, res) ->
expect(err).toBe(null)
expect(res.statusCode).toBe(200)
done(err, user)
initAdmin: (options, done) ->
if _.isFunction(options)
done = options
options = {}
options = _.extend({permissions: ['admin']}, options)
return @initUser(options, done)
initArtisan: (options, done) ->
if _.isFunction(options)
done = options
options = {}
options = _.extend({permissions: ['artisan']}, options)
return @initUser(options, done)
logout: (done) ->
request.post mw.getURL('/auth/logout'), done
wrap: (gen) ->
fn = co.wrap(gen)
return (done) ->
fn.apply(@, [done]).catch (err) -> done.fail(err)
Promise = require 'bluebird'
Promise.promisifyAll(module.exports)