Merge branch 'master' into production

This commit is contained in:
Scott Erickson 2014-06-12 14:45:19 -07:00
commit 6f9371af02
50 changed files with 857 additions and 301 deletions

View file

@ -18,7 +18,7 @@ before_script:
- "mkdir mongo"
- "mongod --dbpath=./mongo --fork --logpath ./mongodb.log"
- "node index.js --unittest &"
- "sleep 5" # to give node a chance to start
- "sleep 10" # to give node a chance to start
script:
- "./node_modules/jasmine-node/bin/jasmine-node test/server/ --coffee --captureExceptions"

View file

@ -18,6 +18,7 @@ doQuerySelector = (value, operatorObj) ->
when '$ne' then return false if mapred value, body, (l, r) -> l == r
when '$in' then return false unless _.reduce value, ((result, val) -> result or val in body), false
when '$nin' then return false if _.reduce value, ((result, val) -> result or val in body), false
when '$exists' then return false if value[0]? isnt body[0]
else return false
true
@ -34,11 +35,13 @@ matchesQuery = (target, queryObj) ->
pieces = prop.split('.')
obj = target
for piece in pieces
return false unless piece of obj
unless piece of obj
obj = null
break
obj = obj[piece]
if typeof query != 'object' or _.isArray query
return false unless obj == query or (query in obj if _.isArray obj)
else return false unless doQuerySelector obj, query
true
LocalMongo.matchesQuery = matchesQuery
LocalMongo.matchesQuery = matchesQuery

View file

@ -75,3 +75,25 @@ module.exports.getByPath = (target, path) ->
return undefined unless piece of obj
obj = obj[piece]
obj
module.exports.round = _.curry (digits, n) ->
n = +n.toFixed(digits)
positify = (func) -> (x) -> if x > 0 then func(x) else 0
# f(x) = ax + b
createLinearFunc = (params) ->
(x) -> (params.a or 1) * x + (params.b or 0)
# f(x) = ax² + bx + c
createQuadraticFunc = (params) ->
(x) -> (params.a or 1) * x * x + (params.b or 1) * x + (params.c or 0)
# f(x) = a log(b (x + c)) + d
createLogFunc = (params) ->
(x) -> if x > 0 then (params.a or 1) * Math.log((params.b or 1) * (x + (params.c or 0))) + (params.d or 0) else 0
module.exports.functionCreators =
linear: positify(createLinearFunc)
quadratic: positify(createQuadraticFunc)
logarithmic: positify(createLogFunc)

View file

@ -507,7 +507,7 @@
new_thang_title_login: "Log In to Create a New Thang Type"
new_level_title_login: "Log In to Create a New Level"
new_achievement_title: "Create a New Achievement"
new_achievement_title_login: "Sign Up to Create a New Achievement"
new_achievement_title_login: "Log In to Create a New Achievement"
article_search_title: "Search Articles Here"
thang_search_title: "Search Thang Types Here"
level_search_title: "Search Levels Here"

View file

@ -16,7 +16,7 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
play: "Jouer"
retry: "Reessayer"
watch: "Regarder"
# unwatch: "Unwatch"
unwatch: "Ne plus regarder"
submit_patch: "Soumettre un correctif"
units:
@ -26,14 +26,14 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
minutes: "minutes"
hour: "heure"
hours: "heures"
# day: "day"
# days: "days"
# week: "week"
# weeks: "weeks"
# month: "month"
# months: "months"
# year: "year"
# years: "years"
day: "jour"
days: "jours"
week: "semaine"
weeks: "semaines"
month: "mois"
months: "mois"
year: "année"
years: "années"
modal:
close: "Fermer"
@ -128,8 +128,8 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
forum_page: "notre forum"
forum_suffix: " À la place."
send: "Envoyer un commentaire"
# contact_candidate: "Contact Candidate"
# recruitment_reminder: "Use this form to reach out to candidates you are interested in interviewing. Remember that CodeCombat charges 15% of first-year salary. The fee is due upon hiring the employee and is refundable for 90 days if the employee does not remain employed. Part time, remote, and contract employees are free, as are interns."
contact_candidate: "Contacter le candidat"
recruitment_reminder: "Utilisez ce formulaire pour entrer en contact avec le candidat qui vous interesse. Souvenez-vous que CodeCombat facture 15% de la première année de salaire. Ces frais sont dues à l'embauche de l'employé, ils sont remboursable pendant 90 jours si l'employé ne reste pas employé. Les employés à temps partiel, à distance ou contractuel sont gratuits en tant que stagiaires."
diplomat_suggestion:
title: "Aidez à traduire CodeCombat!"
@ -173,11 +173,11 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
email_announcements: "Annonces"
email_announcements_description: "Recevoir des mails sur les dernières actualités et sur le développement de CodeCombat."
email_notifications: "Notifications"
# email_notifications_summary: "Controls for personalized, automatic email notifications related to your CodeCombat activity."
# email_any_notes: "Any Notifications"
email_notifications_summary: "Commandes pour personaliser les notifications automatiques d'email liées à votre activité sur CodeCombat."
email_any_notes: "Toutes Notifications"
email_any_notes_description: "Désactivez pour ne plus recevoir de notifications par e-mail."
# email_recruit_notes: "Job Opportunities"
# email_recruit_notes_description: "If you play really well, we may contact you about getting you a (better) job."
email_recruit_notes: "Offres d'emploi"
email_recruit_notes_description: "Si vous jouez vraiment bien, nous pouvons vous contacter pour vous proposer un (meilleur) emploi."
contributor_emails: "Emails des contributeurs"
contribute_prefix: "Nous recherchons des personnes pour se joindre à notre groupe! Consultez la "
contribute_page: "page de contributions"
@ -186,15 +186,15 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
error_saving: "Problème d'enregistrement"
saved: "Changements sauvegardés"
password_mismatch: "Le mot de passe ne correspond pas."
# job_profile: "Job Profile"
# job_profile_approved: "Your job profile has been approved by CodeCombat. Employers will be able to see it until you either mark it inactive or it has not been changed for four weeks."
# job_profile_explanation: "Hi! Fill this out, and we will get in touch about finding you a software developer job."
# sample_profile: "See a sample profile"
job_profile: "Profil d'emploi"
job_profile_approved: "Votre profil d'emploi a été approuvé par CodeCombat. Les employeurs seront en mesure de voir votre profil jusqu'à ce que vous le marquez inactif ou qu'il n'a pas été changé pendant quatre semaines."
job_profile_explanation: "Salut! Remplissez-le et nous prendrons contact pour vous trouver un emploi de développeur de logiciels."
sample_profile: "Voir un exemple de profil"
view_profile: "Voir votre profil"
account_profile:
edit_settings: "Éditer les préférences"
# done_editing_settings: "Done Editing"
done_editing_settings: "Edition terminée"
profile_for_prefix: "Profil pour "
profile_for_suffix: ""
approved: "Approuvé"
@ -202,57 +202,57 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
looking_for: "à la recherche de:"
last_updated: "Dernière Mise à jour:"
contact: "Contact"
# active: "Looking for interview offers now"
# inactive: "Not looking for offers right now"
# complete: "complete"
# next: "Next"
# next_city: "city?"
# next_country: "pick your country."
# next_name: "name?"
# next_short_description: "summarize yourself at a glance."
# next_long_description: "describe the work you're looking for."
# next_skills: "list at least five skills."
# next_work: "list your work experience."
# next_education: "recount your educational ordeals."
# next_projects: "show off up to three projects you've worked on."
# next_links: "add any personal or social links."
# next_photo: "add an optional professional photo."
# next_active: "mark yourself open to offers to show up in searches."
# example_blog: "Your Blog"
# example_github: "Your GitHub"
# links_header: "Personal Links"
# links_blurb: "Link any other sites or profiles you want to highlight, like your GitHub, your LinkedIn, or your blog."
# links_name: "Link Name"
# links_name_help: "What are you linking to?"
# links_link_blurb: "Link URL"
# basics_header: "Update basic info"
# basics_active: "Open to Offers"
# basics_active_help: "Want interview offers right now?"
# basics_job_title: "Desired Job Title"
# basics_job_title_help: "What role are you looking for?"
# basics_city: "City"
# basics_city_help: "City you want to work in (or live in now)."
# basics_country: "Country"
# basics_country_help: "Country you want to work in (or live in now)."
# basics_visa: "US Work Status"
# basics_visa_help: "Are you authorized to work in the US, or do you need visa sponsorship?"
# basics_looking_for: "Looking For"
# basics_looking_for_full_time: "Full-time"
# basics_looking_for_part_time: "Part-time"
# basics_looking_for_remote: "Remote"
# basics_looking_for_contracting: "Contracting"
# basics_looking_for_internship: "Internship"
# basics_looking_for_help: "What kind of developer position do you want?"
# name_header: "Fill in your name"
# name_anonymous: "Anonymous Developer"
# name_help: "Name you want employers to see, like 'Nick Winter'."
# short_description_header: "Write a short description of yourself"
active: "En recherche d'offres"
inactive: "Ne recherche pas d'offres"
complete: "terminé"
next: "Suivant"
next_city: "ville ?"
next_country: "choisissez votre pays."
next_name: "nom ?"
next_short_description: "résumez votre profil en quelques mots."
next_long_description: "décrivez le travail que vous cherchez."
next_skills: "listez au moins 5 compétances."
next_work: "décrivez votre expérience professionnelle."
next_education: "raconter votre scolarité."
next_projects: "décrivez jusqu'à 3 projets sur lesquels vous avez travaillé."
next_links: "ajouter des liens internet vers des sites personnels ou des réseaux sociaux."
next_photo: "ajouter une photo professionelle (optionnel)."
next_active: "déclarez vous ouvert aux offres pour apparaitre dans les recherches."
example_blog: "Votre blog"
example_github: "Votre GitHub"
links_header: "Liens personnels"
links_blurb: "Lien vers d'autres sites ou profils que vous souhaitez mettre en avant, comme votre GitHub, LinkedIn ou votre blog."
links_name: "Nom du lien"
links_name_help: "A quoi êtes vous lié ?"
links_link_blurb: "Lien URL"
basics_header: "Mettre à jour les information basiques"
basics_active: "Ouvert aux propositions"
basics_active_help: "Voulez-vous des offres maintenant ?" # "Want interview offers right now?"
basics_job_title: "Titre du poste souhaité"
basics_job_title_help: "Quel est le rôle que vous cherchez ?"
basics_city: "Ville"
basics_city_help: "Ville dans laquelle vous souhaitez travailler (ou dans laquelle vous vivez actuellement)."
basics_country: "Pays"
basics_country_help: "Pays dans lequel vous souhaitez travailler (ou dans lequel vous vivez actuellement)."
basics_visa: "Status de travail aux Etats-Unis"
basics_visa_help: "Etes vous autorisé à travailler aux Etats-Unis ou avez vous besoin d'un parrainage pour le visa ?"
basics_looking_for: "Recherche"
basics_looking_for_full_time: "Temps plein"
basics_looking_for_part_time: "Temps partiel"
basics_looking_for_remote: "A distance"
basics_looking_for_contracting: "Contrat"
basics_looking_for_internship: "Stage"
basics_looking_for_help: "Quel genre de poste de développeur voulez-vous ?"
name_header: "Remplissez votre nom"
name_anonymous: "Developpeur Anonyme"
name_help: "Le nom que vous souhaitez que l'employeur voie, par exemple 'Chuck Norris'."
short_description_header: "Décrivez vous en quelques mots"
# short_description_blurb: "Add a blurb here that will show, at a glance, whether you might be just the developer that an employer is looking for."
# short_description: "Short Description"
# short_description_help: "Who are you, and what are you looking for? 140 characters max."
# skills_header: "Skills"
# skills_help: "Tag relevant developer skills in order of proficiency."
# long_description_header: "Detail your desired position"
short_description: "Description courte"
short_description_help: "Qui êtes vous et que recherchez vous ? 140 caractères max."
skills_header: "Compétences"
skills_help: "Notez vos compétence de développement par ordre de maitrise."
long_description_header: "Détaillez votre poste souhaité"
# long_description_blurb_1: "Write a little longer section here to describe the role you would like to pursue next."
# long_description_blurb_2: "Talk about how awesome you are and why it would be a good idea to hire you."
# long_description: "Description"
@ -322,7 +322,7 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
goals: "Objectifs"
success: "Succès"
incomplete: "Imcoplet"
# timed_out: "Ran out of time"
timed_out: "Plus de temps"
failing: "Echec"
action_timeline: "Action sur la ligne de temps"
click_to_select: "Clique sur une unité pour la sélectionner."
@ -465,8 +465,8 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
back: "Retour"
revert: "Annuler"
revert_models: "Annuler les modèles"
# fork_title: "Fork New Version"
# fork_creating: "Creating Fork..."
fork_title: "Fork une nouvelle version"
fork_creating: "Créer un Fork..."
more: "Plus"
wiki: "Wiki"
live_chat: "Chat en live"
@ -533,7 +533,7 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
message: "Message"
code: "Code"
ladder: "Companion"
when: "Lorsuqe"
when: "Quand"
opponent: "Adversaire"
rank: "Rang"
score: "Score"
@ -744,8 +744,8 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
simulation_explanation: "En simulant une partie, tu peux classer ton rang plus rapidement!"
simulate_games: "Simuler une Partie!"
simulate_all: "REINITIALISER ET SIMULER DES PARTIES"
# games_simulated_by: "Games simulated by you:"
# games_simulated_for: "Games simulated for you:"
games_simulated_by: "Parties que vous avez simulé :"
games_simulated_for: "parties simulées pour vous :"
games_simulated: "Partie simulée"
games_played: "Parties jouées"
ratio: "Moyenne"
@ -776,11 +776,11 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
warmup: "Préchauffe"
vs: "VS"
# friends_playing: "Friends Playing"
# log_in_for_friends: "Log in to play with your friends!"
# social_connect_blurb: "Connect and play against your friends!"
log_in_for_friends: "Connectez vous pour jouer avec vos amis!"
social_connect_blurb: "Connectez vous pour jouer contre vos amis!"
# invite_friends_to_battle: "Invite your friends to join you in battle!"
# fight: "Fight!"
# watch_victory: "Watch your victory"
fight: "Combattez !"
watch_victory: "Regardez votre victoire"
# defeat_the: "Defeat the"
tournament_ends: "Fin du tournoi"
tournament_rules: "Règles du tournoi"
@ -835,7 +835,7 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
unknown: "Erreur inconnue."
resources:
# your_sessions: "Your Sessions"
your_sessions: "vos Sessions"
level: "Niveau"
# social_network_apis: "Social Network APIs"
facebook_status: "Statut Facebook"
@ -857,11 +857,11 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
# level_session: "Your Session"
# opponent_session: "Opponent Session"
article: "Article"
# user_names: "User Names"
user_names: "Nom d'utilisateur"
# thang_names: "Thang Names"
files: "Fichiers"
top_simulators: "Top Simulateurs"
# source_document: "Source Document"
source_document: "Document Source"
document: "Document"
# sprite_sheet: "Sprite Sheet"
@ -869,7 +869,7 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
added: "Ajouté"
modified: "Modifié"
deleted: "Supprimé"
# moved_index: "Moved Index"
# text_diff: "Text Diff"
# merge_conflict_with: "MERGE CONFLICT WITH"
moved_index: "Index changé"
text_diff: "Différence de texte"
merge_conflict_with: "Fusionner les conflits avec"
no_changes: "Aucuns Changements"

View file

@ -1,4 +1,5 @@
CocoModel = require './CocoModel'
util = require '../lib/utils'
module.exports = class Achievement extends CocoModel
@className: 'Achievement'
@ -6,4 +7,10 @@ module.exports = class Achievement extends CocoModel
urlRoot: '/db/achievement'
isRepeatable: ->
@get('proportionalTo')?
@get('proportionalTo')?
# TODO logic is duplicated in Mongoose Achievement schema
getExpFunction: ->
kind = @get('function')?.kind or @schema.function.default.kind
parameters = @get('function')?.parameters or @schema.function.default.parameters
return utils.functionCreators[kind](parameters) if kind of utils.functionCreators

View file

@ -52,22 +52,17 @@ _.extend(AchievementSchema.properties,
description: 'For repeatables only. Denotes the field a repeatable achievement needs for its calculations'
function:
type: 'object'
oneOf: [
linear:
properties:
kind: {enum: ['linear', 'logarithmic'], default: 'linear'}
parameters:
type: 'object'
properties:
a: {type: 'number', default: 1},
required: ['a']
description: 'f(x) = a * x'
logarithmic:
type:'object'
properties:
a: {type: 'number', default: 1}
b: {type: 'number', default: 1}
required: ['a', 'b']
description: 'f(x) = a * ln(1/b * (x + b))'
]
default: linear: a: 1
c: {type: 'number', default: 1}
default: {kind: 'linear', parameters: a: 1}
required: ['kind', 'parameters']
additionalProperties: false
)
AchievementSchema.definitions = {}

View file

@ -20,15 +20,11 @@ module.exports =
href: '/db/achievement/{($)}'
}
]
collection:
type: 'string'
achievementName:
type: 'string'
created:
type: 'date'
changed:
type: 'date'
achievedAmount:
type: 'number'
notified:
type: 'boolean'
collection: type: 'string'
achievementName: type: 'string'
created: type: 'date'
changed: type: 'date'
achievedAmount: type: 'number'
earnedPoints: type: 'number'
previouslyAchievedAmount: {type: 'number', default: 0}
notified: type: 'boolean'

View file

@ -17,7 +17,7 @@ me.shortString = (ext) -> combine({type: 'string', maxLength: 100}, ext)
me.pct = (ext) -> combine({type: 'number', maximum: 1.0, minimum: 0.0}, ext)
me.date = (ext) -> combine({type: ['object', 'string'], format: 'date-time'}, ext)
# should just be string (Mongo ID), but sometimes mongoose turns them into objects representing those, so we are lenient
me.objectId = (ext) -> schema = combine(['object', 'string'], ext)
me.objectId = (ext) -> schema = combine({type: ['object', 'string'] }, ext)
me.url = (ext) -> combine({type: 'string', format: 'url', pattern: urlPattern}, ext)
PointSchema = me.object {title: "Point", description: "An {x, y} coordinate point.", format: "point2d", required: ["x", "y"]},

View file

@ -0,0 +1,12 @@
#editor-achievement-edit-view
.treema-root
margin: 28px 0px 20px
button
float: right
margin-top: 15px
margin-left: 10px
textarea
width: 92%
height: 300px

View file

@ -45,3 +45,6 @@
font-family: Bangers
font-size: 16px
float: right
.progress-bar-white
background-color: white

View file

@ -33,3 +33,11 @@ block content
a(href="/admin/base", data-i18n="admin.av_other_debug_base_url") Base (for debugging base.jade)
li
a(href="/admin/clas", data-i18n="admin.clas") CLAs
hr
h3 Achievements
p This is just some stuff for temporary achievement testing. Should be replaced by a demo system.
input#increment-field(type="text")
a.btn.btn-secondary#increment-button(href="#") Increment

View file

@ -11,19 +11,21 @@ block content
li.active
| #{achievement.attributes.name}
button(data-i18n="common.save", disabled=authorized === true ? undefined : "true").btn.btn-primary#save-button Save
button(data-i18n="", disabled=me.isAdmin() === true ? undefined : "true").btn.btn-primary#recalculate-button Recalculate
button(data-i18n="common.save", disabled=me.isAdmin() === true ? undefined : "true").btn.btn-primary#save-button Save
h3(data-i18n="achievement.edit_achievement_title") Edit Achievement
span
|: "#{achievement.attributes.name}"
h3(data-i18n="achievement.edit_achievement_title") Edit Achievement
span
|: "#{achievement.attributes.name}"
#achievement-treema
#achievement-treema
#achievement-view
#achievement-view
hr
hr
div#error-view
div#error-view
else
.alert.alert-danger
span Admin only. Turn around.

View file

@ -37,6 +37,7 @@ block content
h3(data-i18n="play_level.tip_reticulating") Reticulating Splines...
.progress.progress-striped.active
.progress-bar
else
.alert.alert-danger
span Admin only. Turn around.

View file

@ -0,0 +1,11 @@
extends /templates/modal/modal_base
block modal-header-content
h3 #{confirmTitle}
block modal-body-content
p #{confirmBody}
block modal-footer-content
button.btn.btn-secondary#decline-button(type="button", data-dismiss="modal") #{confirmDecline}
button.btn.btn-primary#confirm-button(type="button", data-dismiss=closeOnConfirm === true ? "modal" : undefined) #{confirmConfirm}

View file

@ -24,4 +24,4 @@
block modal-footer
.modal-footer
block modal-footer-content
button.btn.btn-primary(type="button", data-dismiss="modal", aria-hidden="true", data-i18n="modal.okay") Okay
button.btn.btn-primary(type="button", data-dismiss="modal", aria-hidden="true", data-i18n="modal.okay") Okay

View file

@ -224,8 +224,11 @@ module.exports = class ProfileView extends View
links = ($.extend(true, {}, link) for link in links)
link.icon = @iconForLink link for link in links
context.profileLinks = _.sortBy links, (link) -> not link.icon # icons first
context.sessions = (s.attributes for s in @sessions.models when (s.get('submitted') or s.get('level-id') is 'gridmancer'))
context.sessions.sort (a, b) -> (b.playtime ? 0) - (a.playtime ? 0)
if @sessions
context.sessions = (s.attributes for s in @sessions.models when (s.get('submitted') or s.get('level-id') is 'gridmancer'))
context.sessions.sort (a, b) -> (b.playtime ? 0) - (a.playtime ? 0)
else
context.sessions = []
context
afterRender: ->

View file

@ -8,6 +8,7 @@ module.exports = class AdminView extends View
events:
'click #enter-espionage-mode': 'enterEspionageMode'
'click #increment-button': 'incrementUserAttribute'
enterEspionageMode: ->
userEmail = $("#user-email").val().toLowerCase()
@ -29,3 +30,8 @@ module.exports = class AdminView extends View
espionageFailure: (jqxhr, status,error)->
console.log "There was an error entering espionage mode: #{error}"
incrementUserAttribute: (e) ->
val = $('#increment-field').val()
me.set(val, me.get(val) + 1)
me.save()

View file

@ -1,6 +1,7 @@
View = require 'views/kinds/RootView'
template = require 'templates/editor/achievement/edit'
Achievement = require 'models/Achievement'
ConfirmModal = require 'views/modal/confirm'
module.exports = class AchievementEditView extends View
id: "editor-achievement-edit-view"
@ -9,6 +10,7 @@ module.exports = class AchievementEditView extends View
events:
'click #save-button': 'saveAchievement'
'click #recalculate-button': 'confirmRecalculation'
subscriptions:
'save-new': 'saveAchievement'
@ -72,3 +74,34 @@ module.exports = class AchievementEditView extends View
res.success =>
url = "/editor/achievement/#{@achievement.get('slug') or @achievement.id}"
document.location.href = url
confirmRecalculation: (e) ->
renderData =
'confirmTitle': "Are you really sure?"
'confirmBody': "This will trigger recalculation of the achievement for all users. Are you really sure you want to go down this path?"
'confirmDecline': "Not really"
'confirmConfirm': "Definitely"
confirmModal = new ConfirmModal(renderData)
confirmModal.onConfirm @recalculateAchievement
@openModalView confirmModal
recalculateAchievement: =>
$.ajax
data: JSON.stringify(achievements: [@achievement.get('slug') or @achievement.get('_id')])
success: (data, status, jqXHR) ->
noty
timeout: 5000
text: 'Recalculation process started'
type: 'success'
layout: 'topCenter'
error: (jqXHR, status, error) ->
console.error jqXHR
noty
timeout: 5000
text: "Starting recalculation process failed with error code #{jqXHR.status}"
type: 'error'
layout: 'topCenter'
url: '/admin/earned.achievement/recalculate'
type: 'POST'
contentType: 'application/json'

View file

@ -4,7 +4,7 @@ ThangType = require 'models/ThangType'
CocoCollection = require 'collections/CocoCollection'
class ThangTypeSearchCollection extends CocoCollection
url: '/db/thang.type/search?project=true'
url: '/db/thang.type?project=true'
model: ThangType
addTerm: (term) ->
@ -73,4 +73,4 @@ module.exports = class AddThangsView extends View
onEscapePressed: ->
@$el.find('input#thang-search').val("")
@runSearch
@runSearch

View file

@ -5,7 +5,7 @@ LevelSystem = require 'models/LevelSystem'
CocoCollection = require 'collections/CocoCollection'
class LevelSystemSearchCollection extends CocoCollection
url: '/db/level_system/search'
url: '/db/level_system'
model: LevelSystem
module.exports = class LevelSystemAddView extends View

View file

@ -21,7 +21,7 @@ componentOriginals =
"physics.Physical" : "524b75ad7fc0f6d519000001"
class ThangTypeSearchCollection extends CocoCollection
url: '/db/thang.type/search?project=original,name,version,slug,kind,components'
url: '/db/thang.type?project=original,name,version,slug,kind,components'
model: ThangType
module.exports = class ThangsTabView extends View

View file

@ -8,6 +8,7 @@ locale = require 'locale/locale'
Achievement = require '../../models/Achievement'
User = require '../../models/User'
# TODO remove
filterKeyboardEvents = (allowedEvents, func) ->
return (splat...) ->
@ -25,26 +26,24 @@ module.exports = class RootView extends CocoView
subscriptions:
'achievements:new': 'handleNewAchievements'
initialize: ->
$ =>
# TODO Ruben remove this. Allows for easy testing right now though
#test = new Achievement(_id:'537ce4855c91b8d1dda7fda8')
#test.fetch(success:@showNewAchievement)
showNewAchievement: (achievement) ->
showNewAchievement: (achievement, earnedAchievement) ->
currentLevel = me.level()
nextLevel = currentLevel + 1
currentLevelExp = User.expForLevel(currentLevel)
nextLevelExp = User.expForLevel(nextLevel)
totalExpNeeded = nextLevelExp - currentLevelExp
expFunction = achievement.getExpFunction()
currentExp = me.get('points')
worth = achievement.get('worth')
leveledUp = currentExp - worth < currentLevelExp
alreadyAchievedPercentage = 100 * (currentExp - currentLevelExp - worth) / totalExpNeeded
newlyAchievedPercentage = if currentLevelExp is currentExp then 0 else 100 * worth / totalExpNeeded
previousExp = currentExp - achievement.get('worth')
previousExp = expFunction(earnedAchievement.get('previouslyAchievedAmount')) * achievement.get('worth') if achievement.isRepeatable()
achievedExp = currentExp - previousExp
leveledUp = currentExp - achievedExp < currentLevelExp
alreadyAchievedPercentage = 100 * (previousExp - currentLevelExp) / totalExpNeeded
newlyAchievedPercentage = if leveledUp then 100 * (currentExp - currentLevelExp) / totalExpNeeded else 100 * achievedExp / totalExpNeeded
console.debug "Current level is #{currentLevel} (#{currentLevelExp} xp), next level is #{nextLevel} (#{nextLevelExp} xp)."
console.debug "Need a total of #{nextLevelExp - currentLevelExp}, already had #{currentExp - currentLevelExp - worth} and just now earned #{worth} totalling on #{currentExp}"
console.debug "Need a total of #{nextLevelExp - currentLevelExp}, already had #{previousExp} and just now earned #{achievedExp} totalling on #{currentExp}"
alreadyAchievedBar = $("<div class='progress-bar progress-bar-warning' style='width:#{alreadyAchievedPercentage}%'></div>")
newlyAchievedBar = $("<div data-toggle='tooltip' class='progress-bar progress-bar-success' style='width:#{newlyAchievedPercentage}%'></div>")
@ -53,7 +52,7 @@ module.exports = class RootView extends CocoView
message = if (currentLevel isnt 1) and leveledUp then "Reached level #{currentLevel}!" else null
alreadyAchievedBar.tooltip(title: "#{currentExp} XP in total")
newlyAchievedBar.tooltip(title: "#{worth} XP earned")
newlyAchievedBar.tooltip(title: "#{achievedExp} XP earned")
emptyBar.tooltip(title: "#{nextLevelExp - currentExp} XP until level #{nextLevel}")
# TODO a default should be linked here
@ -63,7 +62,7 @@ module.exports = class RootView extends CocoView
image: $("<img src='#{imageURL}' />")
description: achievement.get('description')
progressBar: progressBar
earnedExp: "+ #{worth} XP"
earnedExp: "+ #{achievedExp} XP"
message: message
options =
@ -77,13 +76,11 @@ module.exports = class RootView extends CocoView
$.notify( data, options )
handleNewAchievements: (earnedAchievements) ->
console.debug 'Got new earned achievements'
# TODO performance?
_.each(earnedAchievements.models, (earnedAchievement) =>
achievement = new Achievement(_id: earnedAchievement.get('achievement'))
console.log achievement
achievement.fetch(
success: @showNewAchievement
success: (achievement) => @showNewAchievement(achievement, earnedAchievement)
)
)

View file

@ -5,7 +5,7 @@ app = require('application')
class SearchCollection extends Backbone.Collection
initialize: (modelURL, @model, @term, @projection) ->
@url = "#{modelURL}/search?project="
@url = "#{modelURL}?project="
if @projection? and not (@projection == [])
@url += projection[0]
@url += ',' + projected for projected in projection[1..]

View file

@ -0,0 +1,30 @@
ModalView = require '../kinds/ModalView'
template = require 'templates/modal/confirm'
module.exports = class ConfirmModal extends ModalView
id: "confirm-modal"
template: template
closeButton: true
closeOnConfirm: true
events:
'click #decline-button': 'doDecline'
'click #confirm-button': 'doConfirm'
constructor: (@renderData={}, options={}) ->
super(options)
getRenderData: ->
context = super()
context.closeOnConfirm = @closeOnConfirm
_.extend context, @renderData
setRenderData: (@renderData) ->
onDecline: (@decline) ->
onConfirm: (@confirm) ->
doConfirm: -> @confirm() if @confirm
doDecline: -> @decline() if @decline

View file

@ -1,6 +1,8 @@
mongoose = require('mongoose')
jsonschema = require('../../app/schemas/models/achievement')
log = require 'winston'
util = require '../../app/lib/utils'
plugins = require('../plugins/plugins')
# `pre` and `post` are not called for update operations executed directly on the database,
# including `Model.update`,`.findByIdAndUpdate`,`.findOneAndUpdate`, `.findOneAndRemove`,and `.findByIdAndRemove`.order
@ -11,16 +13,21 @@ AchievementSchema = new mongoose.Schema({
userField: String
}, {strict: false})
AchievementSchema.methods.objectifyQuery = () ->
AchievementSchema.methods.objectifyQuery = ->
try
@set('query', JSON.parse(@get('query'))) if typeof @get('query') == "string"
catch error
log.error "Couldn't convert query string to object because of #{error}"
@set('query', {})
AchievementSchema.methods.stringifyQuery = () ->
AchievementSchema.methods.stringifyQuery = ->
@set('query', JSON.stringify(@get('query'))) if typeof @get('query') != "string"
getExpFunction: ->
kind = @get('function')?.kind or jsonschema.function.default.kind
parameters = @get('function')?.parameters or jsonschema.function.default.parameters
return utils.functionCreators[kind](parameters) if kind of utils.functionCreators
AchievementSchema.post('init', (doc) -> doc.objectifyQuery())
AchievementSchema.pre('save', (next) ->
@ -28,9 +35,11 @@ AchievementSchema.pre('save', (next) ->
next()
)
module.exports = Achievement = mongoose.model('Achievement', AchievementSchema)
plugins = require('../plugins/plugins')
AchievementSchema.plugin(plugins.NamedPlugin)
AchievementSchema.plugin(plugins.SearchablePlugin, {searchable: ['name']})
module.exports = Achievement = mongoose.model('Achievement', AchievementSchema)
# Reload achievements upon save
AchievablePlugin = require '../plugins/achievements'
AchievementSchema.post 'save', (doc) -> AchievablePlugin.loadAchievements()

View file

@ -13,7 +13,15 @@ EarnedAchievementSchema = new mongoose.Schema({
default: false
}, {strict:false})
EarnedAchievementSchema.pre 'save', (next) ->
@set('changed', Date.now())
next()
EarnedAchievementSchema.index({user: 1, achievement: 1}, {unique: true, name: 'earned achievement index'})
EarnedAchievementSchema.index({user: 1, changed: -1}, {name: 'latest '})
module.exports = EarnedAchievement = mongoose.model('EarnedAchievement', EarnedAchievementSchema)
module.exports = EarnedAchievement = mongoose.model('EarnedAchievement', EarnedAchievementSchema)

View file

@ -5,17 +5,11 @@ class AchievementHandler extends Handler
modelClass: Achievement
# Used to determine which properties requests may edit
editableProperties: ['name', 'query', 'worth', 'collection', 'description', 'userField', 'proportionalTo', 'icon']
editableProperties: ['name', 'query', 'worth', 'collection', 'description', 'userField', 'proportionalTo', 'icon', 'function']
jsonSchema = require '../../app/schemas/models/achievement.coffee'
hasAccess: (req) ->
req.method is 'GET' or req.user?.isAdmin()
get: (req, res) ->
query = @modelClass.find({})
query.exec (err, documents) =>
return @sendDatabaseError(res, err) if err
documents = (@formatEntity(req, doc) for doc in documents)
@sendSuccess(res, documents)
module.exports = new AchievementHandler()

View file

@ -1,12 +1,115 @@
mongoose = require('mongoose')
log = require 'winston'
mongoose = require 'mongoose'
async = require 'async'
Achievement = require './Achievement'
EarnedAchievement = require './EarnedAchievement'
User = require '../users/User'
Handler = require '../commons/Handler'
LocalMongo = require '../../app/lib/LocalMongo'
class EarnedAchievementHandler extends Handler
modelClass: EarnedAchievement
# Don't allow POSTs or anything yet
hasAccess: (req) ->
req.method is 'GET'
req.method is 'GET' # or req.user.isAdmin()
recalculate: (req, res) ->
onSuccess = (data) => log.debug "Finished recalculating achievements"
if 'achievements' of req.body # Support both slugs and IDs separated by commas
achievementSlugsOrIDs = req.body.achievements
EarnedAchievementHandler.recalculate achievementSlugsOrIDs, onSuccess
else
EarnedAchievementHandler.recalculate onSuccess
@sendSuccess res, {}
# Returns success: boolean
# TODO call onFinished
@recalculate: (callbackOrSlugsOrIDs, onFinished) ->
if _.isArray callbackOrSlugsOrIDs
achievementSlugs = (thing for thing in callbackOrSlugsOrIDs when not Handler.isID(thing))
achievementIDs = (thing for thing in callbackOrSlugsOrIDs when Handler.isID(thing))
else
onFinished = callbackOrSlugsOrIDs
filter = {}
filter.$or = [
{_id: $in: achievementIDs},
{slug: $in: achievementSlugs}
] if achievementSlugs? or achievementIDs?
# Fetch all relevant achievements
Achievement.find filter, (err, achievements) ->
return log.error err if err?
# Fetch every single user
User.find {}, (err, users) ->
_.each users, (user) ->
# Keep track of a user's already achieved in order to set the notified values correctly
userID = user.get('_id').toHexString()
# Fetch all of a user's earned achievements
EarnedAchievement.find {user: userID}, (err, alreadyEarned) ->
alreadyEarnedIDs = []
previousPoints = 0
_.each alreadyEarned, (earned) ->
if (_.find achievements, (single) -> earned.get('achievement') is single.get('_id').toHexString())
alreadyEarnedIDs.push earned.get('achievement')
previousPoints += earned.get 'earnedPoints'
# TODO maybe also delete earned? Make sure you don't delete too many
newTotalPoints = 0
earnedAchievementSaverGenerator = (achievement) -> (callback) ->
isRepeatable = achievement.get('proportionalTo')?
model = mongoose.model(achievement.get('collection'))
if not model?
log.error "Model #{achievement.get 'collection'} doesn't even exist."
return callback()
model.findOne achievement.query, (err, something) ->
return callback() unless something
log.debug "Matched an achievement: #{achievement.get 'name'}"
earned =
user: userID
achievement: achievement._id.toHexString()
achievementName: achievement.get 'name'
notified: achievement._id in alreadyEarnedIDs
if isRepeatable
earned.achievedAmount = something.get(achievement.get 'proportionalTo')
earned.previouslyAchievedAmount = 0
expFunction = achievement.getExpFunction()
newPoints = expFunction(earned.achievedAmount) * achievement.get('worth')
else
newPoints = achievement.get 'worth'
earned.earnedPoints = newPoints
newTotalPoints += newPoints
EarnedAchievement.update {achievement:earned.achievement, user:earned.user}, earned, {upsert: true}, (err) ->
log.error err if err?
callback()
saveUserPoints = (callback) ->
# In principle it is enough to deduct the old amount of points and add the new amount,
# but just to be entirely safe let's start from 0 in case we're updating all of a user's achievements
log.debug "Matched a total of #{newTotalPoints} new points"
if _.isEmpty filter # Completely clean
User.update {_id: userID}, {$set: points: newTotalPoints}, {}, (err) -> log.error err if err?
else
log.debug "Incrementing score for these achievements with #{newTotalPoints - previousPoints}"
User.update {_id: userID}, {$inc: points: newTotalPoints - previousPoints}, {}, (err) -> log.error err if err?
earnedAchievementSavers = (earnedAchievementSaverGenerator(achievement) for achievement in achievements)
earnedAchievementSavers.push saveUserPoints
# We need to have all these database updates chained so we know the final score
async.series earnedAchievementSavers
module.exports = new EarnedAchievementHandler()

View file

@ -17,6 +17,7 @@ module.exports = class Handler
postEditableProperties: []
jsonSchema: {}
waterfallFunctions: []
allowedMethods: ['GET', 'POST', 'PUT', 'PATCH']
# subclasses should override these methods
hasAccess: (req) -> true
@ -63,26 +64,72 @@ module.exports = class Handler
# generic handlers
get: (req, res) ->
# by default, ordinary users never get unfettered access to the database
return @sendUnauthorizedError(res) unless req.user?.isAdmin()
@sendUnauthorizedError(res) if not @hasAccess(req)
# admins can send any sort of query down the wire, though
conditions = JSON.parse(req.query.conditions || '[]')
query = @modelClass.find()
specialParameters = ['term', 'project', 'conditions']
try
for condition in conditions
name = condition[0]
f = query[name]
args = condition[1..]
query = query[name](args...)
catch e
return @sendError(res, 422, 'Badly formed conditions.')
# If the model uses coco search it's probably a text search
if @modelClass.schema.uses_coco_search
term = req.query.term
matchedObjects = []
filters = if @modelClass.schema.uses_coco_versions or @modelClass.schema.uses_coco_permissions then [filter: {index: true}] else [filter: {}]
if @modelClass.schema.uses_coco_permissions and req.user
filters.push {filter: {index: req.user.get('id')}}
projection = null
if req.query.project is 'true'
projection = PROJECT
else if req.query.project
if @modelClass.className is 'User'
projection = PROJECT
log.warn "Whoa, we haven't yet thought about public properties for User projection yet."
else
projection = {}
projection[field] = 1 for field in req.query.project.split(',')
for filter in filters
callback = (err, results) =>
return @sendDatabaseError(res, 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
res.send matchedObjects
res.end()
if term
filter.project = projection
@modelClass.textSearch term, filter, callback
else
args = [filter.filter]
args.push projection if projection
@modelClass.find(args...).limit(FETCH_LIMIT).exec callback
# if it's not a text search but the user is an admin, let him try stuff anyway
else if req.user?.isAdmin()
# admins can send any sort of query down the wire
filter = {}
filter[key] = (val for own key, val of req.query.filter when key not in specialParameters) if 'filter' of req.query
query = @modelClass.find(filter)
# Conditions are chained query functions, for example: query.find().limit(20).sort('-dateCreated')
conditions = JSON.parse(req.query.conditions || '[]')
try
for condition in conditions
name = condition[0]
f = query[name]
args = condition[1..]
query = query[name](args...)
catch e
return @sendError(res, 422, 'Badly formed conditions.')
query.exec (err, documents) =>
return @sendDatabaseError(res, err) if err
documents = (@formatEntity(req, doc) for doc in documents)
@sendSuccess(res, documents)
# regular users are only allowed text searches for now, without any additional filters or sorting
else
return @sendUnauthorizedError(res)
query.exec (err, documents) =>
return @sendDatabaseError(res, err) if err
documents = (@formatEntity(req, doc) for doc in documents)
@sendSuccess(res, documents)
getById: (req, res, id) ->
# return @sendNotFoundError(res) # for testing
@ -153,44 +200,6 @@ module.exports = class Handler
return @sendDatabaseError(res, err) if err
@sendSuccess(res, @formatEntity(req, document))
# project=true or project=name,description,slug for example
search: (req, res) ->
unless @modelClass.schema.uses_coco_search
return @sendNotFoundError(res)
term = req.query.term
matchedObjects = []
filters = if @modelClass.schema.uses_coco_versions or @modelClass.schema.uses_coco_permissions then [filter: {index: true}] else [filter: {}]
if @modelClass.schema.uses_coco_permissions and req.user
filters.push {filter: {index: req.user.get('id')}}
projection = null
if req.query.project is 'true'
projection = PROJECT
else if req.query.project
if @modelClass.className is 'User'
projection = PROJECT
log.warn "Whoa, we haven't yet thought about public properties for User projection yet."
else
projection = {}
projection[field] = 1 for field in req.query.project.split(',')
for filter in filters
callback = (err, results) =>
return @sendDatabaseError(res, 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
res.send matchedObjects
res.end()
if term
filter.project = projection
@modelClass.textSearch term, filter, callback
else
args = [filter.filter]
args.push projection if projection
@modelClass.find(args...).limit(FETCH_LIMIT).exec callback
versions: (req, res, id) ->
# TODO: a flexible system for doing GAE-like cursors for these sort of paginating queries
# Keeping it simple for now and just allowing access to the first FETCH_LIMIT results.
@ -420,3 +429,7 @@ module.exports = class Handler
dict[document.id] = document
res.send dict
res.end()
delete: (req, res) -> @sendMethodNotAllowed res, @allowedMethods, "DELETE not allowed."
head: (req, res) -> @sendMethodNotAllowed res, @allowedMethods, "HEAD not allowed."

View file

@ -17,8 +17,9 @@ module.exports.notFound = (res, message='Not found.') ->
res.send 404, message
res.end()
module.exports.badMethod = (res, message='Method Not Allowed') ->
# TODO: The response MUST include an Allow header containing a list of valid methods for the requested resource
module.exports.badMethod = (res, allowed=['GET', 'POST', 'PUT', 'PATCH'], message='Method Not Allowed') ->
allowHeader = _.reduce allowed, ((str, current) -> str += ', ' + current)
res.set 'Allow', allowHeader # TODO not sure if these are always the case
res.send 405, message
res.end()
@ -40,4 +41,4 @@ module.exports.gatewayTimeoutError = (res, message="Gateway timeout") ->
module.exports.clientTimeout = (res, message="The server did not recieve the client response in a timely manner") ->
res.send 408, message
res.end()
res.end()

View file

@ -14,6 +14,7 @@ module.exports.handlers =
module.exports.routes =
[
'routes/admin'
'routes/auth'
'routes/contact'
'routes/db'

View file

@ -1,5 +1,4 @@
mongoose = require('mongoose')
Achievement = require('../achievements/Achievement')
EarnedAchievement = require '../achievements/EarnedAchievement'
LocalMongo = require '../../app/lib/LocalMongo'
util = require '../../app/lib/utils'
@ -7,18 +6,9 @@ log = require 'winston'
achievements = {}
loadAchievements = ->
achievements = {}
query = Achievement.find({})
query.exec (err, docs) ->
_.each docs, (achievement) ->
category = achievement.get 'collection'
achievements[category] = [] unless category of achievements
achievements[category].push achievement
loadAchievements()
module.exports = AchievablePlugin = (schema, options) ->
User = require '../users/User'
User = require '../users/User' # Avoid mutual inclusion cycles
Achievement = require('../achievements/Achievement')
checkForAchievement = (doc) ->
collectionName = doc.constructor.modelName
@ -54,6 +44,8 @@ module.exports = AchievablePlugin = (schema, options) ->
achievement: achievement._id.toHexString()
achievementName: achievement.get 'name'
}
worth = achievement.get('worth')
earnedPoints = 0
wrapUp = ->
# Update user's experience points
@ -68,23 +60,37 @@ module.exports = AchievablePlugin = (schema, options) ->
newAmount = docObj[proportionalTo]
if originalAmount isnt newAmount
expFunction = achievement.getExpFunction()
earned.notified = false
earned.achievedAmount = newAmount
earned.changed = Date.now()
EarnedAchievement.findOneAndUpdate({achievement:earned.achievement, user:earned.user}, earned, upsert:true, (err, docs) ->
return log.debug err if err?
)
earned.earnedPoints = (expFunction(newAmount) - expFunction(originalAmount)) * worth
earned.previouslyAchievedAmount = originalAmount
EarnedAchievement.update {achievement:earned.achievement, user:earned.user}, earned, {upsert: true}, (err) ->
return log.debug err if err?
earnedPoints = achievement.get('worth') * (newAmount - originalAmount)
earnedPoints = earned.earnedPoints
log.debug earnedPoints
wrapUp()
else # not alreadyAchieved
log.debug 'Creating a new earned achievement called \'' + (achievement.get 'name') + '\' for ' + userID
earned.earnedPoints = worth
(new EarnedAchievement(earned)).save (err, doc) ->
return log.debug err if err?
earnedPoints = achievement.get('worth')
earnedPoints = worth
wrapUp()
delete before[doc.id] unless isNew # This assumes everything we patch has a _id
return
module.exports.loadAchievements = ->
achievements = {}
Achievement = require('../achievements/Achievement')
query = Achievement.find({})
query.exec (err, docs) ->
_.each docs, (achievement) ->
category = achievement.get 'collection'
achievements[category] = [] unless category of achievements
achievements[category].push achievement
AchievablePlugin.loadAchievements()

View file

@ -23,7 +23,7 @@ module.exports.PatchablePlugin = (schema) ->
schema.is_patchable = true
schema.index({'target.original':1, 'status':'1', 'created':-1})
RESERVED_NAMES = ['search', 'names']
RESERVED_NAMES = ['names']
module.exports.NamedPlugin = (schema) ->
schema.uses_coco_names = true

View file

@ -0,0 +1,27 @@
log = require 'winston'
errors = require '../commons/errors'
handlers = require('../commons/mapping').handlers
mongoose = require('mongoose')
module.exports.setup = (app) ->
app.post '/admin/*', (req, res) ->
# TODO apparently I can leave this out as long as I use res.send
res.setHeader('Content-Type', 'application/json')
module = req.path[7..]
parts = module.split('/')
module = parts[0]
return errors.unauthorized(res, 'Must be admin to access this area.') unless req.user?.isAdmin()
try
moduleName = module.replace '.', '_'
name = handlers[moduleName]
handler = require('../' + name)
return handler[parts[1]](req, res, parts[2..]...) if parts[1] of handler
catch error
log.error("Error trying db method '#{req.route.method}' route '#{parts}' from #{name}: #{error}")
errors.notFound(res, "Route #{req.path} not found.")

View file

@ -34,7 +34,6 @@ module.exports.setup = (app) ->
return handler.getLatestVersion(req, res, parts[1], parts[3]) if parts[2] is 'version'
return handler.versions(req, res, parts[1]) if parts[2] is 'versions'
return handler.files(req, res, parts[1]) if parts[2] is 'files'
return handler.search(req, res) if req.route.method is 'get' and parts[1] is 'search'
return handler.getNamesByIDs(req, res) if req.route.method in ['get', 'post'] and parts[1] is 'names'
return handler.getByRelationship(req, res, parts[1..]...) if parts.length > 2
return handler.getById(req, res, parts[1]) if req.route.method is 'get' and parts[1]?

View file

@ -8,7 +8,7 @@ module.exports.setup = (app) ->
app.all '/file*', (req, res) ->
return fileGet(req, res) if req.route.method is 'get'
return filePost(req, res) if req.route.method is 'post'
return errors.badMethod(res)
return errors.badMethod(res, ['GET', 'POST'])
fileGet = (req, res) ->

View file

@ -4,7 +4,7 @@ errors = require '../commons/errors'
module.exports.setup = (app) ->
app.all '/folder*', (req, res) ->
return folderGet(req, res) if req.route.method is 'get'
return errors.badMethod(res)
return errors.badMethod(res, ['GET'])
folderGet = (req, res) ->
folder = req.path[7..]
@ -15,4 +15,4 @@ folderGet = (req, res) ->
mongoose.connection.db.collection 'media.files', (errors, collection) ->
collection.find({'metadata.path': folder}).toArray (err, results) ->
res.send(results)
res.end()
res.end()

View file

@ -11,7 +11,7 @@ module.exports.setup = (app) ->
app.all '/languages', (req, res) ->
# Now that these are in the client, not sure when we would use this, but hey
return errors.badMethod(res) if req.route.method isnt 'get'
return errors.badMethod(res, ['GET']) if req.route.method isnt 'get'
res.send(languages)
return res.end()

View file

@ -28,7 +28,7 @@ module.exports.setup = (app) ->
app.all '/queue/*', (req, res) ->
setResponseHeaderToJSONContentType res
queueName = getQueueNameFromPath req.path
try
handler = loadQueueHandler queueName
@ -64,7 +64,7 @@ isHTTPMethodPost = (req) -> return req.route.method is 'post'
isHTTPMethodPut = (req) -> return req.route.method is 'put'
sendMethodNotSupportedError = (req, res) -> errors.badMethod(res,"Queues do not support the HTTP method used." )
sendMethodNotSupportedError = (req, res) -> errors.badMethod(res, ['GET', 'POST', 'PUT'], "Queues do not support the HTTP method used." )
sendQueueError = (req,res, error) -> errors.serverError(res, "Route #{req.path} had a problem: #{error}")

View file

@ -2,7 +2,7 @@ describe 'Local Mongo queries', ->
LocalMongo = require 'lib/LocalMongo'
beforeEach ->
this.fixture1 =
@fixture1 =
'id': 'somestring'
'value': 9000
'levels': [3, 8, 21]
@ -10,68 +10,72 @@ describe 'Local Mongo queries', ->
'type': 'unicorn'
'likes': ['poptarts', 'popsicles', 'popcorn']
this.fixture2 = this: is: so: 'deep'
@fixture2 = this: is: so: 'deep'
it 'regular match of a property', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'gender': 'unicorn')).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'type':'unicorn')).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'type':'zebra')).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'type':'unicorn', 'id':'somestring')).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'gender': 'unicorn')).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'type':'unicorn')).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'type':'zebra')).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'type':'unicorn', 'id':'somestring')).toBeTruthy()
it 'array match of a property', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'likes':'poptarts')).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'likes':'walks on the beach')).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'likes':'poptarts')).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'likes':'walks on the beach')).toBeFalsy()
it 'nested match', ->
expect(LocalMongo.matchesQuery(this.fixture2, 'this.is.so':'deep')).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture2, 'this.is.so':'deep')).toBeTruthy()
it '$gt selector', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$gt': 8000)).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$gt': [8000, 10000])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'levels': '$gt': [10, 20, 30])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$gt': 9000)).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': {'$gt': 8000}, 'worth': {'$gt': 5})).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$gt': 8000)).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$gt': [8000, 10000])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'levels': '$gt': [10, 20, 30])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$gt': 9000)).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': {'$gt': 8000}, 'worth': {'$gt': 5})).toBeTruthy()
it '$gte selector', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$gte': 9001)).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$gte': 9000)).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$gte': [9000, 10000])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'levels': '$gte': [21, 30])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$gte': 9001)).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$gte': 9000)).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$gte': [9000, 10000])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'levels': '$gte': [21, 30])).toBeTruthy()
it '$lt selector', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$lt': 9001)).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$lt': 9000)).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$lt': [9001, 9000])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'levels': '$lt': [10, 20, 30])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': {'$lt': 9001}, 'worth': {'$lt': 7})).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$lt': 9001)).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$lt': 9000)).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$lt': [9001, 9000])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'levels': '$lt': [10, 20, 30])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': {'$lt': 9001}, 'worth': {'$lt': 7})).toBeTruthy()
it '$lte selector', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$lte': 9000)).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$lte': 8000)).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': {'$lte': 9000}, 'worth': {'$lte': [6, 5]})).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$lte': 9000)).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$lte': 8000)).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': {'$lte': 9000}, 'worth': {'$lte': [6, 5]})).toBeTruthy()
it '$ne selector', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$ne': 9000)).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'id': '$ne': 'otherstring')).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'id': '$ne': ['otherstring', 'somestring'])).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'likes': '$ne': ['popcorn', 'chicken'])).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$ne': 9000)).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'id': '$ne': 'otherstring')).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'id': '$ne': ['otherstring', 'somestring'])).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'likes': '$ne': ['popcorn', 'chicken'])).toBeFalsy()
it '$in selector', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'type': '$in': ['unicorn', 'zebra'])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'type': '$in': ['cats', 'dogs'])).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'likes': '$in': ['popcorn', 'chicken'])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'type': '$in': ['unicorn', 'zebra'])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'type': '$in': ['cats', 'dogs'])).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'likes': '$in': ['popcorn', 'chicken'])).toBeTruthy()
it '$nin selector', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'type': '$nin': ['unicorn', 'zebra'])).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'type': '$nin': ['cats', 'dogs'])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'likes': '$nin': ['popcorn', 'chicken'])).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'type': '$nin': ['unicorn', 'zebra'])).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'type': '$nin': ['cats', 'dogs'])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'likes': '$nin': ['popcorn', 'chicken'])).toBeFalsy()
it '$or operator', ->
expect(LocalMongo.matchesQuery(this.fixture1, $or: [{value:9000}, {type:'zebra'}])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, $or: [{value:9001}, {worth:$lt:10}])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, $or: [{value:9000}, {type:'zebra'}])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, $or: [{value:9001}, {worth:$lt:10}])).toBeTruthy()
it '$and operator', ->
expect(LocalMongo.matchesQuery(this.fixture1, $and: [{value:9000}, {type:'zebra'}])).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, $and: [{value:9000}, {type:'unicorn'}])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, $and: [{value:$gte:9000}, {worth:$lt:10}])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, $and: [{value:9000}, {type:'zebra'}])).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, $and: [{value:9000}, {type:'unicorn'}])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, $and: [{value:$gte:9000}, {worth:$lt:10}])).toBeTruthy()
it '$exists operator', ->
expect(LocalMongo.matchesQuery(@fixture1, type: $exists: true)).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, interesting: $exists: false)).toBeTruthy()

View file

@ -30,6 +30,8 @@ models_path = [
'../../server/levels/thangs/LevelThangType'
'../../server/users/User'
'../../server/patches/Patch'
'../../server/achievements/Achievement'
'../../server/achievements/EarnedAchievement'
]
for m in models_path
@ -162,4 +164,4 @@ tick = ->
mongoose.disconnect()
clearTimeout tickInterval
tickInterval = setInterval tick, 1000
tickInterval = setInterval tick, 1000

View file

@ -0,0 +1,110 @@
require '../common'
unlockable =
name: 'Dungeon Arena Started'
description: 'Started playing Dungeon Arena.'
worth: 3
collection: 'level.session'
query: "{\"level.original\":\"dungeon-arena\"}"
userField: 'creator'
repeatable =
name: 'Simulated'
description: 'Simulated Games.'
worth: 1
collection: 'User'
query: "{\"simulatedBy\":{\"$gt\":\"0\"}}"
userField: '_id'
proportionalTo: 'simulatedBy'
url = getURL('/db/achievement')
describe 'Achievement', ->
allowHeader = 'GET, POST, PUT, PATCH'
it 'preparing test: deleting all Achievements first', (done) ->
clearModels [Achievement, EarnedAchievement, LevelSession, User], (err) ->
expect(err).toBeNull()
done()
it 'can\'t be created by ordinary users', (done) ->
loginJoe ->
request.post {uri: url, json: unlockable}, (err, res, body) ->
expect(res.statusCode).toBe(403)
done()
it 'can\'t be updated by ordinary users', (done) ->
loginJoe ->
request.put {uri: url, json:unlockable}, (err, res, body) ->
expect(res.statusCode).toBe(403)
request {method: 'patch', uri: url, json: unlockable}, (err, res, body) ->
expect(res.statusCode).toBe(403)
done()
it 'can be created by admins', (done) ->
loginAdmin ->
request.post {uri: url, json: unlockable}, (err, res, body) ->
expect(res.statusCode).toBe(200)
unlockable._id = body._id
request.post {uri: url, json: repeatable}, (err, res, body) ->
expect(res.statusCode).toBe(200)
repeatable._id = body._id
done()
it 'can get all for ordinary users', (done) ->
loginJoe ->
request.get {uri: url, json: unlockable}, (err, res, body) ->
expect(res.statusCode).toBe(200)
expect(body.length).toBe(2)
done()
it 'can be read by ordinary users', (done) ->
loginJoe ->
request.get {uri: url + '/' + unlockable._id, json: unlockable}, (err, res, body) ->
expect(res.statusCode).toBe(200)
expect(body.name).toBe(unlockable.name)
done()
it 'can\'t be requested with HTTP HEAD method', (done) ->
loginJoe ->
request.head {uri: url + '/' + unlockable._id}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it 'can\'t be requested with HTTP DEL method', (done) ->
loginJoe ->
request.del {uri: url + '/' + unlockable._id}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
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()
done()
describe 'Achieving Achievements', ->
it 'allows users to unlock one-time Achievements', (done) ->
loginJoe (joe) ->
levelSession =
creator: joe._id
level: original: 'dungeon-arena'
request.post {uri:getURL('/db/level.session'), json:levelSession}, (session) ->
done()
xit 'cleaning up test: deleting all Achievements and relates', (done) ->
clearModels [Achievement, EarnedAchievement, LevelSession], (err) ->
expect(err).toBeNull()
done()

View file

@ -8,6 +8,8 @@ describe '/db/article', ->
done()
article = {name: 'Yo', body:'yo ma'}
article2 = {name: 'Original', body:'yo daddy'}
url = getURL('/db/article')
articles = {}
@ -27,11 +29,22 @@ describe '/db/article', ->
expect(body.original).toBeDefined()
expect(body.creator).toBeDefined()
articles[0] = body
done()
# 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 = '...'
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)
@ -61,7 +74,6 @@ describe '/db/article', ->
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])
@ -87,9 +99,41 @@ describe '/db/article', ->
it 'does not allow naming an article a reserved word', (done) ->
loginAdmin ->
new_article = {name: 'Search', body:'is a reserved word'}
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 '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()
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].created).toBeUndefined()
expect(body[0].version).toBeDefined()
# 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()
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()

View file

@ -28,6 +28,8 @@ xdescribe '/file', ->
my_buffer_url: 'http://fc07.deviantart.net/fs37/f/2008/283/5/1/Chu_Chu_Pikachu_by_angelishi.gif'
}
allowHeader = 'GET, POST'
it 'preparing test : deletes all the files first', (done) ->
dropGridFS ->
done()
@ -147,19 +149,28 @@ xdescribe '/file', ->
request.post(options, func)
it ' can\'t be requested with HTTP PATCH method', (done) ->
request {method: 'patch', uri:url}, (err, res) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it ' can\'t be requested with HTTP PUT method', (done) ->
request.put {uri:url}, (err, res) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it ' can\'t be requested with HTTP HEAD method', (done) ->
request.head {uri:url}, (err, res) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it ' can\'t be requested with HTTP DEL method', (done) ->
request.del {uri:url}, (err, res) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
# TODO: test server errors, see what they do

View file

@ -0,0 +1,35 @@
require '../common'
describe 'folder', ->
url = getURL('/folder')
allowHeader = 'GET'
it 'can\'t be requested with HTTP POST method', (done) ->
request.post {uri: url}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it 'can\'t be requested with HTTP PUT method', (done) ->
request.put {uri: url}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it 'can\'t be requested with HTTP PATCH method', (done) ->
request {method:'patch', uri: url}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it 'can\'t be requested with HTTP HEAD method', (done) ->
request.head {uri: url}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it 'can\'t be requested with HTTP DELETE method', (done) ->
request.del {uri: url}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()

View file

@ -0,0 +1,35 @@
require '../common'
describe 'languages', ->
url = getURL('/languages')
allowHeader = 'GET'
it 'can\'t be requested with HTTP POST method', (done) ->
request.post {uri: url}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it 'can\'t be requested with HTTP PUT method', (done) ->
request.put {uri: url}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it 'can\'t be requested with HTTP PATCH method', (done) ->
request {method:'patch', uri: url}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it 'can\'t be requested with HTTP HEAD method', (done) ->
request.head {uri: url}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it 'can\'t be requested with HTTP DELETE method', (done) ->
request.del {uri: url}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()

View file

@ -130,17 +130,17 @@ describe 'LevelComponent', ->
xit ' can\'t be requested with HTTP PUT method', (done) ->
request.put {uri:url+'/'+components[0]._id}, (err, res) ->
expect(res.statusCode).toBe(404)
expect(res.statusCode).toBe(405)
done()
it ' can\'t be requested with HTTP HEAD method', (done) ->
request.head {uri:url+'/'+components[0]._id}, (err, res) ->
expect(res.statusCode).toBe(404)
expect(res.statusCode).toBe(405)
done()
it ' can\'t be requested with HTTP DEL method', (done) ->
request.del {uri:url+'/'+components[0]._id}, (err, res) ->
expect(res.statusCode).toBe(404)
expect(res.statusCode).toBe(405)
done()
it 'get schema', (done) ->

View file

@ -123,12 +123,12 @@ describe 'LevelSystem', ->
it ' can\'t be requested with HTTP HEAD method', (done) ->
request.head {uri:url+'/'+systems[0]._id}, (err, res) ->
expect(res.statusCode).toBe(404)
expect(res.statusCode).toBe(405)
done()
it ' can\'t be requested with HTTP DEL method', (done) ->
request.del {uri:url+'/'+systems[0]._id}, (err, res) ->
expect(res.statusCode).toBe(404)
expect(res.statusCode).toBe(405)
done()
it 'get schema', (done) ->

View file

@ -0,0 +1,25 @@
require '../common'
describe 'queue', ->
someURL = getURL('/queue/')
allowHeader = 'GET, POST, PUT'
xit 'can\'t be requested with HTTP PATCH method', (done) ->
request {method:'patch', uri: someURL}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
xit 'can\'t be requested with HTTP HEAD method', (done) ->
request.head {uri: someURL}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
xit 'can\'t be requested with HTTP DELETE method', (done) ->
request.del {uri: someURL}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()