From 46ac89f43f52f0448e07e3a6fe4d76c3c07fa5e2 Mon Sep 17 00:00:00 2001 From: Dan Rosart Date: Sat, 11 Aug 2018 17:55:48 -0700 Subject: [PATCH 1/4] Add test suite for Meteor methods and some other server-side code. --- .gitignore | 2 + .meteor/packages | 3 + .meteor/versions | 7 + .travis.yml | 14 + README.md | 2 + lib/imports/canonical.coffee | 10 + lib/imports/canonical.test.coffee | 25 ++ lib/imports/match.coffee | 18 + lib/imports/match.test.coffee | 64 ++++ lib/imports/tags.coffee | 21 ++ lib/imports/tags.test.coffee | 69 ++++ lib/methods/addIncorrectAnswer.test.coffee | 102 ++++++ lib/methods/cancelCallIn.test.coffee | 57 +++ lib/methods/correctCallIn.test.coffee | 141 ++++++++ lib/methods/deleteAnswer.test.coffee | 167 +++++++++ lib/methods/deletePuzzle.test.coffee | 92 +++++ lib/methods/deleteRound.test.coffee | 128 +++++++ lib/methods/deleteRoundGroup.test.coffee | 92 +++++ lib/methods/incorrectCallIn.test.coffee | 66 ++++ lib/methods/locateNick.test.coffee | 73 ++++ lib/methods/newCallIn.test.coffee | 179 ++++++++++ lib/methods/newPuzzle.test.coffee | 95 +++++ lib/methods/newQuip.test.coffee | 45 +++ lib/methods/newRound.test.coffee | 100 ++++++ lib/methods/newRoundGroup.test.coffee | 89 +++++ lib/methods/renamePuzzle.test.coffee | 125 +++++++ lib/methods/renameRound.test.coffee | 126 +++++++ lib/methods/renameRoundGroup.test.coffee | 116 ++++++ lib/methods/setAnswer.test.coffee | 248 +++++++++++++ lib/methods/setPresence.test.coffee | 134 +++++++ lib/methods/summon.test.coffee | 250 +++++++++++++ lib/methods/unsummon.test.coffee | 143 ++++++++ lib/methods/updateLastRead.test.coffee | 54 +++ lib/methods/useQuip.test.coffee | 78 ++++ lib/model.coffee | 391 ++------------------- package-lock.json | 190 ++++++++++ package.json | 27 ++ server/batch.coffee | 307 ++++++++++++++++ server/drive.coffee | 257 +------------- server/imports/drive.coffee | 224 ++++++++++++ server/imports/drive.test.coffee | 330 +++++++++++++++++ server/{ => imports}/emoji.coffee | 2 +- server/{ => imports}/emoji.json | 0 server/imports/emoji.test.coffee | 9 + 44 files changed, 4064 insertions(+), 608 deletions(-) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 lib/imports/canonical.coffee create mode 100644 lib/imports/canonical.test.coffee create mode 100644 lib/imports/match.coffee create mode 100644 lib/imports/match.test.coffee create mode 100644 lib/imports/tags.coffee create mode 100644 lib/imports/tags.test.coffee create mode 100644 lib/methods/addIncorrectAnswer.test.coffee create mode 100644 lib/methods/cancelCallIn.test.coffee create mode 100644 lib/methods/correctCallIn.test.coffee create mode 100644 lib/methods/deleteAnswer.test.coffee create mode 100644 lib/methods/deletePuzzle.test.coffee create mode 100644 lib/methods/deleteRound.test.coffee create mode 100644 lib/methods/deleteRoundGroup.test.coffee create mode 100644 lib/methods/incorrectCallIn.test.coffee create mode 100644 lib/methods/locateNick.test.coffee create mode 100644 lib/methods/newCallIn.test.coffee create mode 100644 lib/methods/newPuzzle.test.coffee create mode 100644 lib/methods/newQuip.test.coffee create mode 100644 lib/methods/newRound.test.coffee create mode 100644 lib/methods/newRoundGroup.test.coffee create mode 100644 lib/methods/renamePuzzle.test.coffee create mode 100644 lib/methods/renameRound.test.coffee create mode 100644 lib/methods/renameRoundGroup.test.coffee create mode 100644 lib/methods/setAnswer.test.coffee create mode 100644 lib/methods/setPresence.test.coffee create mode 100644 lib/methods/summon.test.coffee create mode 100644 lib/methods/unsummon.test.coffee create mode 100644 lib/methods/updateLastRead.test.coffee create mode 100644 lib/methods/useQuip.test.coffee create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server/batch.coffee create mode 100644 server/imports/drive.coffee create mode 100644 server/imports/drive.test.coffee rename server/{ => imports}/emoji.coffee (95%) rename server/{ => imports}/emoji.json (100%) create mode 100644 server/imports/emoji.test.coffee diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..8d335860f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Must have comment, right? +node_modules/ diff --git a/.meteor/packages b/.meteor/packages index f28951d34..f604d16b9 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -38,3 +38,6 @@ shell-server@0.3.0 babel-runtime@1.1.1 dynamic-import@0.2.0 ecmascript +meteortesting:mocha +fds:coffeescript-share +xolvio:cleaner diff --git a/.meteor/versions b/.meteor/versions index ba46789e8..cf1b8e0da 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -36,6 +36,7 @@ ecmascript-runtime@0.5.0 ecmascript-runtime-client@0.5.0 ecmascript-runtime-server@0.5.0 ejson@1.1.0 +fds:coffeescript-share@1.0.0 geojson-utils@1.0.10 hot-code-push@1.0.4 html-tools@1.0.11 @@ -48,9 +49,13 @@ json@1.0.3 launch-screen@1.1.1 less@2.7.11 livedata@1.0.18 +lmieulet:meteor-coverage@1.1.4 logging@1.1.19 meteor@1.8.2 meteor-base@1.2.0 +meteorhacks:picker@1.0.3 +meteortesting:browser-tests@1.0.0 +meteortesting:mocha@1.0.0 minifier-css@1.2.16 minifier-js@2.2.2 minimongo@1.4.3 @@ -64,6 +69,7 @@ mongo-id@1.0.6 npm-mongo@2.2.33 observe-sequence@1.0.16 ordered-dict@1.0.9 +practicalmeteor:mocha-core@1.0.1 promise@0.10.0 random@1.0.10 reactive-dict@1.2.0 @@ -87,3 +93,4 @@ underscore@1.0.10 url@1.1.0 webapp@1.4.0 webapp-hashing@1.0.9 +xolvio:cleaner@0.3.3 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..ac1738994 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,14 @@ +language: node_js +node_js: + - 8 + +cache: + directories: + - node_modules + - $HOME/.meteor/ + +before_install: + - curl https://install.meteor.com | /bin/sh + +script: + - ~/.meteor/meteor test --once --driver-package meteortesting:mocha diff --git a/README.md b/README.md index e82d8ab3b..cac753258 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ codex-blackboard ================ +[![Build Status](https://travis-ci.org/cjb/codex-blackboard.svg?branch=master)](https://travis-ci.org/cjb/codex-blackboard) + Meteor app for coordating solving for our MIT Mystery Hunt team. To run, first obtain the password for our google drive account. Then: diff --git a/lib/imports/canonical.coffee b/lib/imports/canonical.coffee new file mode 100644 index 000000000..9090e3666 --- /dev/null +++ b/lib/imports/canonical.coffee @@ -0,0 +1,10 @@ +'use strict' + +# canonical names: lowercases, all non-alphanumerics replaced with '_' +export default canonical = (s) -> + s = s.toLowerCase().replace(/^\s+/, '').replace(/\s+$/, '') # lower, strip + # suppress 's and 't + s = s.replace(/[\'\u2019]([st])\b/g, "$1") + # replace all non-alphanumeric with _ + s = s.replace(/[^a-z0-9]+/g, '_').replace(/^_/,'').replace(/_$/,'') + return s diff --git a/lib/imports/canonical.test.coffee b/lib/imports/canonical.test.coffee new file mode 100644 index 000000000..e0d5a6534 --- /dev/null +++ b/lib/imports/canonical.test.coffee @@ -0,0 +1,25 @@ +import canonical from './canonical.coffee' +import chai from 'chai' + +describe 'canonical', -> + it 'strips whitespace', -> + chai.assert.equal canonical(' leading'), 'leading' + chai.assert.equal canonical('trailing '), 'trailing' + chai.assert.equal canonical('_id'), 'id' + + it 'converts to lowercase', -> + chai.assert.equal canonical('HappyTime'), 'happytime' + + it 'converts space to underscore', -> + chai.assert.equal canonical('sport of princesses'), 'sport_of_princesses' + chai.assert.equal canonical('sport of princesses'), 'sport_of_princesses' + + it 'converts non-alphanumeric to underscore', -> + chai.assert.equal canonical("Whomst'd've"), 'whomst_d_ve' + chai.assert.equal canonical('ca$h'), 'ca_h' + chai.assert.equal canonical('command.com'), 'command_com' + chai.assert.equal canonical('2chainz'), '2chainz' + + it 'deletes possessive and contraction apostrophes', -> + chai.assert.equal canonical("bill's"), 'bills' + chai.assert.equal canonical("don't"), 'dont' diff --git a/lib/imports/match.coffee b/lib/imports/match.coffee new file mode 100644 index 000000000..8b8d44657 --- /dev/null +++ b/lib/imports/match.coffee @@ -0,0 +1,18 @@ +'use strict' + +export NonEmptyString = Match.Where (x) -> + check x, String + return x.length > 0 + +# either an id, or an object containing an id +export IdOrObject = Match.OneOf NonEmptyString, Match.Where (o) -> + typeof o is 'object' and ((check o._id, NonEmptyString) or true) + +# This is like Match.ObjectIncluding, but we don't require `o` to be +# a plain object +export ObjectWith = (pattern) -> + Match.Where (o) -> + return false if typeof(o) is not 'object' + Object.keys(pattern).forEach (k) -> + check o[k], pattern[k] + true diff --git a/lib/imports/match.test.coffee b/lib/imports/match.test.coffee new file mode 100644 index 000000000..37c75151e --- /dev/null +++ b/lib/imports/match.test.coffee @@ -0,0 +1,64 @@ +import * as match from './match.coffee' +import chai from 'chai' + +describe 'NonEmptyString', -> + it 'rejects empty string', -> + chai.assert.throws -> + check '', match.NonEmptyString + , Match.Error + + it 'accepts non-empty string', -> + chai.assert.doesNotThrow -> + check 'foo', match.NonEmptyString + , Match.Error + + it 'rejects non-string', -> + chai.assert.throws -> + check {}, match.NonEmptyString + , Match.Error + +describe 'IdOrObject', -> + it 'rejects empty string', -> + chai.assert.throws -> + check '', match.IdOrObject + , Match.Error + + it 'accepts empty string', -> + chai.assert.doesNotThrow -> + check 'foo', match.IdOrObject + , Match.Error + + it 'rejects empty object', -> + chai.assert.throws -> + check {}, match.IdOrObject + , Match.Error + + it 'rejects object without _id', -> + chai.assert.throws -> + check {foo: 'bar'}, match.IdOrObject + , Match.Error + + it 'accepts object with _id', -> + chai.assert.doesNotThrow -> + check {_id: 'fffff'}, match.IdOrObject + , Match.Error + +describe 'ObjectWith', -> + it 'matches anything when empty', -> + chai.assert.doesNotThrow -> + check {foo: 'bar', baz: 3}, match.ObjectWith {} + , Match.Error + + it 'matches parts', -> + chai.assert.doesNotThrow -> + check {foo: 'bar', bar: 3}, match.ObjectWith + foo: match.NonEmptyString + bar: Number + , Match.Error + + it 'fails on any submatch failure', -> + chai.assert.throws -> + check {foo: '', bar: 3}, match.ObjectWith + foo: match.NonEmptyString + bar: Number + , Match.Error diff --git a/lib/imports/tags.coffee b/lib/imports/tags.coffee new file mode 100644 index 000000000..16374747e --- /dev/null +++ b/lib/imports/tags.coffee @@ -0,0 +1,21 @@ +'use strict' + +import canonical from './canonical.coffee' +import { ObjectWith, NonEmptyString } from './match.coffee' + +export getTag = (object, name) -> + (tag.value for tag in (object?.tags or []) when tag.canon is canonical(name))[0] + +export isStuck = (object) -> + object? and /^stuck\b/i.test(getTag(object, 'Status') or '') + +export canonicalTags = (tags, who) -> + check tags, [ObjectWith(name:NonEmptyString,value:Match.Any)] + now = Date.now() + ({ + name: tag.name + canon: canonical(tag.name) + value: tag.value + touched: tag.touched ? now + touched_by: tag.touched_by ? canonical(who) + } for tag in tags) diff --git a/lib/imports/tags.test.coffee b/lib/imports/tags.test.coffee new file mode 100644 index 000000000..5a336c792 --- /dev/null +++ b/lib/imports/tags.test.coffee @@ -0,0 +1,69 @@ +import * as tags from './tags.coffee' +import chai from 'chai' + +describe 'getTag', -> + it 'accepts missing object', -> + chai.assert.isUndefined tags.getTag null, 'foo' + + it 'accepts missing tags', -> + chai.assert.isUndefined tags.getTag {}, 'foo' + + it 'accepts empty tags', -> + chai.assert.isUndefined tags.getTag {tags: []}, 'foo' + + it 'accepts nonmatching tags', -> + chai.assert.isUndefined tags.getTag {tags: [{name: 'Yo', canon: 'yo', value: 'ho ho'}]}, 'foo' + + it 'accepts matching tags', -> + chai.assert.equal tags.getTag({tags: [{name: 'Yo', canon: 'yo', value: 'ho ho'}]}, 'yo'), 'ho ho' + + it 'canonicalizes tags', -> + chai.assert.equal tags.getTag({tags: [{name: 'Yo', canon: 'yo', value: 'ho ho'}]}, 'yO'), 'ho ho' + +describe 'isStuck', -> + it 'accepts missing object', -> + chai.assert.isFalse tags.isStuck null + + it 'accepts missing tags', -> + chai.assert.isFalse tags.isStuck {} + + it 'accepts empty tags', -> + chai.assert.isFalse tags.isStuck {tags: []} + + it 'ignores other tags', -> + chai.assert.isFalse tags.isStuck {tags: [{name: 'Yo', canon: 'yo', value: 'ho ho'}]} + + it 'ignores nonstuck status', -> + chai.assert.isFalse tags.isStuck {tags: [{name: 'Status', canon: 'status', value: 'making progress'}]} + + it 'matches stuck status', -> + chai.assert.isTrue tags.isStuck {tags: [{name: 'Status', canon: 'status', value: 'stuck'}]} + + it 'matches verbose stuck status', -> + chai.assert.isTrue tags.isStuck {tags: [{name: 'Status', canon: 'status', value: 'Stuck to the wall'}]} + +describe 'canonicalTags', -> + it 'requires list', -> + chai.assert.throws -> + tags.canonicalTags null, 'torgen' + chai.assert.throws -> + tags.canonicalTags {}, 'torgen' + chai.assert.deepEqual tags.canonicalTags([], 'torgen'), [] + + it 'fills entries', -> + pre = Date.now() + [foo, baz] = tags.canonicalTags [{name: 'Foo', value: 'bar'}, {name: 'BaZ', value: 'qux'}], 'Torgen' + chai.assert.include foo, {name: 'Foo', canon: 'foo', value: 'bar', touched_by: 'torgen'} + chai.assert.isAtLeast foo.touched, pre + chai.assert.include baz, {name: 'BaZ', canon: 'baz', value: 'qux', touched_by: 'torgen'} + chai.assert.isAtLeast baz.touched, pre + + it 'preserves touched', -> + pre = Date.now() - 5 + chai.assert.deepEqual( + tags.canonicalTags([{name: 'Foo', value: 'bar', touched: pre}], 'torgen'), + [{name: 'Foo', canon: 'foo', value: 'bar', touched: pre, touched_by: 'torgen'}]) + + it 'preserves touched_by', -> + [tag] = tags.canonicalTags [{name: 'Foo', value: 'bar', touched_by: 'cscott'}], 'torgen' + chai.assert.include tag, {name: 'Foo', canon: 'foo', value: 'bar', touched_by: 'cscott'} diff --git a/lib/methods/addIncorrectAnswer.test.coffee b/lib/methods/addIncorrectAnswer.test.coffee new file mode 100644 index 000000000..7e2acfcf9 --- /dev/null +++ b/lib/methods/addIncorrectAnswer.test.coffee @@ -0,0 +1,102 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +describe 'addIncorrectAnswer', -> + clock = null + + beforeEach -> + clock = sinon.useFakeTimers(7) + + afterEach -> + clock.restore() + + beforeEach -> + resetDatabase() + + it 'fails on non-puzzle', -> + id = model.Nicks.insert + name: 'Torgen' + canon: 'torgen' + tags: [{name: 'Answer', canon: 'answer', value: 'knock knock', touched: 1, touched_by: 'torgen'}] + chai.assert.throws -> + Meteor.call 'addIncorrectAnswer', + type: 'nicks' + target: id + who: 'cjb' + answer: 'foo' + , Match.Error + + ['roundgroups', 'rounds', 'puzzles'].forEach (type) => + describe "on #{model.pretty_collection(type)}", -> + it 'fails when it doesn\'t exist', -> + chai.assert.throws -> + Meteor.call 'newCallIn', + type: type + target: 'something' + answer: 'precipitate' + who: 'torgen' + , Meteor.Error + + describe 'which exists', -> + id = null + beforeEach -> + id = model.collection(type).insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 2 + touched_by: 'torgen' + solved: null + solved_by: null + tags: [{name: 'Status', canon: 'status', value: 'stuck', touched: 2, touched_by: 'torgen'}] + incorrectAnswers: [{answer: 'qux', who: 'torgen', timestamp: 2, backsolve: false, provided: false}] + model.CallIns.insert + type: type + target: id + name: 'Foo' + answer: 'flimflam' + created: 4 + created_by: 'cjb' + Meteor.call 'addIncorrectAnswer', + type: type + target: id + who: 'cjb' + answer: 'flimflam' + + it 'appends answer', -> + doc = model.collection(type).findOne id + chai.assert.lengthOf doc.incorrectAnswers, 2 + chai.assert.include doc.incorrectAnswers[1], + answer: 'flimflam' + who: 'cjb' + timestamp: 7 + backsolve: false + provided: false + + it 'doesn\'t touch', -> + doc = model.collection(type).findOne id + chai.assert.include doc, + touched: 2 + touched_by: 'torgen' + + it 'oplogs', -> + o = model.Messages.find(room_name: 'oplog/0').fetch() + chai.assert.lengthOf o, 1 + chai.assert.include o[0], + type: type + id: id + stream: 'callins' + nick: 'cjb' + # oplog is lowercase + chai.assert.include o[0].body, 'flimflam', 'message' + + it 'deletes callin', -> + chai.assert.lengthOf model.CallIns.find().fetch(), 0 diff --git a/lib/methods/cancelCallIn.test.coffee b/lib/methods/cancelCallIn.test.coffee new file mode 100644 index 000000000..4dc0640f5 --- /dev/null +++ b/lib/methods/cancelCallIn.test.coffee @@ -0,0 +1,57 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +describe 'cancelCallIn', -> + clock = null + + beforeEach -> + clock = sinon.useFakeTimers(7) + + afterEach -> + clock.restore() + + beforeEach -> + resetDatabase() + + ['puzzles', 'rounds', 'roundgroups'].forEach (type) => + describe "for #{model.pretty_collection(type)}", -> + puzzle = null + callin = null + beforeEach -> + puzzle = model.collection(type).insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 1 + touched_by: 'cscott' + solved: null + solved_by: null + tags: [] + callin = model.CallIns.insert + name: 'Foo:precipitate' + type: type + target: puzzle + answer: 'precipitate' + created: 2 + created_by: 'torgen' + submitted_to_hq: true + backsolve: false + provided: false + Meteor.call 'cancelCallIn', + id: callin + who: 'cjb' + + it 'deletes callin', -> + chai.assert.isUndefined model.CallIns.findOne() + + it 'oplogs', -> + chai.assert.lengthOf model.Messages.find({type: type, id: puzzle}).fetch(), 1 + \ No newline at end of file diff --git a/lib/methods/correctCallIn.test.coffee b/lib/methods/correctCallIn.test.coffee new file mode 100644 index 000000000..256d1fac8 --- /dev/null +++ b/lib/methods/correctCallIn.test.coffee @@ -0,0 +1,141 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +describe 'correctCallIn', -> + clock = null + + beforeEach -> + clock = sinon.useFakeTimers(7) + + afterEach -> + clock.restore() + + beforeEach -> + resetDatabase() + + ['puzzles', 'rounds', 'roundgroups'].forEach (type) => + describe "for #{model.pretty_collection(type)}", -> + puzzle = null + callin = null + beforeEach -> + puzzle = model.collection(type).insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 1 + touched_by: 'cscott' + solved: null + solved_by: null + tags: [] + callin = model.CallIns.insert + name: 'Foo:precipitate' + type: type + target: puzzle + answer: 'precipitate' + created: 2 + created_by: 'torgen' + submitted_to_hq: true + backsolve: false + provided: false + Meteor.call 'correctCallIn', + id: callin + who: 'cjb' + + it "updates #{model.pretty_collection(type)}", -> + doc = model.collection(type).findOne puzzle + chai.assert.include doc, + touched: 7 + touched_by: 'cjb' + solved: 7 + solved_by: 'cjb' + chai.assert.lengthOf doc.tags, 1 + chai.assert.deepInclude doc.tags[0], + name: 'Answer' + canon: 'answer' + value: 'precipitate' + touched: 7 + touched_by: 'cjb' + + it 'removes callin', -> + chai.assert.isUndefined model.CallIns.findOne callin + + it 'oplogs', -> + o = model.Messages.find(room_name: 'oplog/0').fetch() + chai.assert.lengthOf o, 1 + chai.assert.include o[0], + type: type + id: puzzle + stream: 'answers' + nick: 'cjb' + chai.assert.include o[0].body, '(PRECIPITATE)', 'message' + + it "notifies #{model.pretty_collection(type)} chat", -> + o = model.Messages.find(room_name: "#{type}/#{puzzle}").fetch() + chai.assert.lengthOf o, 1 + chai.assert.include o[0], + nick: 'cjb' + action: true + chai.assert.include o[0].body, 'PRECIPITATE', 'message' + chai.assert.notInclude o[0].body, '(Foo)', 'message' + + it 'notifies general chat', -> + o = model.Messages.find(room_name: "general/0").fetch() + chai.assert.lengthOf o, 1 + chai.assert.include o[0], + nick: 'cjb' + action: true + chai.assert.include o[0].body, 'PRECIPITATE', 'message' + chai.assert.include o[0].body, '(Foo)', 'message' + + it 'notifies round chat for puzzle', -> + p = model.Puzzles.insert + name: 'Foo' + canon: 'foo' + created: 2 + created_by: 'cscott' + touched: 2 + touched_by: 'cscott' + solved: null + solved_by: null + tags: [] + incorrectAnswers: [] + r = model.Rounds.insert + name: 'Bar' + canon: 'bar' + created: 1 + created_by: 'torgen' + touched: 2 + touched_by: 'cscott' + solved: null + solved_by: null + puzzles: [p] + tags: [] + incorrectAnswers: [] + callin = model.CallIns.insert + name: 'Foo:precipitate' + type: 'puzzles' + target: p + answer: 'precipitate' + created: 2 + created_by: 'torgen' + submitted_to_hq: true + backsolve: false + provided: false + Meteor.call 'correctCallIn', + id: callin + who: 'cjb' + m = model.Messages.find(room_name: "rounds/#{r}").fetch() + chai.assert.lengthOf m, 1 + chai.assert.include m[0], + nick: 'cjb' + action: true + chai.assert.include m[0].body, 'PRECIPITATE' + chai.assert.include m[0].body, '(Foo)' diff --git a/lib/methods/deleteAnswer.test.coffee b/lib/methods/deleteAnswer.test.coffee new file mode 100644 index 000000000..9938e6c0b --- /dev/null +++ b/lib/methods/deleteAnswer.test.coffee @@ -0,0 +1,167 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +describe 'deleteAnswer', -> + clock = null + + beforeEach -> + clock = sinon.useFakeTimers(7) + + afterEach -> + clock.restore() + + beforeEach -> + resetDatabase() + + it 'fails on non-puzzle', -> + id = model.Nicks.insert + name: 'Torgen' + canon: 'torgen' + tags: [{name: 'Answer', canon: 'answer', value: 'knock knock', touched: 1, touched_by: 'torgen'}] + chai.assert.throws -> + Meteor.call 'deleteAnswer', + type: 'nicks' + target: id + who: 'cjb' + , Match.Error + + ['roundgroups', 'rounds', 'puzzles'].forEach (type) => + describe "on #{model.pretty_collection(type)}", -> + it 'works when unanswered', -> + id = model.collection(type).insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 2 + touched_by: 'torgen' + solved: null + solved_by: null + tags: [{name: 'Status', canon: 'status', value: 'stuck', touched: 2, touched_by: 'torgen'}] + Meteor.call 'deleteAnswer', + type: type + target: id, + who: 'cjb' + doc = model.collection(type).findOne id + chai.assert.deepEqual doc, + _id: id + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 7 + touched_by: 'cjb' + solved: null + solved_by: null + tags: [{name: 'Status', canon: 'status', value: 'stuck', touched: 2, touched_by: 'torgen'}] + oplogs = model.Messages.find(room_name: 'oplog/0').fetch() + chai.assert.equal oplogs.length, 1 + chai.assert.include oplogs[0], + nick: 'cjb' + timestamp: 7 + body: 'Deleted answer for' + bodyIsHtml: false + type: type + id: id + oplog: true + followup: true + action: true + system: false + to: null + stream: '' + + it 'removes answer', -> + id = model.collection(type).insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 2 + touched_by: 'torgen' + solved: 2 + solved_by: 'torgen' + tags: [{name: 'Answer', canon: 'answer', value: 'foo', touched: 2, touched_by: 'torgen'}, + {name: 'Temperature', canon: 'temperature', value: '12', touched: 2, touched_by: 'torgen'}] + Meteor.call 'deleteAnswer', + type: type + target: id, + who: 'cjb' + doc = model.collection(type).findOne id + chai.assert.deepEqual doc, + _id: id + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 7 + touched_by: 'cjb' + solved: null + solved_by: null + tags: [{name: 'Temperature', canon: 'temperature', value: '12', touched: 2, touched_by: 'torgen'}] + oplogs = model.Messages.find(room_name: 'oplog/0').fetch() + chai.assert.equal oplogs.length, 1 + chai.assert.include oplogs[0], + nick: 'cjb' + timestamp: 7 + body: 'Deleted answer for' + bodyIsHtml: false + type: type + id: id + oplog: true + followup: true + action: true + system: false + to: null + stream: '' + + it 'removes backsolve and provided', -> + id = model.collection(type).insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 2 + touched_by: 'torgen' + solved: 2 + solved_by: 'torgen' + tags: [{name: 'Answer', canon: 'answer', value: 'foo', touched: 2, touched_by: 'torgen'}, + {name: 'Backsolve', canon: 'backsolve', value: 'yes', touched: 2, touched_by: 'torgen'}, + {name: 'Provided', canon: 'provided', value: 'yes', touched: 2, touched_by: 'torgen'}] + Meteor.call 'deleteAnswer', + type: type + target: id, + who: 'cjb' + doc = model.collection(type).findOne id + chai.assert.deepEqual doc, + _id: id + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 7 + touched_by: 'cjb' + solved: null + solved_by: null + tags: [] + oplogs = model.Messages.find(room_name: 'oplog/0').fetch() + chai.assert.equal oplogs.length, 1 + chai.assert.include oplogs[0], + nick: 'cjb' + timestamp: 7 + body: 'Deleted answer for' + bodyIsHtml: false + type: type + id: id + oplog: true + followup: true + action: true + system: false + to: null + stream: '' diff --git a/lib/methods/deletePuzzle.test.coffee b/lib/methods/deletePuzzle.test.coffee new file mode 100644 index 000000000..7a4dcb750 --- /dev/null +++ b/lib/methods/deletePuzzle.test.coffee @@ -0,0 +1,92 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +describe 'deletePuzzle', -> + driveMethods = null + clock = null + beforeEach -> + clock = sinon.useFakeTimers(7) + driveMethods = + createPuzzle: sinon.fake.returns + id: 'fid' # f for folder + spreadId: 'sid' + docId: 'did' + renamePuzzle: sinon.spy() + deletePuzzle: sinon.spy() + if share.drive? + sinon.stub(share, 'drive').value(driveMethods) + else + share.drive = driveMethods + + afterEach -> + sinon.restore() + + id = null + rid = null + ret = null + beforeEach -> + resetDatabase() + id = model.Puzzles.insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'torgen' + touched: 1 + touched_by: 'torgen' + solved: null + solved_by: null + incorrectAnswers: [] + tags: [] + drive: 'ffoo' + spreadsheet: 'sfoo' + doc: 'dfoo' + rid = model.Rounds.insert + name: 'Bar' + canon: 'bar' + created: 1 + created_by: 'torgen' + touched: 1 + touched_by: 'torgen' + solved: null + solved_by: null + puzzles: [id, 'another_puzzle'] + incorrectAnswers: [] + tags: [] + ret = Meteor.call 'deletePuzzle', + id: id + who: 'cjb' + + it 'returns true', -> + chai.assert.isTrue ret + + it 'deletes puzzle', -> + chai.assert.isUndefined model.Puzzles.findOne() + + it 'oplogs', -> + chai.assert.lengthOf model.Messages.find({nick: 'cjb', type: 'puzzles', room_name: 'oplog/0'}).fetch(), 1 + + it 'removes puzzle from round', -> + chai.assert.deepEqual model.Rounds.findOne(rid), + _id: rid + name: 'Bar' + canon: 'bar' + created: 1 + created_by: 'torgen' + # Removing puzzle doesn't count as touching, apparently. + touched: 1 + touched_by: 'torgen' + solved: null + solved_by: null + puzzles: ['another_puzzle'] + incorrectAnswers: [] + tags: [] + + it 'deletes drive', -> + chai.assert.deepEqual driveMethods.deletePuzzle.getCall(0).args, ['ffoo'] diff --git a/lib/methods/deleteRound.test.coffee b/lib/methods/deleteRound.test.coffee new file mode 100644 index 000000000..91db60253 --- /dev/null +++ b/lib/methods/deleteRound.test.coffee @@ -0,0 +1,128 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +describe 'deleteRound', -> + driveMethods = null + clock = null + beforeEach -> + clock = sinon.useFakeTimers(7) + driveMethods = + createPuzzle: sinon.fake.returns + id: 'fid' # f for folder + spreadId: 'sid' + docId: 'did' + renamePuzzle: sinon.spy() + deletePuzzle: sinon.spy() + if share.drive? + sinon.stub(share, 'drive').value(driveMethods) + else + share.drive = driveMethods + + afterEach -> + sinon.restore() + + beforeEach -> + resetDatabase() + + describe 'when it is empty', -> + id = null + rgid = null + ret = null + beforeEach -> + id = model.Rounds.insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'torgen' + touched: 1 + touched_by: 'torgen' + solved: null + solved_by: null + puzzles: [] + incorrectAnswers: [] + tags: [] + drive: 'ffoo' + spreadsheet: 'sfoo' + doc: 'dfoo' + rgid = model.RoundGroups.insert + name: 'Bar' + canon: 'bar' + created: 1 + created_by: 'torgen' + touched: 1 + touched_by: 'torgen' + solved: null + solved_by: null + rounds: [id, 'another_round'] + incorrectAnswers: [] + tags: [] + ret = Meteor.call 'deleteRound', + id: id + who: 'cjb' + + it 'returns true', -> + chai.assert.isTrue ret + + it 'deletes the round', -> + chai.assert.isUndefined model.Rounds.findOne(), 'no rounds after deletion' + + it 'oplogs', -> + chai.assert.lengthOf model.Messages.find({nick: 'cjb', type: 'rounds', room_name: 'oplog/0'}).fetch(), 1 + + it 'removes round from round group', -> + chai.assert.deepEqual model.RoundGroups.findOne(rgid), + _id: rgid + name: 'Bar' + canon: 'bar' + created: 1 + created_by: 'torgen' + # Removing round doesn't count as touching, apparently. + touched: 1 + touched_by: 'torgen' + solved: null + solved_by: null + rounds: ['another_round'] + incorrectAnswers: [] + tags: [] + + it 'deletes drive', -> + chai.assert.deepEqual driveMethods.deletePuzzle.getCall(0).args, ['ffoo'] + + describe 'when round isn\'t empty', -> + id = null + ret = null + beforeEach -> + id = model.Rounds.insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'torgen' + touched: 1 + touched_by: 'torgen' + solved: null + solved_by: null + puzzles: ['foo1', 'foo2'] + incorrectAnswers: [] + tags: [] + ret = Meteor.call 'deleteRound', + id: id + who: 'cjb' + + it 'returns false', -> + chai.assert.isFalse ret + + it 'leaves round', -> + chai.assert.isNotNull model.Rounds.findOne id + + it 'leaves drive', -> + chai.assert.equal driveMethods.deletePuzzle.callCount, 0 + + it 'doesn\'t oplog', -> + chai.assert.lengthOf model.Messages.find(room_name: 'oplog/0').fetch(), 0, 'oplogs' diff --git a/lib/methods/deleteRoundGroup.test.coffee b/lib/methods/deleteRoundGroup.test.coffee new file mode 100644 index 000000000..bf8dc2616 --- /dev/null +++ b/lib/methods/deleteRoundGroup.test.coffee @@ -0,0 +1,92 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +describe 'deleteRoundGroup', -> + driveMethods = null + clock = null + beforeEach -> + clock = sinon.useFakeTimers(7) + driveMethods = + createPuzzle: sinon.fake.returns + id: 'fid' # f for folder + spreadId: 'sid' + docId: 'did' + renamePuzzle: sinon.spy() + deletePuzzle: sinon.spy() + if share.drive? + sinon.stub(share, 'drive').value(driveMethods) + else + share.drive = driveMethods + + afterEach -> + sinon.restore() + + beforeEach -> + resetDatabase() + + describe 'when it is empty', -> + id = null + ret = null + beforeEach -> + id = model.RoundGroups.insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'torgen' + touched: 1 + touched_by: 'torgen' + solved: null + solved_by: null + rounds: [] + incorrectAnswers: [] + tags: [] + ret = Meteor.call 'deleteRoundGroup', + id: id + who: 'cjb' + + it 'returns true', -> + chai.assert.isTrue ret + + it 'deletes the round group', -> + chai.assert.isUndefined model.RoundGroups.findOne() + + it 'makes no drive calls', -> + chai.assert.equal driveMethods.deletePuzzle.callCount, 0 + + it 'oplogs', -> + chai.assert.lengthOf model.Messages.find({nick: 'cjb', type: 'roundgroups', room_name: 'oplog/0'}).fetch(), 1 + + describe 'when it contains rounds', -> + id = null + ret = null + beforeEach -> + id = model.RoundGroups.insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'torgen' + touched: 1 + touched_by: 'torgen' + solved: null + solved_by: null + rounds: ['foo1', 'foo2'] + incorrectAnswers: [] + tags: [] + ret = Meteor.call 'deleteRoundGroup', + id: id + who: 'cjb' + it 'returns false', -> + chai.assert.isFalse ret + + it 'leads round group alone', -> + chai.assert.isNotNull model.RoundGroups.findOne id + + it 'doesn\'t oplog', -> + chai.assert.lengthOf model.Messages.find(room_name: 'oplog/0').fetch(), 0, 'oplogs' diff --git a/lib/methods/incorrectCallIn.test.coffee b/lib/methods/incorrectCallIn.test.coffee new file mode 100644 index 000000000..5e683d3c4 --- /dev/null +++ b/lib/methods/incorrectCallIn.test.coffee @@ -0,0 +1,66 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +describe 'incorrectCallIn', -> + clock = null + + beforeEach -> + clock = sinon.useFakeTimers(7) + + afterEach -> + clock.restore() + + beforeEach -> + resetDatabase() + + ['puzzles', 'rounds', 'roundgroups'].forEach (type) => + describe "for #{model.pretty_collection(type)}", -> + puzzle = null + callin = null + beforeEach -> + puzzle = model.collection(type).insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 1 + touched_by: 'cscott' + solved: null + solved_by: null + tags: [] + callin = model.CallIns.insert + name: 'Foo:precipitate' + type: type + target: puzzle + answer: 'precipitate' + created: 2 + created_by: 'torgen' + submitted_to_hq: true + backsolve: false + provided: false + Meteor.call 'incorrectCallIn', + id: callin + who: 'cjb' + + it 'deletes callin', -> + chai.assert.isUndefined model.CallIns.findOne() + + it 'addsIncorrectAnswer', -> + chai.assert.deepInclude model.collection(type).findOne(puzzle), + incorrectAnswers: [{answer: 'precipitate', who: 'cjb', timestamp: 7, backsolve: false, provided: false}] + + it 'oplogs', -> + chai.assert.lengthOf model.Messages.find({type: type, id: puzzle, stream: 'callins'}).fetch(), 1 + + it "notifies #{model.pretty_collection(type)} chat", -> + chai.assert.lengthOf model.Messages.find(room_name: "#{type}/#{puzzle}").fetch(), 1 + + it "notifies general chat", -> + chai.assert.lengthOf model.Messages.find(room_name: 'general/0').fetch(), 1 \ No newline at end of file diff --git a/lib/methods/locateNick.test.coffee b/lib/methods/locateNick.test.coffee new file mode 100644 index 000000000..513dee7bf --- /dev/null +++ b/lib/methods/locateNick.test.coffee @@ -0,0 +1,73 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +describe 'locateNick', -> + clock = null + + beforeEach -> + clock = sinon.useFakeTimers(7) + + afterEach -> + clock.restore() + + beforeEach -> + resetDatabase() + + describe 'without queue position', -> + id = null + beforeEach -> + id = model.Nicks.insert + name: 'Torgen' + canon: 'torgen' + located_at: # Mountain View, CA + lat: 37.419857 + lng: -122.078827 + + Meteor.call 'locateNick', + nick: 'Torgen' + # Sunnyvale, CA + lat: 37.368832 + lng: -122.036346 + timestamp: 5 + + it 'leaves public location', -> + chai.assert.deepInclude model.Nicks.findOne(id), + located_at: + lat: 37.419857 + lng: -122.078827 + + it 'sets private location fields', -> + chai.assert.deepInclude model.Nicks.findOne(id), + priv_located: 5 + priv_located_at: + lat: 37.368832 + lng: -122.036346 + priv_located_order: 7 + + it 'leaves existing queue position', -> + id = model.Nicks.insert + name: 'Torgen' + canon: 'torgen' + located_at: # Mountain View, CA + lat: 37.419857 + lng: -122.078827 + priv_located_order: 4 + + Meteor.call 'locateNick', + nick: 'Torgen' + # Sunnyvale, CA + lat: 37.368832 + lng: -122.036346 + + chai.assert.deepInclude model.Nicks.findOne(id), + priv_located: 7 + priv_located_order: 4 + + diff --git a/lib/methods/newCallIn.test.coffee b/lib/methods/newCallIn.test.coffee new file mode 100644 index 000000000..8d8b758e2 --- /dev/null +++ b/lib/methods/newCallIn.test.coffee @@ -0,0 +1,179 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +describe 'newCallIn', -> + clock = null + + beforeEach -> + clock = sinon.useFakeTimers(7) + + afterEach -> + clock.restore() + + beforeEach -> + resetDatabase() + + it 'fails for non-puzzle type', -> + chai.assert.throws -> + id = model.Nicks.insert + name: 'Torgen' + canon: 'torgen' + Meteor.call 'newCallIn', + type: 'nicks' + target: id + answer: 'precipitate' + who: 'torgen' + , Match.Error + + ['puzzles', 'rounds', 'roundgroups'].forEach (type) => + describe "for #{model.pretty_collection(type)}", -> + it 'fails when it doesn\'t exist', -> + chai.assert.throws -> + Meteor.call 'newCallIn', + type: type + target: 'something' + answer: 'precipitate' + who: 'torgen' + , Meteor.Error + + describe 'which exists', -> + id = null + beforeEach -> + id = model.collection(type).insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 1 + touched_by: 'cscott' + solved: null + solved_by: null + tags: [] + incorrectAnswers: [] + + describe 'with simple callin', -> + beforeEach -> + Meteor.call 'newCallIn', + type: type + target: id + answer: 'precipitate' + who: 'torgen' + + it 'creates document', -> + c = model.CallIns.findOne() + chai.assert.include c, + name: 'Foo:precipitate' + type: type + target: id + answer: 'precipitate' + who: 'torgen' + submitted_to_hq: false + backsolve: false + provided: false + + it 'oplogs', -> + o = model.Messages.find(room_name: 'oplog/0').fetch() + chai.assert.lengthOf o, 1 + chai.assert.include o[0], + type: type + id: id + stream: 'callins' + nick: 'torgen' + # oplog is lowercase + chai.assert.include o[0].body, 'precipitate', 'message' + + it "notifies #{model.pretty_collection(type)} chat", -> + o = model.Messages.find(room_name: "#{type}/#{id}").fetch() + chai.assert.lengthOf o, 1 + chai.assert.include o[0], + nick: 'torgen' + action: true + chai.assert.include o[0].body, 'PRECIPITATE', 'message' + chai.assert.notInclude o[0].body, '(Foo)', 'message' + + it 'notifies general chat', -> + o = model.Messages.find(room_name: "general/0").fetch() + chai.assert.lengthOf o, 1 + chai.assert.include o[0], + nick: 'torgen' + action: true + chai.assert.include o[0].body, 'PRECIPITATE', 'message' + chai.assert.include o[0].body, '(Foo)', 'message' + + it 'sets backsolve', -> + Meteor.call 'newCallIn', + type: type + target: id + answer: 'precipitate' + who: 'torgen' + backsolve: true + c = model.CallIns.findOne() + chai.assert.include c, + type: type + target: id + answer: 'precipitate' + who: 'torgen' + submitted_to_hq: false + backsolve: true + provided: false + + it 'sets provided', -> + Meteor.call 'newCallIn', + type: type + target: id + answer: 'precipitate' + who: 'torgen' + provided: true + c = model.CallIns.findOne() + chai.assert.include c, + type: type + target: id + answer: 'precipitate' + who: 'torgen' + submitted_to_hq: false + backsolve: false + provided: true + + it 'notifies round chat for puzzle', -> + p = model.Puzzles.insert + name: 'Foo' + canon: 'foo' + created: 2 + created_by: 'cscott' + touched: 2 + touched_by: 'cscott' + solved: null + solved_by: null + tags: [] + incorrectAnswers: [] + r = model.Rounds.insert + name: 'Bar' + canon: 'bar' + created: 1 + created_by: 'cjb' + touched: 2 + touched_by: 'cscott' + solved: null + solved_by: null + puzzles: [p] + tags: [] + incorrectAnswers: [] + Meteor.call 'newCallIn', + type: 'puzzles' + target: p + answer: 'precipitate' + who: 'torgen' + m = model.Messages.find(room_name: "rounds/#{r}").fetch() + chai.assert.lengthOf m, 1 + chai.assert.include m[0], + nick: 'torgen' + action: true + chai.assert.include m[0].body, 'PRECIPITATE' + chai.assert.include m[0].body, '(Foo)' diff --git a/lib/methods/newPuzzle.test.coffee b/lib/methods/newPuzzle.test.coffee new file mode 100644 index 000000000..9de0c5355 --- /dev/null +++ b/lib/methods/newPuzzle.test.coffee @@ -0,0 +1,95 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +describe 'newPuzzle', -> + driveMethods = null + clock = null + beforeEach -> + clock = sinon.useFakeTimers(7) + driveMethods = + createPuzzle: sinon.fake.returns + id: 'fid' # f for folder + spreadId: 'sid' + renamePuzzle: sinon.spy() + deletePuzzle: sinon.spy() + if share.drive? + sinon.stub(share, 'drive').value(driveMethods) + else + share.drive = driveMethods + + afterEach -> + sinon.restore() + + beforeEach -> + resetDatabase() + + describe 'when none exists with that name', -> + id = null + beforeEach -> + id = Meteor.call 'newPuzzle', + name: 'Foo' + who: 'torgen' + link: 'https://puzzlehunt.mit.edu/foo' + ._id + + it 'creates puzzle', -> + chai.assert.deepInclude model.Puzzles.findOne(id), + name: 'Foo' + canon: 'foo' + created: 7 + created_by: 'torgen' + touched: 7 + touched_by: 'torgen' + solved: null + solved_by: null + incorrectAnswers: [] + link: 'https://puzzlehunt.mit.edu/foo' + drive: 'fid' + spreadsheet: 'sid' + tags: [] + + it 'oplogs', -> + chai.assert.lengthOf model.Messages.find({id: id, type: 'puzzles'}).fetch(), 1 + + describe 'when one exists with that name', -> + id1 = null + id2 = null + beforeEach -> + id1 = model.Puzzles.insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'torgen' + touched: 1 + touched_by: 'torgen' + solved: null + solved_by: null + incorrectAnswers: [] + link: 'https://puzzlehunt.mit.edu/foo' + drive: 'fid' + spreadsheet: 'sid' + tags: [] + id2 = Meteor.call 'newPuzzle', + name: 'Foo' + who: 'cjb' + ._id + + it 'returns existing puzzle', -> + chai.assert.equal id1, id2 + + it 'doesn\'t touch', -> + chai.assert.include model.Puzzles.findOne(id1), + created: 1 + created_by: 'torgen' + touched: 1 + touched_by: 'torgen' + + it 'doesn\'t oplog', -> + chai.assert.lengthOf model.Messages.find({id: id1, type: 'puzzles'}).fetch(), 0 diff --git a/lib/methods/newQuip.test.coffee b/lib/methods/newQuip.test.coffee new file mode 100644 index 000000000..0335cfb76 --- /dev/null +++ b/lib/methods/newQuip.test.coffee @@ -0,0 +1,45 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +describe 'newQuip', -> + clock = null + + beforeEach -> + clock = sinon.useFakeTimers(7) + + afterEach -> + clock.restore() + + id = null + + beforeEach -> + resetDatabase() + id = Meteor.call 'newQuip', + who: 'torgen' + text: 'I\'m codex, and there are wolves after me.' + ._id + + it 'creates document', -> + chai.assert.include model.Quips.findOne(id), + created: 7 + created_by: 'torgen' + touched: 7 + touched_by: 'torgen' + last_used: 0 + use_count: 0 + text: 'I\'m codex, and there are wolves after me.' + name: 'Odessa Clayter' # from hash of text + + it 'oplogs', -> + chai.assert.lengthOf model.Messages.find({type: 'quips', id: id}).fetch(), 1 + + + + \ No newline at end of file diff --git a/lib/methods/newRound.test.coffee b/lib/methods/newRound.test.coffee new file mode 100644 index 000000000..02ead4bbc --- /dev/null +++ b/lib/methods/newRound.test.coffee @@ -0,0 +1,100 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +describe 'newRound', -> + driveMethods = null + clock = null + beforeEach -> + clock = sinon.useFakeTimers(7) + driveMethods = + createPuzzle: sinon.fake.returns + id: 'fid' # f for folder + spreadId: 'sid' + renamePuzzle: sinon.spy() + deletePuzzle: sinon.spy() + if share.drive? + sinon.stub(share, 'drive').value(driveMethods) + else + share.drive = driveMethods + + afterEach -> + sinon.restore() + + beforeEach -> + resetDatabase() + + describe 'when none exists with that name', -> + id = null + beforeEach -> + id = Meteor.call 'newRound', + name: 'Foo' + who: 'torgen' + link: 'https://puzzlehunt.mit.edu/foo' + puzzles: ['yoy'] + ._id + + it 'creates round', -> + # Round is created, then drive et al are added + round = model.Rounds.findOne id + chai.assert.deepInclude round, + name: 'Foo' + canon: 'foo' + created: 7 + created_by: 'torgen' + touched: 7 + touched_by: 'torgen' + solved: null + solved_by: null + puzzles: ['yoy'] + incorrectAnswers: [] + link: 'https://puzzlehunt.mit.edu/foo' + drive: 'fid' + spreadsheet: 'sid' + tags: [] + + it 'oplogs', -> + chai.assert.lengthOf model.Messages.find({id: id, type: 'rounds'}).fetch(), 1 + + describe 'when one has that name', -> + id1 = null + id2 = null + beforeEach -> + id1 = model.Rounds.insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'torgen' + touched: 1 + touched_by: 'torgen' + solved: null + solved_by: null + puzzles: ['yoy'] + incorrectAnswers: [] + link: 'https://puzzlehunt.mit.edu/foo' + drive: 'fid' + spreadsheet: 'sid' + tags: [] + id2 = Meteor.call 'newRound', + name: 'Foo' + who: 'cjb' + ._id + + it 'returns existing round', -> + chai.assert.equal id1, id2 + + it 'doesn\'t touch', -> + chai.assert.include model.Rounds.findOne(id2), + created: 1 + created_by: 'torgen' + touched: 1 + touched_by: 'torgen' + + it 'doesn\'t oplog', -> + chai.assert.lengthOf model.Messages.find({id: id2, type: 'rounds'}).fetch(), 0 diff --git a/lib/methods/newRoundGroup.test.coffee b/lib/methods/newRoundGroup.test.coffee new file mode 100644 index 000000000..5f7aba6d3 --- /dev/null +++ b/lib/methods/newRoundGroup.test.coffee @@ -0,0 +1,89 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +describe 'newRoundGroup', -> + driveMethods = null + clock = null + beforeEach -> + clock = sinon.useFakeTimers(7) + driveMethods = + createPuzzle: sinon.fake.returns + id: 'fid' # f for folder + spreadId: 'sid' + docId: 'did' + renamePuzzle: sinon.spy() + deletePuzzle: sinon.spy() + if share.drive? + sinon.stub(share, 'drive').value(driveMethods) + else + share.drive = driveMethods + + afterEach -> + sinon.restore() + + beforeEach -> + resetDatabase() + + describe 'when none exists with that name', -> + id = null + beforeEach -> + id = Meteor.call 'newRoundGroup', + name: 'Foo' + who: 'torgen' + rounds: ['rd1'] + ._id + + it 'creates new round group', -> + group = model.RoundGroups.findOne id + chai.assert.deepInclude group, + name: 'Foo' + canon: 'foo' + created: 7 + created_by: 'torgen' + touched: 7 + touched_by: 'torgen' + solved: null + solved_by: null + rounds: ['rd1'] + incorrectAnswers: [] + tags: [] + + it 'has no drive', -> + group = model.RoundGroups.findOne id + chai.assert.doesNotHaveAnyKeys group, ['drive', 'spreadsheet', 'doc', 'link'] + + it 'oplogs', -> + chai.assert.lengthOf model.Messages.find({id: id, type: 'roundgroups'}).fetch(), 1, 'oplogs' + + describe 'when one has that name', -> + id = null + group = null + beforeEach -> + id = model.RoundGroups.insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'torgen' + touched: 1 + touched_by: 'torgen' + tags: [] + solved: null + solved_by: null + incorrectAnswers: [] + rounds: ['rd1', 'rd2'] + group = Meteor.call 'newRoundGroup', + name: 'Foo' + who: 'cjb' + + it 'returns the existing group', -> + chai.assert.equal group._id, id + + it 'doesn\'t oplog', -> + chai.assert.lengthOf model.Messages.find({id: id, type: 'roundgroups'}).fetch(), 0, 'oplogs' diff --git a/lib/methods/renamePuzzle.test.coffee b/lib/methods/renamePuzzle.test.coffee new file mode 100644 index 000000000..7a33bbed7 --- /dev/null +++ b/lib/methods/renamePuzzle.test.coffee @@ -0,0 +1,125 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +describe 'renamePuzzle', -> + driveMethods = null + clock = null + beforeEach -> + clock = sinon.useFakeTimers(7) + driveMethods = + createPuzzle: sinon.fake.returns + id: 'fid' # f for folder + spreadId: 'sid' + renamePuzzle: sinon.spy() + deletePuzzle: sinon.spy() + if share.drive? + sinon.stub(share, 'drive').value(driveMethods) + else + share.drive = driveMethods + + afterEach -> + sinon.restore() + + beforeEach -> + resetDatabase() + + describe 'when new name is unique', -> + id = null + ret = null + beforeEach -> + id = model.Puzzles.insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'torgen' + touched: 1 + touched_by: 'torgen' + solved: null + solved_by: null + incorrectAnswers: [] + link: 'https://puzzlehunt.mit.edu/foo' + drive: 'fid' + spreadsheet: 'sid' + tags: [] + ret = Meteor.call 'renamePuzzle', + id: id + name: 'Bar' + who: 'cjb' + + it 'returns true', -> + chai.assert.isTrue ret + + it 'renames puzzle', -> + puzzle = model.Puzzles.findOne id + chai.assert.include puzzle, + name: 'Bar' + canon: 'bar' + touched: 7 + touched_by: 'cjb' + + it 'renames drive', -> + chai.assert.deepEqual driveMethods.renamePuzzle.getCall(0).args, ['Bar', 'fid', 'sid'] + + it 'oplogs', -> + chai.assert.lengthOf model.Messages.find({id: id, type: 'puzzles'}).fetch(), 1 + + describe 'when puzzle with that name exists', -> + id1 = null + id2 = null + ret = null + beforeEach -> + id1 = model.Puzzles.insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'torgen' + touched: 1 + touched_by: 'torgen' + solved: null + solved_by: null + incorrectAnswers: [] + link: 'https://puzzlehunt.mit.edu/foo' + drive: 'f1' + spreadsheet: 's1' + tags: [] + id2 = model.Puzzles.insert + name: 'Bar' + canon: 'bar' + created: 2 + created_by: 'cscott' + touched: 2 + touched_by: 'cscott' + solved: null + solved_by: null + incorrectAnswers: [] + link: 'https://puzzlehunt.mit.edu/foo' + drive: 'f2' + spreadsheet: 's2' + tags: [] + ret = Meteor.call 'renamePuzzle', + id: id1 + name: 'Bar' + who: 'cjb' + + it 'returns false', -> + chai.assert.isFalse ret + + it 'leaves puzzle unchanged', -> + chai.assert.include model.Puzzles.findOne(id1), + name: 'Foo' + canon: 'foo' + touched: 1 + touched_by: 'torgen' + + it 'doesn\'t oplog', -> + chai.assert.lengthOf model.Messages.find({id: {$in: [id1, id2]}, type: 'puzzles'}).fetch(), 0, 'oplogs' + + it 'doesn\'t rename drive', -> + chai.assert.equal driveMethods.renamePuzzle.callCount, 0 diff --git a/lib/methods/renameRound.test.coffee b/lib/methods/renameRound.test.coffee new file mode 100644 index 000000000..61fc3b6fc --- /dev/null +++ b/lib/methods/renameRound.test.coffee @@ -0,0 +1,126 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +describe 'renameRound', -> + driveMethods = null + clock = null + beforeEach -> + clock = sinon.useFakeTimers(7) + driveMethods = + createPuzzle: sinon.fake.returns + id: 'fid' # f for folder + spreadId: 'sid' + renamePuzzle: sinon.spy() + deletePuzzle: sinon.spy() + if share.drive? + sinon.stub(share, 'drive').value(driveMethods) + else + share.drive = driveMethods + + afterEach -> + sinon.restore() + + beforeEach -> + resetDatabase() + + describe 'when new name is unique', -> + id = null + ret = null + beforeEach -> + id = model.Rounds.insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'torgen' + touched: 1 + touched_by: 'torgen' + solved: null + solved_by: null + puzzles: ['yoy'] + incorrectAnswers: [] + link: 'https://puzzlehunt.mit.edu/foo' + drive: 'fid' + spreadsheet: 'sid' + tags: [] + ret = Meteor.call 'renameRound', + id: id + name: 'Bar' + who: 'cjb' + + it 'returns true', -> + chai.assert.isTrue ret + + it 'renames round', -> + round = model.Rounds.findOne id + chai.assert.include round, + name: 'Bar' + canon: 'bar' + touched: 7 + touched_by: 'cjb' + + it 'renames drive', -> + chai.assert.deepEqual driveMethods.renamePuzzle.getCall(0).args, ['Bar', 'fid', 'sid'] + + it 'oplogs', -> + chai.assert.lengthOf model.Messages.find({id: id, type: 'rounds'}).fetch(), 1, 'oplogs' + + describe 'when a round exists with that name', -> + id1 = null + id2 = null + ret = null + beforeEach -> + id1 = model.Rounds.insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'torgen' + touched: 1 + touched_by: 'torgen' + solved: null + solved_by: null + incorrectAnswers: [] + link: 'https://puzzlehunt.mit.edu/foo' + drive: 'f1' + spreadsheet: 's1' + tags: [] + id2 = model.Rounds.insert + name: 'Bar' + canon: 'bar' + created: 2 + created_by: 'cscott' + touched: 2 + touched_by: 'cscott' + solved: null + solved_by: null + incorrectAnswers: [] + link: 'https://puzzlehunt.mit.edu/foo' + drive: 'f2' + spreadsheet: 's2' + tags: [] + ret = Meteor.call 'renameRound', + id: id1 + name: 'Bar' + who: 'cjb' + + it 'returns false', -> + chai.assert.isFalse ret + + it 'leaves round alone', -> + chai.assert.include model.Rounds.findOne(id1), + name: 'Foo' + canon: 'foo' + touched: 1 + touched_by: 'torgen' + + it 'doesn\'t rename drive', -> + chai.assert.equal driveMethods.renamePuzzle.callCount, 0, 'rename calls' + + it 'doesn\'t oplog', -> + chai.assert.lengthOf model.Messages.find({id: {$in: [id1, id2]}, type: 'rounds'}).fetch(), 0 diff --git a/lib/methods/renameRoundGroup.test.coffee b/lib/methods/renameRoundGroup.test.coffee new file mode 100644 index 000000000..214184506 --- /dev/null +++ b/lib/methods/renameRoundGroup.test.coffee @@ -0,0 +1,116 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +describe 'renameRoundGroup', -> + driveMethods = null + clock = null + beforeEach -> + clock = sinon.useFakeTimers(7) + driveMethods = + createPuzzle: sinon.fake.returns + id: 'fid' # f for folder + spreadId: 'sid' + docId: 'did' + renamePuzzle: sinon.spy() + deletePuzzle: sinon.spy() + if share.drive? + sinon.stub(share, 'drive').value(driveMethods) + else + share.drive = driveMethods + + afterEach -> + sinon.restore() + + beforeEach -> + resetDatabase() + + describe 'when new name is unique', -> + id = null + ret = null + beforeEach -> + id = model.RoundGroups.insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'torgen' + touched: 1 + touched_by: 'torgen' + solved: null + solved_by: null + rounds: ['yoy'] + incorrectAnswers: [] + tags: [] + ret = Meteor.call 'renameRoundGroup', + id: id + name: 'Bar' + who: 'cjb' + + it 'returns true', -> + chai.assert.isTrue ret + + it 'renames round group', -> + group = model.RoundGroups.findOne id + chai.assert.include group, + name: 'Bar' + canon: 'bar' + touched: 7 + touched_by: 'cjb' + + it 'doesn\'t rename a drive', -> + chai.assert.equal driveMethods.renamePuzzle.callCount, 0, 'rename drive calls' + + it 'oplogs', -> + chai.assert.lengthOf model.Messages.find({id: id, type: 'roundgroups'}).fetch(), 1, 'oplogs' + + describe 'when another round group has same name', -> + id1 = null + id2 = null + ret = null + beforeEach -> + id1 = model.RoundGroups.insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'torgen' + touched: 1 + touched_by: 'torgen' + solved: null + solved_by: null + incorrectAnswers: [] + tags: [] + id2 = model.RoundGroups.insert + name: 'Bar' + canon: 'bar' + created: 2 + created_by: 'cscott' + touched: 2 + touched_by: 'cscott' + solved: null + solved_by: null + incorrectAnswers: [] + tags: [] + ret = Meteor.call 'renameRoundGroup', + id: id1 + name: 'Bar' + who: 'cjb' + + it 'returns false', -> + chai.assert.isFalse ret + + it 'leaves round group alone', -> + group = model.RoundGroups.findOne id1 + chai.assert.include group, + name: 'Foo' + canon: 'foo' + touched: 1 + touched_by: 'torgen' + + it 'doesn\'t oplog', -> + chai.assert.lengthOf model.Messages.find({id: {$in: [id1, id2]}, type: 'roundgroups'}).fetch(), 0, 'oplogs' diff --git a/lib/methods/setAnswer.test.coffee b/lib/methods/setAnswer.test.coffee new file mode 100644 index 000000000..0679b5724 --- /dev/null +++ b/lib/methods/setAnswer.test.coffee @@ -0,0 +1,248 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +describe 'setAnswer', -> + clock = null + + beforeEach -> + clock = sinon.useFakeTimers(7) + + afterEach -> + clock.restore() + + beforeEach -> + resetDatabase() + + it 'fails on non-puzzle', -> + id = model.Nicks.insert + name: 'Torgen' + canon: 'torgen' + tags: [{name: 'Real Name', canon: 'real_name', value: 'Dan Rosart', touched: 1, touched_by: 'torgen'}] + chai.assert.throws -> + Meteor.call 'setAnswer', + type: 'nicks' + target: id + who: 'cjb' + , Match.Error + + ['roundgroups', 'rounds', 'puzzles'].forEach (type) => + describe "on #{model.pretty_collection(type)}", -> + describe 'without answer', -> + id = null + ret = null + beforeEach -> + id = model.collection(type).insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 2 + touched_by: 'torgen' + solved: null + solved_by: null + tags: [{name: 'Technology', canon: 'technology', value: 'Pottery', touched: 2, touched_by: 'torgen'}] + ret = Meteor.call 'setAnswer', + type: type + target: id + who: 'cjb' + answer: 'bar' + + it 'returns true', -> + chai.assert.isTrue ret + + it 'modifies document', -> + chai.assert.deepEqual model.collection(type).findOne(id), + _id: id + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 7 + touched_by: 'cjb' + solved: 7 + solved_by: 'cjb' + tags: [{name: 'Answer', canon: 'answer', value: 'bar', touched: 7, touched_by: 'cjb'}, + {name: 'Technology', canon: 'technology', value: 'Pottery', touched: 2, touched_by: 'torgen'}] + + it 'oplogs', -> + oplogs = model.Messages.find(room_name: 'oplog/0').fetch() + chai.assert.equal oplogs.length, 1 + chai.assert.include oplogs[0], + nick: 'cjb' + timestamp: 7 + type: type + id: id + oplog: true + action: true + stream: 'answers' + + describe 'with answer', -> + id = null + ret = null + beforeEach -> + id = model.collection(type).insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 2 + touched_by: 'torgen' + solved: 2 + solved_by: 'torgen' + tags: [{name: 'Answer', canon: 'answer', value: 'qux', touched: 2, touched_by: 'torgen'}, + {name: 'Technology', canon: 'technology', value: 'Pottery', touched: 2, touched_by: 'torgen'}] + ret = Meteor.call 'setAnswer', + type: type + target: id + who: 'cjb' + answer: 'bar' + + it 'returns true', -> + chai.assert.isTrue ret + + it 'modifies document', -> + chai.assert.deepEqual model.collection(type).findOne(id), + _id: id + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 7 + touched_by: 'cjb' + solved: 7 + solved_by: 'cjb' + tags: [{name: 'Answer', canon: 'answer', value: 'bar', touched: 7, touched_by: 'cjb'}, + {name: 'Technology', canon: 'technology', value: 'Pottery', touched: 2, touched_by: 'torgen'}] + + it 'oplogs', -> + oplogs = model.Messages.find(room_name: 'oplog/0').fetch() + chai.assert.equal oplogs.length, 1 + chai.assert.include oplogs[0], + nick: 'cjb' + timestamp: 7 + bodyIsHtml: false + type: type + id: id + oplog: true + action: true + stream: 'answers' + + describe 'with same answer', -> + id = null + ret = null + beforeEach -> + id = model.collection(type).insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 2 + touched_by: 'torgen' + solved: 2 + solved_by: 'torgen' + tags: [{name: 'Answer', canon: 'answer', value: 'bar', touched: 2, touched_by: 'torgen'}, + {name: 'Technology', canon: 'technology', value: 'Pottery', touched: 2, touched_by: 'torgen'}] + ret = Meteor.call 'setAnswer', + type: type + target: id + who: 'cjb' + answer: 'bar' + + it 'returns false', -> + chai.assert.isFalse ret + + it 'leaves document alone', -> + chai.assert.deepEqual model.collection(type).findOne(id), + _id: id + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 2 + touched_by: 'torgen' + solved: 2 + solved_by: 'torgen' + tags: [{name: 'Answer', canon: 'answer', value: 'bar', touched: 2, touched_by: 'torgen'}, + {name: 'Technology', canon: 'technology', value: 'Pottery', touched: 2, touched_by: 'torgen'}] + + it 'doesn\'t oplog', -> + chai.assert.lengthOf model.Messages.find(room_name: 'oplog/0').fetch(), 0 + + it 'modifies tags', -> + id = model.collection(type).insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 2 + touched_by: 'torgen' + solved: null + solved_by: null + tags: [{name: 'Status', canon: 'status', value: 'stuck', touched: 2, touched_by: 'torgen'}] + chai.assert.isTrue Meteor.call 'setAnswer', + type: type + target: id + who: 'cjb' + answer: 'bar' + backsolve: true + provided: true + chai.assert.deepInclude model.collection(type).findOne(id), + tags: [{name: 'Answer', canon: 'answer', value: 'bar', touched: 7, touched_by: 'cjb'}, + {name: 'Backsolve', canon: 'backsolve', value: 'yes', touched: 7, touched_by: 'cjb'}, + {name: 'Provided', canon: 'provided', value: 'yes', touched: 7, touched_by: 'cjb'}] + + describe 'with matching callins', -> + id = null + cid1 = null + cid2 = null + beforeEach -> + id = model.collection(type).insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 2 + touched_by: 'torgen' + solved: null + solved_by: null + tags: [] + cid1 = model.CallIns.insert + type: type + target: id + name: 'Foo' + answer: 'bar' + created: 5 + created_by: 'codexbot' + submitted_to_hq: true + backsolve: false + provided: false + cid2 = model.CallIns.insert + type: type + target: id + name: 'Foo' + answer: 'qux' + created: 5 + created_by: 'codexbot' + submitted_to_hq: false + backsolve: false + provided: false + Meteor.call 'setAnswer', + type: type + target: id + who: 'cjb' + answer: 'bar' + it 'deletes callins', -> + chai.assert.lengthOf model.CallIns.find().fetch(), 0 + + it 'doesn\'t oplog for callins', -> + chai.assert.lengthOf model.Messages.find({room_name: 'oplog/0', type: 'callins'}).fetch(), 0 + + it "oplogs for #{model.pretty_collection(type)}", -> + chai.assert.lengthOf model.Messages.find({room_name: 'oplog/0', type: type, id: id}).fetch(), 2 diff --git a/lib/methods/setPresence.test.coffee b/lib/methods/setPresence.test.coffee new file mode 100644 index 000000000..86c0884bf --- /dev/null +++ b/lib/methods/setPresence.test.coffee @@ -0,0 +1,134 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +describe 'setPresence', -> + clock = null + + beforeEach -> + clock = sinon.useFakeTimers(7) + + afterEach -> + clock.restore() + + beforeEach -> + resetDatabase() + + describe 'create', -> + describe 'when present', -> + it 'sets foreground false', -> + Meteor.call 'setPresence', + nick: 'torgen' + room_name: 'general/0' + present: true + foreground: false + uuid: '12345' + doc = model.Presence.findOne({nick: 'torgen', room_name: 'general/0'}) + chai.assert.notInclude doc, + foreground: false + foreground_uuid: null + + it 'sets foreground true', -> + Meteor.call 'setPresence', + nick: 'torgen' + room_name: 'general/0' + present: true + foreground: true + uuid: '12345' + chai.assert.include model.Presence.findOne({nick: 'torgen', room_name: 'general/0'}), + foreground: true + foreground_uuid: '12345' + describe 'when absent', -> + [false, true].forEach (foreground) => + it "ignores foreground when #{foreground}", -> + Meteor.call 'setPresence', + nick: 'torgen' + room_name: 'general/0' + present: false + foreground: foreground + uuid: '12345' + chai.assert.notInclude model.Presence.findOne({nick: 'torgen', room_name: 'general/0'}), + foreground: null + foreground_uuid: null + + describe 'update', -> + it 'leaves foreground when present is false', -> + model.Presence.insert + nick: 'torgen' + room_name: 'general/0' + present: true + foreground: true + foreground_uuid: '23456' + Meteor.call 'setPresence', + nick: 'torgen' + room_name: 'general/0' + present: false + foreground: true + uuid: '12345' + doc = model.Presence.findOne({nick: 'torgen', room_name: 'general/0'}) + chai.assert.include doc, + present: false + foreground: true + foreground_uuid: '23456' + + it 'updates uuid when foreground is true', -> + model.Presence.insert + nick: 'torgen' + room_name: 'general/0' + present: true + foreground: true + foreground_uuid: '23456' + Meteor.call 'setPresence', + nick: 'torgen' + room_name: 'general/0' + present: true + foreground: true + uuid: '12345' + doc = model.Presence.findOne({nick: 'torgen', room_name: 'general/0'}) + chai.assert.include doc, + present: true + foreground: true + foreground_uuid: '12345' + + it 'leaves uuid when foreground is false', -> + model.Presence.insert + nick: 'torgen' + room_name: 'general/0' + present: true + foreground: true + foreground_uuid: '23456' + Meteor.call 'setPresence', + nick: 'torgen' + room_name: 'general/0' + present: true + foreground: false + uuid: '12345' + chai.assert.include model.Presence.findOne({nick: 'torgen', room_name: 'general/0'}), + present: true + foreground: true + foreground_uuid: '23456' + + it 'sets foreground false when uuid matches', -> + model.Presence.insert + nick: 'torgen' + room_name: 'general/0' + present: true + foreground: true + foreground_uuid: '23456' + Meteor.call 'setPresence', + nick: 'torgen' + room_name: 'general/0' + present: true + foreground: false + uuid: '23456' + chai.assert.include model.Presence.findOne({nick: 'torgen', room_name: 'general/0'}), + present: true + foreground: false + foreground_uuid: '23456' + \ No newline at end of file diff --git a/lib/methods/summon.test.coffee b/lib/methods/summon.test.coffee new file mode 100644 index 000000000..89224b2c8 --- /dev/null +++ b/lib/methods/summon.test.coffee @@ -0,0 +1,250 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +describe 'summon', -> + clock = null + + beforeEach -> + clock = sinon.useFakeTimers(7) + + afterEach -> + clock.restore() + + beforeEach -> + resetDatabase() + + + ['roundgroups', 'rounds', 'puzzles'].forEach (type) => + describe "on #{model.pretty_collection(type)}", -> + + describe 'when already answered', -> + id = null + ret = null + beforeEach -> + id = model.collection(type).insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 2 + touched_by: 'cjb' + solved: 2 + solved_by: 'cjb' + tags: [{name: 'Answer', canon: 'answer', value: 'precipitate', touched: 2, touched_by: 'cjb'}] + ret = Meteor.call 'summon', + who: 'torgen' + type: type + object: id + + it 'returns an error', -> + chai.assert.isString ret + + it 'doesn\'t touch', -> + chai.assert.deepInclude model.collection(type).findOne(id), + touched: 2 + touched_by: 'cjb' + solved: 2 + solved_by: 'cjb' + tags: [{name: 'Answer', canon: 'answer', value: 'precipitate', touched: 2, touched_by: 'cjb'}] + + it 'doesn\'t chat', -> + chai.assert.lengthOf model.Messages.find(room_name: $ne: 'oplog/0').fetch(), 0 + + it 'doesn\'t oplog', -> + chai.assert.lengthOf model.Messages.find(room_name: 'oplog/0').fetch(), 0 + + describe 'when already stuck', -> + id = null + ret = null + beforeEach -> + id = model.collection(type).insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 2 + touched_by: 'cjb' + solved: null + solved_by: null + tags: [{name: 'Status', canon: 'status', value: 'Stuck on you', touched: 2, touched_by: 'cjb'}] + ret = Meteor.call 'summon', + who: 'torgen' + type: type + object: id + how: 'Stuck like glue' + it 'returns nothing', -> + chai.assert.isUndefined ret + + it 'updates document', -> + chai.assert.deepInclude model.collection(type).findOne(id), + touched: 7 + touched_by: 'torgen' + tags: [{name: 'Status', canon: 'status', value: 'Stuck like glue', touched: 7, touched_by: 'torgen'}] + + it 'doesn\'t chat', -> + chai.assert.lengthOf model.Messages.find(room_name: $ne: 'oplog/0').fetch(), 0 + + it 'doesn\'t oplog', -> + chai.assert.lengthOf model.Messages.find(room_name: 'oplog/0').fetch(), 0 + + describe 'with other status', -> + id = null + ret = null + beforeEach -> + id = model.collection(type).insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 2 + touched_by: 'cjb' + solved: null + solved_by: null + tags: [{name: 'Status', canon: 'status', value: 'everything is fine', touched: 2, touched_by: 'cjb'}] + ret = Meteor.call 'summon', + who: 'torgen' + type: type + object: id + how: 'Stuck like glue' + it 'returns nothing', -> + chai.assert.isUndefined ret + + it 'updates document', -> + chai.assert.deepInclude model.collection(type).findOne(id), + touched: 7 + touched_by: 'torgen' + tags: [{name: 'Status', canon: 'status', value: 'Stuck like glue', touched: 7, touched_by: 'torgen'}] + + it 'notifies main chat', -> + msgs = model.Messages.find(room_name: 'general/0').fetch() + chai.assert.lengthOf msgs, 1 + chai.assert.include msgs[0].body, ': Stuck like glue (' + chai.assert.include msgs[0].body, 'Foo' + + it "notifies #{model.pretty_collection(type)} chat", -> + msgs = model.Messages.find(room_name: "#{type}/#{id}").fetch() + chai.assert.lengthOf msgs, 1 + chai.assert.include msgs[0].body, ': Stuck like glue' + chai.assert.notInclude msgs[0].body, 'Foo' + + it 'oplogs', -> + chai.assert.lengthOf model.Messages.find({room_name: 'oplog/0', stream: 'stuck', type: type, id: id}).fetch(), 1 + describe 'with no status', -> + id = null + beforeEach -> + id = model.collection(type).insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 2 + touched_by: 'cjb' + solved: null + solved_by: null + tags: [] + describe 'empty how', -> + ret = null + beforeEach -> + ret = Meteor.call 'summon', + who: 'torgen', + type: type + object: id + + it 'returns nothing', -> + chai.assert.isUndefined ret + + it 'updates document', -> + chai.assert.deepInclude model.collection(type).findOne(id), + touched: 7 + touched_by: 'torgen' + tags: [{name: 'Status', canon: 'status', value: 'Stuck', touched: 7, touched_by: 'torgen'}] + + it 'notifies main chat', -> + msgs = model.Messages.find(room_name: 'general/0').fetch() + chai.assert.lengthOf msgs, 1 + chai.assert.include msgs[0].body, ': Stuck (' + chai.assert.include msgs[0].body, 'Foo' + + it "notifies #{model.pretty_collection(type)} chat", -> + msgs = model.Messages.find(room_name: "#{type}/#{id}").fetch() + chai.assert.lengthOf msgs, 1 + chai.assert.include msgs[0].body, ': Stuck' + chai.assert.notInclude msgs[0].body, 'Foo' + + it 'oplogs', -> + chai.assert.lengthOf model.Messages.find({room_name: 'oplog/0', stream: 'stuck', type: type, id: id}).fetch(), 1 + + describe 'how starts with stuck', -> + ret = null + beforeEach -> + ret = Meteor.call 'summon', + who: 'torgen', + type: type + object: id + how: 'stucK like glue' + + it 'returns nothing', -> + chai.assert.isUndefined ret + + it 'updates document', -> + chai.assert.deepInclude model.collection(type).findOne(id), + touched: 7 + touched_by: 'torgen' + tags: [{name: 'Status', canon: 'status', value: 'stucK like glue', touched: 7, touched_by: 'torgen'}] + + it 'notifies main chat', -> + msgs = model.Messages.find(room_name: 'general/0').fetch() + chai.assert.lengthOf msgs, 1 + chai.assert.include msgs[0].body, ': stucK like glue (' + chai.assert.include msgs[0].body, 'Foo' + + it "notifies #{model.pretty_collection(type)} chat", -> + msgs = model.Messages.find(room_name: "#{type}/#{id}").fetch() + chai.assert.lengthOf msgs, 1 + chai.assert.include msgs[0].body, ': stucK like glue' + chai.assert.notInclude msgs[0].body, 'Foo' + + it 'oplogs', -> + chai.assert.lengthOf model.Messages.find({room_name: 'oplog/0', stream: 'stuck', type: type, id: id}).fetch(), 1 + + describe 'how starts with other', -> + ret = null + beforeEach -> + ret = Meteor.call 'summon', + who: 'torgen', + type: type + object: id + how: 'no idea' + + it 'returns nothing', -> + chai.assert.isUndefined ret + + it 'updates document', -> + chai.assert.deepInclude model.collection(type).findOne(id), + touched: 7 + touched_by: 'torgen' + tags: [{name: 'Status', canon: 'status', value: 'Stuck: no idea', touched: 7, touched_by: 'torgen'}] + + it 'notifies main chat', -> + msgs = model.Messages.find(room_name: 'general/0').fetch() + chai.assert.lengthOf msgs, 1 + chai.assert.include msgs[0].body, ': no idea (' + chai.assert.notInclude msgs[0].body, 'Stuck' + chai.assert.include msgs[0].body, 'Foo' + + it "notifies #{model.pretty_collection(type)} chat", -> + msgs = model.Messages.find(room_name: "#{type}/#{id}").fetch() + chai.assert.lengthOf msgs, 1 + chai.assert.include msgs[0].body, ': no idea' + chai.assert.notInclude msgs[0].body, 'Stuck' + chai.assert.notInclude msgs[0].body, 'Foo' + + it 'oplogs', -> + chai.assert.lengthOf model.Messages.find({room_name: 'oplog/0', stream: 'stuck', type: type, id: id}).fetch(), 1 diff --git a/lib/methods/unsummon.test.coffee b/lib/methods/unsummon.test.coffee new file mode 100644 index 000000000..924451720 --- /dev/null +++ b/lib/methods/unsummon.test.coffee @@ -0,0 +1,143 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +describe 'unsummon', -> + clock = null + + beforeEach -> + clock = sinon.useFakeTimers(7) + + afterEach -> + clock.restore() + + beforeEach -> + resetDatabase() + + ['roundgroups', 'rounds', 'puzzles'].forEach (type) => + describe "on #{model.pretty_collection(type)}", -> + describe 'which is not stuck', -> + id = null + ret = null + beforeEach -> + id = model.collection(type).insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 2 + touched_by: 'cjb' + solved: null + solved_by: null + tags: [{name: 'Status', canon: 'status', value: 'precipitate', touched: 2, touched_by: 'cjb'}] + ret = Meteor.call 'unsummon', + who: 'torgen' + type: type + object: id + + it 'returns an error', -> + chai.assert.isString ret + + it 'doesn\'t touch', -> + chai.assert.deepInclude model.collection(type).findOne(id), + touched: 2 + touched_by: 'cjb' + tags: [{name: 'Status', canon: 'status', value: 'precipitate', touched: 2, touched_by: 'cjb'}] + + it 'doesn\'t chat', -> + chai.assert.lengthOf model.Messages.find(room_name: $ne: 'oplog/0').fetch(), 0 + + it 'doesn\'t oplog', -> + chai.assert.lengthOf model.Messages.find(room_name: 'oplog/0').fetch(), 0 + + describe 'which someone else made stuck', -> + id = null + ret = null + beforeEach -> + id = model.collection(type).insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 2 + touched_by: 'cjb' + solved: null + solved_by: null + tags: [{name: 'Status', canon: 'status', value: 'stuck', touched: 2, touched_by: 'cjb'}] + ret = Meteor.call 'unsummon', + who: 'torgen' + type: type + object: id + + it 'returns nothing', -> + chai.assert.isUndefined ret + + it 'updates document', -> + chai.assert.deepInclude model.collection(type).findOne(id), + touched: 7 + touched_by: 'torgen' + tags: [] + + it 'oplogs', -> + chai.assert.lengthOf model.Messages.find({room_name: 'oplog/0', type: type, id: id}).fetch(), 1 + + it 'notifies main chat', -> + msgs = model.Messages.find(room_name: 'general/0').fetch() + chai.assert.lengthOf msgs, 1 + chai.assert.include msgs[0].body, 'has arrived' + chai.assert.include msgs[0].body, "#{model.pretty_collection(type)} Foo" + + it "notifies #{model.pretty_collection(type)} chat", -> + msgs = model.Messages.find(room_name: "#{type}/#{id}").fetch() + chai.assert.lengthOf msgs, 1 + chai.assert.include msgs[0].body, 'has arrived' + chai.assert.notInclude msgs[0].body, "#{model.pretty_collection(type)} Foo" + + describe 'which they made stuck', -> + id = null + ret = null + beforeEach -> + id = model.collection(type).insert + name: 'Foo' + canon: 'foo' + created: 1 + created_by: 'cscott' + touched: 2 + touched_by: 'cjb' + solved: null + solved_by: null + tags: [{name: 'Status', canon: 'status', value: 'stuck', touched: 2, touched_by: 'cjb'}] + ret = Meteor.call 'unsummon', + who: 'cjb' + type: type + object: id + + it 'returns nothing', -> + chai.assert.isUndefined ret + + it 'updates document', -> + chai.assert.deepInclude model.collection(type).findOne(id), + touched: 7 + touched_by: 'cjb' + tags: [] + + it 'oplogs', -> + chai.assert.lengthOf model.Messages.find({room_name: 'oplog/0', type: type, id: id}).fetch(), 1 + + it 'notifies main chat', -> + msgs = model.Messages.find(room_name: 'general/0').fetch() + chai.assert.lengthOf msgs, 1 + chai.assert.include msgs[0].body, 'no longer' + chai.assert.include msgs[0].body, "#{model.pretty_collection(type)} Foo" + + it "notifies #{model.pretty_collection(type)} chat", -> + msgs = model.Messages.find(room_name: "#{type}/#{id}").fetch() + chai.assert.lengthOf msgs, 1 + chai.assert.include msgs[0].body, 'no longer' + chai.assert.notInclude msgs[0].body, "#{model.pretty_collection(type)} Foo" diff --git a/lib/methods/updateLastRead.test.coffee b/lib/methods/updateLastRead.test.coffee new file mode 100644 index 000000000..10ff2af26 --- /dev/null +++ b/lib/methods/updateLastRead.test.coffee @@ -0,0 +1,54 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +describe 'updatelastRead', -> + clock = null + + beforeEach -> + clock = sinon.useFakeTimers(7) + + afterEach -> + clock.restore() + + beforeEach -> + resetDatabase() + + it 'creates', -> + Meteor.call 'updateLastRead', + nick: 'torgen' + room_name: 'general/0', + timestamp: 3 + chai.assert.include model.LastRead.findOne({nick: 'torgen', room_name: 'general/0'}), + timestamp: 3 + + it 'advances', -> + model.LastRead.insert + nick: 'torgen' + room_name: 'general/0' + timestamp: 2 + Meteor.call 'updateLastRead', + nick: 'torgen' + room_name: 'general/0', + timestamp: 3 + chai.assert.include model.LastRead.findOne({nick: 'torgen', room_name: 'general/0'}), + timestamp: 3 + + it 'doesn\'t retreat', -> + model.LastRead.insert + nick: 'torgen' + room_name: 'general/0' + timestamp: 3 + Meteor.call 'updateLastRead', + nick: 'torgen' + room_name: 'general/0', + timestamp: 2 + chai.assert.include model.LastRead.findOne({nick: 'torgen', room_name: 'general/0'}), + timestamp: 3 + diff --git a/lib/methods/useQuip.test.coffee b/lib/methods/useQuip.test.coffee new file mode 100644 index 000000000..ce91ef7e7 --- /dev/null +++ b/lib/methods/useQuip.test.coffee @@ -0,0 +1,78 @@ +'use strict' + +# Will access contents via share +import '../model.coffee' +import chai from 'chai' +import sinon from 'sinon' +import { resetDatabase } from 'meteor/xolvio:cleaner' + +model = share.model + +msg = 'This is codex. Your hunt is bad and you should feel bad.' + +describe 'useQuip', -> + clock = null + + beforeEach -> + clock = sinon.useFakeTimers(7) + + afterEach -> + clock.restore() + + id = null + beforeEach -> + resetDatabase() + id = model.Quips.insert + created: 1 + created_by: 'torgen' + touched: 1 + touched_by: 'torgen' + name: 'Hector Mercilessly' + text: msg + last_used: 3 + use_count: 2 + + describe 'not punted', -> + quip = null + beforeEach -> + Meteor.call 'useQuip', + id: id + who:'cjb' + quip = model.Quips.findOne id + + it 'updates document', -> + chai.assert.include quip, + created: 1 + created_by: 'torgen' + touched: 7 + touched_by: 'cjb' + last_used: 7 + use_count: 3 + + it 'tells general chat', -> + chai.assert.lengthOf model.Messages.find({nick: 'cjb', action: true}).fetch(), 1 + + describe 'punted', -> + quip = null + beforeEach -> + Meteor.call 'useQuip', + id: id + who:'cjb' + punted: true + quip = model.Quips.findOne id + + it 'updates document', -> + chai.assert.include quip, + created: 1 + created_by: 'torgen' + touched: 7 + touched_by: 'cjb' + last_used: 7 + use_count: 2 + + it 'no message', -> + chai.assert.lengthOf model.Messages.find(room_name: 'general/0').fetch(), 0 + + + + diff --git a/lib/model.coffee b/lib/model.coffee index 841f992ac..7f9b8fe11 100644 --- a/lib/model.coffee +++ b/lib/model.coffee @@ -1,4 +1,9 @@ 'use strict' + +import canonical from './imports/canonical.coffee' +import { NonEmptyString, IdOrObject, ObjectWith } from './imports/match.coffee' +import { getTag, isStuck, canonicalTags } from './imports/tags.coffee' + # Blackboard -- data model # Loaded on both the client and the server @@ -6,47 +11,16 @@ # client/server load. PRESENCE_KEEPALIVE_MINUTES = 2 -# how many chats in a page? -MESSAGE_PAGE = 100 - # this is used to yield "zero results" in collections which index by timestamp NOT_A_TIMESTAMP = -9999 -# migrate old documents with different 'answer' representation -MIGRATE_ANSWERS = false - -# move pages of messages to oldmessages collection -MOVE_OLD_PAGES = true - # Server-side, client-side, or no follow-up processing followupStyle = -> Meteor.settings?.public?.followupStyle ? 'client' -emojify = (s) -> share.emojify?(s) or s - -# helper function: like _.throttle, but always ensures `wait` of idle time -# between invocations. This ensures that we stay chill even if a single -# execution of the function starts to exceed `wait`. -throttle = (func, wait = 0) -> - [context, args, running, pending] = [null, null, false, false] - later = -> - if pending - run() - else - running = false - run = -> - [running, pending] = [true, false] - try - func.apply(context, args) - # Note that the timeout doesn't start until the function has completed. - Meteor.setTimeout(later, wait) - (a...) -> - return if pending - [context, args] = [this, a] - if running - pending = true - else - running = true - Meteor.setTimeout(run, 0) +emojify = if Meteor.isServer + require('../server/imports/emoji.coffee').default +else + (s) -> s BBCollection = Object.create(null) # create new object w/o any inherited cruft @@ -89,25 +63,6 @@ LastAnswer = BBCollection.last_answer = \ RoundGroups = BBCollection.roundgroups = new Mongo.Collection "roundgroups" if Meteor.isServer RoundGroups._ensureIndex {canon: 1}, {unique:true, dropDups:true} - updateRoundStart = -> - round_start = 0 - RoundGroups.find({}, sort: ["created"]).forEach (rg) -> - if rg.round_start isnt round_start - RoundGroups.update rg._id, $set: round_start: round_start - round_start += rg.rounds.length - # Note that throttle uses Meteor.setTimeout here even if a call isn't - # yet pending -- we want to ensure that we give all the observeChanges - # time to fire before we do the update. - # In theory we could use `Tracker.afterFlush`, but see - # https://github.com/meteor/meteor/issues/3293 - queueUpdateRoundStart = throttle(updateRoundStart, 100) - # observe changes to the rounds field and update round_start - queueUpdateRoundStart() - RoundGroups.find({}).observeChanges - added: (id, fields) -> queueUpdateRoundStart() - removed: (id, fields) -> queueUpdateRoundStart() - changed: (id, fields) -> - queueUpdateRoundStart() if 'created' of fields or 'rounds' of fields # Rounds are: # _id: mongodb id @@ -128,33 +83,6 @@ if Meteor.isServer Rounds = BBCollection.rounds = new Mongo.Collection "rounds" if Meteor.isServer Rounds._ensureIndex {canon: 1}, {unique:true, dropDups:true} - if MIGRATE_ANSWERS - # migrate objects -- rename 'Meta answer' tag to 'Answer' - Meteor.startup -> - Rounds.find({}).forEach (r) -> - answer = getTag(r, 'Meta Answer') - return unless answer? - console.log 'Migrating round', r.name - tweak = (tag) -> - name = if tag.canon is 'meta_answer' then 'Answer' else tag.name - return { - name: name - canon: canonical(name) - value: tag.value - touched: tag.touched ? r.created - touched_by: tag.touched_by ? r.created_by - } - ntags = (tweak(tag) for tag in r.tags) - ntags.sort (a, b) -> (a?.canon or "").localeCompare (b?.canon or "") - [solved, solved_by] = [null, null] - ntags.forEach (tag) -> if tag.canon is canonical('Answer') - [solved, solved_by] = [tag.touched, tag.touched_by] - Rounds.update r._id, $set: - tags: ntags - incorrectAnswers: [] - solved: solved - solved_by: solved_by - # Puzzles are: # _id: mongodb id @@ -174,18 +102,6 @@ if Meteor.isServer Puzzles = BBCollection.puzzles = new Mongo.Collection "puzzles" if Meteor.isServer Puzzles._ensureIndex {canon: 1}, {unique:true, dropDups:true} - if MIGRATE_ANSWERS - # migrate objects -- we used to have an `answer` field in Puzzles. - Meteor.startup -> - Puzzles.find(answer: { $exists: true, $ne: null }).forEach (p) -> - console.log 'Migrating puzzle', p.name - update = {$set: {solved: p.solved}, $unset: {answer: ''}} - Meteor.call "setAnswer", - type: 'puzzles' - target: p._id - answer: p.answer - who: p.solved_by - Puzzles.update p._id, update # CallIns are: # _id: mongodb id @@ -199,8 +115,8 @@ if Meteor.isServer # provided: true/false CallIns = BBCollection.callins = new Mongo.Collection "callins" if Meteor.isServer - CallIns._ensureIndex {created: 1}, {} - CallIns._ensureIndex {type: 1, target: 1, answer: 1}, {unique:true, dropDups:true} + CallIns._ensureIndex {created: 1}, {} + CallIns._ensureIndex {type: 1, target: 1, answer: 1}, {unique:true, dropDups:true} # Quips are: # _id: mongodb id @@ -230,35 +146,6 @@ Nicks = BBCollection.nicks = new Mongo.Collection "nicks" if Meteor.isServer Nicks._ensureIndex {canon: 1}, {unique:true, dropDups:true} Nicks._ensureIndex {priv_located_order: 1}, {} - # synchronize priv_located* with located* at a throttled rate. - # order by priv_located_order, which we'll clear when we apply the update - # this ensures nobody gets starved for updates - do -> - # limit to 10 location updates/minute - LOCATION_BATCH_SIZE = 10 - LOCATION_THROTTLE = 60*1000 - runBatch = -> - Nicks.find({ - priv_located_order: { $exists: true, $ne: null } - }, { - sort: [['priv_located_order','asc']] - limit: LOCATION_BATCH_SIZE - }).forEach (n, i) -> - console.log "Updating location for #{n.name} (#{i})" - Nicks.update n._id, - $set: - located: n.priv_located - located_at: n.priv_located_at - $unset: priv_located_order: '' - maybeRunBatch = throttle(runBatch, LOCATION_THROTTLE) - Nicks.find({ - priv_located_order: { $exists: true, $ne: null } - }, { - fields: priv_located_order: 1 - }).observeChanges - added: (id, fields) -> maybeRunBatch() - # also run batch on removed: batch size might not have been big enough - removed: (id) -> maybeRunBatch() # Messages # body: string @@ -305,66 +192,6 @@ if Meteor.isServer M._ensureIndex {to:1, room_name:1, timestamp:-1}, {} M._ensureIndex {nick:1, room_name:1, timestamp:-1}, {} M._ensureIndex {room_name:1, timestamp:-1}, {} - # watch messages collection and set the followup field as appropriate - # (followup field should already be set properly when the field is - # archived into the OldMessages collection) - if followupStyle() is 'server' then do -> - # defer (and then throttle) this computation on startup, so - # startup doesn't take forever. - initiallyDefer = true - check = (room_name, timestamp, m) -> - return if initiallyDefer - prev = Messages.find( - {room_name: room_name, timestamp: $lt: +timestamp}, - {sort:[['timestamp','desc']], limit: 1 }).fetch() - eq = Messages.find( - {room_name: room_name, timestamp: +timestamp}, - {sort:[['timestamp','asc']]}).fetch() - next = Messages.find( - {room_name: room_name, timestamp: $gt: +timestamp}, - {sort:[['timestamp','asc']], limit: 1}).fetch() - affected = prev.concat(eq, next) - # ok, for all possibly affected messages, see if the followup field is - # correct. - for i in [1...affected.length] by 1 - [ prev, curr ] = [ affected[i-1], affected[i] ] - f = computeMessageFollowup prev, curr - if (!!curr.followup) != f - console.log 'Updating followup status', curr._id, curr.nick - Messages.update curr._id, $set: followup: f - Messages.find({}).observe - added: (msg) -> check(msg.room_name, msg.timestamp, msg) - removed: (msg) -> check(msg.room_name, msg.timestamp) - changed: (nmsg, omsg) -> - check(omsg.room_name, omsg.timestamp) - check(nmsg.room_name, nmsg.timestamp, nmsg) - initiallyDefer = false - # ok, now we're going to (slowly) check all the messages, in chunks, - # at startup. We're throttling this so we don't hose the server on - # restart. - [checked,alleq] = [0,false] - CHUNK_SIZE = 50 # messages - CHUNK_PACE = 10 # seconds - checkChunk = throttle -> - cur = if alleq - Messages.find(timestamp: checked) - else - Messages.find({timestamp: $gt: checked},{sort:[['timestamp','asc']], limit: CHUNK_SIZE}) - lastTimestamp = null - cur.forEach (msg) -> - lastTimestamp = msg.timestamp - check(msg.room_name, msg.timestamp, msg) - if alleq - alleq = false - checkChunk() - else if lastTimestamp? - checked = lastTimestamp - alleq = true - checkChunk() - else - console.log 'Done checking followups.' - , CHUNK_PACE*1000 - checkChunk() # Pages -- paging metadata for Messages collection # from: timestamp (first page has from==0) @@ -376,71 +203,12 @@ if Meteor.isServer # Messages with from <= timestamp < to are included in a specific page. Pages = BBCollection.pages = new Mongo.Collection "pages" if Meteor.isServer - # used in the server observe code below + # used in the observe code in server/batch.coffee Pages._ensureIndex {room_name:1, to:-1}, {unique:true} # used in the publish method Pages._ensureIndex {next: 1, room_name:1}, {} # used for archiving Pages._ensureIndex {archived:1, next:1, to:1}, {} - # ensure old pages have the `archived` field - Meteor.startup -> - Pages.find(archived: $exists: false).forEach (p) -> - Pages.update p._id, $set: archived: false - # move messages to oldmessages collection - queueMessageArchive = throttle -> - p = Pages.findOne({archived: false, next: $ne: null}, {sort:[['to','asc']]}) - return unless p? - limit = 2 * MESSAGE_PAGE - loop - msgs = Messages.find({room_name: p.room_name, timestamp: $lt: p.to}, \ - {sort:[['to','asc']], limit: limit, reactive: false}).fetch() - OldMessages.upsert(m._id, m) for m in msgs - Pages.update(p._id, $set: archived: true) if msgs.length < limit - Messages.remove(m._id) for m in msgs - break if msgs.length < limit - queueMessageArchive() - , 60*1000 # no more than once a minute - # watch messages collection and create pages as necessary - do -> - unpaged = Object.create(null) - Messages.find({}, sort:[['timestamp','asc']]).observe - added: (msg) -> - room_name = msg.room_name - # don't count pms (so we don't end up with a blank 'page') - return if msg.to - # add to (conservative) count of unpaged messages - # (this message might already be in a page, but we'll catch that below) - unpaged[room_name] = (unpaged[room_name] or 0) + 1 - return if unpaged[room_name] < MESSAGE_PAGE - # recompute page parameters before adding a new page - # (be safe in case we had out-of-order observations) - # find highest existing page - p = Pages.findOne({room_name: room_name}, {sort:[['to','desc']]})\ - or { _id: null, room_name: room_name, from: -1, to: 0 } - # count the number of unpaged messages - m = Messages.find(\ - {room_name: room_name, to: null, timestamp: $gte: p.to}, \ - {sort:[['timestamp','asc']], limit: MESSAGE_PAGE}).fetch() - if m.length < MESSAGE_PAGE - # false alarm: reset unpaged message count and continue - unpaged[room_name] = m.length - return - # ok, let's make a new page. this will include at least all the - # messages in m, possibly more (if there are additional messages - # added with timestamp == m[m.length-1].timestamp) - pid = Pages.insert - room_name: room_name - from: p.to - to: 1 + m[m.length-1].timestamp - prev: p._id - next: null - archived: false - if p._id? - Pages.update p._id, $set: next: pid - unpaged[room_name] = 0 - queueMessageArchive() if MOVE_OLD_PAGES - # migrate messages to old messages collection - (Meteor.startup queueMessageArchive) if MOVE_OLD_PAGES # Last read message for a user in a particular chat room # nick: canonicalized string, as in Messages @@ -463,53 +231,6 @@ if Meteor.isServer Presence._ensureIndex {nick: 1, room_name:1}, {unique:true, dropDups:true} Presence._ensureIndex {timestamp:-1}, {} Presence._ensureIndex {present:1, room_name:1}, {} - # ensure old entries are timed out after 2*PRESENCE_KEEPALIVE_MINUTES - # some leeway here to account for client/server time drift - Meteor.setInterval -> - #console.log "Removing entries older than", (UTCNow() - 5*60*1000) - removeBefore = UTCNow() - (2*PRESENCE_KEEPALIVE_MINUTES*60*1000) - Presence.remove timestamp: $lt: removeBefore - , 60*1000 - # generate automatic " entered " and left room" messages - # as the presence set changes - initiallySuppressPresence = true - Presence.find(present: true).observe - added: (presence) -> - return if initiallySuppressPresence - # look up a real name, if there is one - n = Nicks.findOne canon: canonical(presence.nick) - name = getTag(n, 'Real Name') or presence.nick - #console.log "#{name} entered #{presence.room_name}" - return if presence.room_name is 'oplog/0' - Messages.insert - system: true - nick: presence.nick - to: null - presence: 'join' - body: "#{name} joined the room." - bodyIsHtml: false - room_name: presence.room_name - timestamp: UTCNow() - removed: (presence) -> - return if initiallySuppressPresence - # look up a real name, if there is one - n = Nicks.findOne canon: canonical(presence.nick) - name = getTag(n, 'Real Name') or presence.nick - #console.log "#{name} left #{presence.room_name}" - return if presence.room_name is 'oplog/0' - Messages.insert - system: true - nick: presence.nick - to: null - presence: 'part' - body: "#{name} left the room." - bodyIsHtml: false - room_name: presence.room_name - timestamp: UTCNow() - # turn on presence notifications once initial observation set has been - # processed. (observe doesn't return on server until initial observation - # is complete.) - initiallySuppressPresence = false # this reverses the name given to Mongo.Collection; that is the # 'type' argument is the name of a server-side Mongo collection. @@ -527,21 +248,6 @@ pretty_collection = (type) -> when "oldmessages" then "old message" else type.replace(/s$/, '') -getTag = (object, name) -> - (tag.value for tag in (object?.tags or []) when tag.canon is canonical(name))[0] - -isStuck = (object) -> - object? and /^stuck\b/i.test(getTag(object, 'Status') or '') - -# canonical names: lowercases, all non-alphanumerics replaced with '_' -canonical = (s) -> - s = s.toLowerCase().replace(/^\s+/, '').replace(/\s+$/, '') # lower, strip - # suppress 's and 't - s = s.replace(/[\'\u2019]([st])\b/g, "$1") - # replace all non-alphanumeric with _ - s = s.replace(/[^a-z0-9]+/g, '_').replace(/^_/,'').replace(/_$/,'') - return s - drive_id_to_link = (id) -> "https://docs.google.com/folder/d/#{id}/edit" spread_id_to_link = (id) -> @@ -551,16 +257,8 @@ spread_id_to_link = (id) -> # private helpers, not exported unimplemented = -> throw new Meteor.Error(500, "Unimplemented") - canonicalTags = (tags, who) -> - check tags, [ObjectWith(name:NonEmptyString,value:Match.Any)] - now = UTCNow() - ({ - name: tag.name - canon: canonical(tag.name) - value: tag.value - touched: tag.touched ? now - touched_by: tag.touched_by ? canonical(who) - } for tag in tags) + isDuplicateError = (error) -> + Meteor.isServer and error?.name is 'MongoError' and error?.code==11000 huntPrefix = (type) -> # this is a huge hack, it's too hard to find the correct @@ -572,9 +270,6 @@ spread_id_to_link = (id) -> else return Meteor.settings?[type+'_prefix'] - NonEmptyString = Match.Where (x) -> - check x, String - return x.length > 0 # a key of BBCollection ValidType = Match.Where (x) -> check x, NonEmptyString @@ -583,17 +278,6 @@ spread_id_to_link = (id) -> ValidAnswerType = Match.Where (x) -> check x, ValidType x == 'puzzles' || x == 'rounds' || x == 'roundgroups' - # either an id, or an object containing an id - IdOrObject = Match.OneOf NonEmptyString, Match.Where (o) -> - typeof o is 'object' and ((check o._id, NonEmptyString) or true) - # This is like Match.ObjectIncluding, but we don't require `o` to be - # a plain object - ObjectWith = (pattern) -> - Match.Where (o) -> - return false if typeof(o) is not 'object' - Object.keys(pattern).forEach (k) -> - check o[k], pattern[k] - true oplog = (message, type="", id="", who="", stream="") -> Messages.insert @@ -629,7 +313,7 @@ spread_id_to_link = (id) -> try object._id = collection(type).insert object catch error - if Meteor.isServer and error?.name is 'MongoError' and error?.code==11000 + if isDuplicateError error # duplicate key, fetch the real thing return collection(type).findOne({canon:canonical(args.name)}) throw error # something went wrong, who knows what, pass it on @@ -651,11 +335,17 @@ spread_id_to_link = (id) -> if collection(type).findOne(args.id).name is args.name return false - collection(type).update args.id, $set: - name: args.name - canon: canonical(args.name) - touched: now - touched_by: canonical(args.who) + try + collection(type).update args.id, $set: + name: args.name + canon: canonical(args.name) + touched: now + touched_by: canonical(args.who) + catch error + # duplicate name--bail out + if isDuplicateError error + return false + throw error unless options.suppressLog oplog "Renamed", type, args.id, args.who return true @@ -878,13 +568,12 @@ spread_id_to_link = (id) -> return false unless old? and old?.puzzles?.length is 0 # get drive ID (racy) drive = old?.drive - spreadsheet = old?.spreadsheet # remove round itself r = deleteObject "rounds", args # remove from all roundgroups RoundGroups.update { rounds: rid },{ $pull: rounds: rid },{ multi: true } # delete google drive folder and all contents, recursively - deleteDriveFolder drive, spreadsheet if drive? + deleteDriveFolder drive if drive? # XXX: delete chat room logs? return r @@ -923,13 +612,12 @@ spread_id_to_link = (id) -> # get drive ID (racy) old = Puzzles.findOne(args.id) drive = old?.drive - spreadsheet = old?.spreadsheet # remove puzzle itself r = deleteObject "puzzles", args # remove from all rounds Rounds.update { puzzles: pid },{ $pull: puzzles: pid },{ multi: true } # delete google drive folder - deleteDriveFolder drive, spreadsheet if drive? + deleteDriveFolder drive if drive? # XXX: delete chat room logs? return r @@ -1156,17 +844,6 @@ spread_id_to_link = (id) -> nick: newMsg.nick room_name: newMsg.room_name timestamp: newMsg.timestamp - # update the 'followup' field to reduce flicker. - # it doesn't matter if this computation isn't exact (for example if - # there are multiple messages with the same timestamp); there's - # a server observe thread to compute the actual correct value. we just - # want to reduce flicker in the common case. - if followupStyle() is 'server' - prev = Messages.find( - {room_name: newMsg.room_name, timestamp: $lt: newMsg.timestamp}, - {sort: [['timestamp','desc']], limit: 1 }).fetch() - if prev.length and computeMessageFollowup(prev[0], newMsg) - newMsg.followup = true newMsg._id = Messages.insert newMsg return newMsg @@ -1185,8 +862,7 @@ spread_id_to_link = (id) -> catch e # ignore duplicate key errors; they are harmless and occur when we # try to move the LastRead.timestamp backwards. - if Meteor.isServer and e?.name is 'MongoError' and e?.code==11000 - return false + return false if isDuplicateError e throw e setPresence: (args) -> @@ -1326,7 +1002,8 @@ spread_id_to_link = (id) -> if obj.solved return "#{pretty_collection args.type} #{obj.name} is already answered" wasStuck = isStuck obj - how = args.how or 'Stuck' + rawhow = args.how or 'Stuck' + how = if rawhow.toLowerCase().startsWith('stuck') then rawhow else "Stuck: #{rawhow}" setTagInternal object: id type: args.type @@ -1337,7 +1014,7 @@ spread_id_to_link = (id) -> if wasStuck return oplog "Help requested for", args.type, id, args.who, 'stuck' - body = "has requested help: #{how}" + body = "has requested help: #{rawhow}" Meteor.call 'newMessage', nick: args.who action: true @@ -1345,7 +1022,7 @@ spread_id_to_link = (id) -> room_name: "#{args.type}/#{id}" objUrl = # see Router.urlFor Meteor._relativeToSiteRootUrl "/#{args.type}/#{id}" - body = "has requested help: #{UI._escape how} (#{pretty_collection args.type} #{UI._escape obj.name})" + body = "has requested help: #{UI._escape rawhow} (#{pretty_collection args.type} #{UI._escape obj.name})" Meteor.call 'newMessage', nick: args.who action: true @@ -1603,7 +1280,6 @@ UTCNow = -> Date.now() share.model = # constants PRESENCE_KEEPALIVE_MINUTES: PRESENCE_KEEPALIVE_MINUTES - MESSAGE_PAGE: MESSAGE_PAGE NOT_A_TIMESTAMP: NOT_A_TIMESTAMP # collection types CallIns: CallIns @@ -1625,6 +1301,7 @@ share.model = getTag: getTag isStuck: isStuck followupStyle: followupStyle + computeMessageFollowup: computeMessageFollowup canonical: canonical drive_id_to_link: drive_id_to_link spread_id_to_link: spread_id_to_link diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..c636d4ce3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,190 @@ +{ + "name": "codex-blackboard", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@sinonjs/formatio": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-2.0.0.tgz", + "integrity": "sha512-ls6CAMA6/5gG+O/IdsBcblvnd8qcO/l1TYoNeAzp3wcISOxlPXQEus0mLcdwazEkWjaBdaJ3TaxmNgCLWwvWzg==", + "dev": true, + "requires": { + "samsam": "1.3.0" + } + }, + "@sinonjs/samsam": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-2.0.0.tgz", + "integrity": "sha512-D7VxhADdZbDJ0HjUTMnSQ5xIGb4H2yWpg8k9Sf1T08zfFiQYlaxM8LZydpR4FQ2E6LZJX8IlabNZ5io4vdChwg==", + "dev": true + }, + "assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true + }, + "babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", + "requires": { + "core-js": "2.5.1", + "regenerator-runtime": "0.11.0" + } + }, + "chai": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.1.2.tgz", + "integrity": "sha1-D2RYS6ZC8PKs4oBiefTwbKI61zw=", + "dev": true, + "requires": { + "assertion-error": "1.1.0", + "check-error": "1.0.2", + "deep-eql": "3.0.1", + "get-func-name": "2.0.0", + "pathval": "1.1.0", + "type-detect": "4.0.8" + } + }, + "check-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", + "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", + "dev": true + }, + "core-js": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.1.tgz", + "integrity": "sha1-rmh03GaTd4m4B1T/VCjfZoGcpQs=" + }, + "deep-eql": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", + "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "dev": true + }, + "get-func-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", + "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + }, + "just-extend": { + "version": "1.1.27", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-1.1.27.tgz", + "integrity": "sha512-mJVp13Ix6gFo3SBAy9U/kL+oeZqzlYYYLQBwXVBlVzIsZwBqGREnOro24oC/8s8aox+rJhtZ2DiQof++IrkA+g==", + "dev": true + }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", + "dev": true + }, + "lolex": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-2.7.1.tgz", + "integrity": "sha512-Oo2Si3RMKV3+lV5MsSWplDQFoTClz/24S0MMHYcgGWWmFXr6TMlqcqk/l1GtH+d5wLBwNRiqGnwDRMirtFalJw==", + "dev": true + }, + "nise": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/nise/-/nise-1.4.2.tgz", + "integrity": "sha512-BxH/DxoQYYdhKgVAfqVy4pzXRZELHOIewzoesxpjYvpU+7YOalQhGNPf7wAx8pLrTNPrHRDlLOkAl8UI0ZpXjw==", + "dev": true, + "requires": { + "@sinonjs/formatio": "2.0.0", + "just-extend": "1.1.27", + "lolex": "2.7.1", + "path-to-regexp": "1.7.0", + "text-encoding": "0.6.4" + } + }, + "path-to-regexp": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.7.0.tgz", + "integrity": "sha1-Wf3g9DW62suhA6hOnTvGTpa5k30=", + "dev": true, + "requires": { + "isarray": "0.0.1" + } + }, + "pathval": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", + "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", + "dev": true + }, + "regenerator-runtime": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.0.tgz", + "integrity": "sha1-flT+W1zNXWYk6mJVw0c74JC4AuE=" + }, + "samsam": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/samsam/-/samsam-1.3.0.tgz", + "integrity": "sha512-1HwIYD/8UlOtFS3QO3w7ey+SdSDFE4HRNLZoZRYVQefrOY3l17epswImeB1ijgJFQJodIaHcwkp3r/myBjFVbg==", + "dev": true + }, + "sinon": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-6.1.4.tgz", + "integrity": "sha512-NFEts+4D4jp2sBjL94fQpZk5o73kzn/g58+I9Dp15i9vsnT4Lk1UEyUf2jACODWLG6Pz/llF0sArYUw47Aarmg==", + "dev": true, + "requires": { + "@sinonjs/formatio": "2.0.0", + "@sinonjs/samsam": "2.0.0", + "diff": "3.5.0", + "lodash.get": "4.4.2", + "lolex": "2.7.1", + "nise": "1.4.2", + "supports-color": "5.4.0", + "type-detect": "4.0.8" + } + }, + "supports-color": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", + "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", + "dev": true, + "requires": { + "has-flag": "3.0.0" + } + }, + "text-encoding": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz", + "integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk=", + "dev": true + }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..8d5bc9fbc --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "codex-blackboard", + "version": "1.0.0", + "description": "codex-blackboard ================", + "main": "index.js", + "dependencies": { + "babel-runtime": "^6.26.0" + }, + "devDependencies": { + "chai": "^4.1.2", + "sinon": "^6.1.4" + }, + "scripts": { + "test": "meteor test --once --driver-package meteortesting:mocha" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/cjb/codex-blackboard.git" + }, + "author": "", + "license": "AGPL", + "bugs": { + "url": "https://github.com/cjb/codex-blackboard/issues" + }, + "homepage": "https://github.com/cjb/codex-blackboard#readme", + "private": true +} diff --git a/server/batch.coffee b/server/batch.coffee new file mode 100644 index 000000000..7400479bb --- /dev/null +++ b/server/batch.coffee @@ -0,0 +1,307 @@ +'use strict' + +import canonical from '../lib/imports/canonical.coffee' +import { getTag } from '../lib/imports/tags.coffee' + +model = share.model + +# Does various fixups of the collections. +# Was in lib/model.coffee, but that meant it was loaded on the client even +# though it could never run there. + +# how many chats in a page? +MESSAGE_PAGE = 100 + +# migrate old documents with different 'answer' representation +MIGRATE_ANSWERS = false + +# move pages of messages to oldmessages collection +MOVE_OLD_PAGES = true + +# helper function: like _.throttle, but always ensures `wait` of idle time +# between invocations. This ensures that we stay chill even if a single +# execution of the function starts to exceed `wait`. +throttle = (func, wait = 0) -> + [context, args, running, pending] = [null, null, false, false] + later = -> + if pending + run() + else + running = false + run = -> + [running, pending] = [true, false] + try + func.apply(context, args) + # Note that the timeout doesn't start until the function has completed. + Meteor.setTimeout(later, wait) + (a...) -> + return if pending + [context, args] = [this, a] + if running + pending = true + else + running = true + Meteor.setTimeout(run, 0) + +# Round groups +updateRoundStart = -> + round_start = 0 + model.RoundGroups.find({}, sort: ["created"]).forEach (rg) -> + if rg.round_start isnt round_start + model.RoundGroups.update rg._id, $set: round_start: round_start + round_start += rg.rounds.length +# Note that throttle uses Meteor.setTimeout here even if a call isn't +# yet pending -- we want to ensure that we give all the observeChanges +# time to fire before we do the update. +# In theory we could use `Tracker.afterFlush`, but see +# https://github.com/meteor/meteor/issues/3293 +queueUpdateRoundStart = throttle(updateRoundStart, 100) +# observe changes to the rounds field and update round_start +queueUpdateRoundStart() +model.RoundGroups.find({}).observeChanges + added: (id, fields) -> queueUpdateRoundStart() + removed: (id, fields) -> queueUpdateRoundStart() + changed: (id, fields) -> + queueUpdateRoundStart() if 'created' of fields or 'rounds' of fields + +# Rounds +if MIGRATE_ANSWERS + # migrate objects -- rename 'Meta answer' tag to 'Answer' + Meteor.startup -> + model.Rounds.find({}).forEach (r) -> + answer = getTag(r, 'Meta Answer') + return unless answer? + console.log 'Migrating round', r.name + tweak = (tag) -> + name = if tag.canon is 'meta_answer' then 'Answer' else tag.name + return { + name: name + canon: canonical(name) + value: tag.value + touched: tag.touched ? r.created + touched_by: tag.touched_by ? r.created_by + } + ntags = (tweak(tag) for tag in r.tags) + ntags.sort (a, b) -> (a?.canon or "").localeCompare (b?.canon or "") + [solved, solved_by] = [null, null] + ntags.forEach (tag) -> if tag.canon is canonical('Answer') + [solved, solved_by] = [tag.touched, tag.touched_by] + model.Rounds.update r._id, $set: + tags: ntags + incorrectAnswers: [] + solved: solved + solved_by: solved_by + +# Puzzles +if MIGRATE_ANSWERS + # migrate objects -- we used to have an `answer` field in Puzzles. + Meteor.startup -> + model.Puzzles.find(answer: { $exists: true, $ne: null }).forEach (p) -> + console.log 'Migrating puzzle', p.name + update = {$set: {solved: p.solved}, $unset: {answer: ''}} + Meteor.call "setAnswer", + type: 'puzzles' + target: p._id + answer: p.answer + who: p.solved_by + model.Puzzles.update p._id, update + +# Nicks: synchronize priv_located* with located* at a throttled rate. +# order by priv_located_order, which we'll clear when we apply the update +# this ensures nobody gets starved for updates +do -> + # limit to 10 location updates/minute + LOCATION_BATCH_SIZE = 10 + LOCATION_THROTTLE = 60*1000 + runBatch = -> + model.Nicks.find({ + priv_located_order: { $exists: true, $ne: null } + }, { + sort: [['priv_located_order','asc']] + limit: LOCATION_BATCH_SIZE + }).forEach (n, i) -> + console.log "Updating location for #{n.name} (#{i})" + model.Nicks.update n._id, + $set: + located: n.priv_located + located_at: n.priv_located_at + $unset: priv_located_order: '' + maybeRunBatch = throttle(runBatch, LOCATION_THROTTLE) + model.Nicks.find({ + priv_located_order: { $exists: true, $ne: null } + }, { + fields: priv_located_order: 1 + }).observeChanges + added: (id, fields) -> maybeRunBatch() + # also run batch on removed: batch size might not have been big enough + removed: (id) -> maybeRunBatch() + +# Messages +# watch messages collection and set the followup field as appropriate +# (followup field should already be set properly when the field is +# archived into the OldMessages collection) +if model.followupStyle() is 'server' then do -> + # defer (and then throttle) this computation on startup, so + # startup doesn't take forever. + initiallyDefer = true + check = (room_name, timestamp, m) -> + return if initiallyDefer + prev = Messages.find( + {room_name: room_name, timestamp: $lt: +timestamp}, + {sort:[['timestamp','desc']], limit: 1 }).fetch() + eq = Messages.find( + {room_name: room_name, timestamp: +timestamp}, + {sort:[['timestamp','asc']]}).fetch() + next = Messages.find( + {room_name: room_name, timestamp: $gt: +timestamp}, + {sort:[['timestamp','asc']], limit: 1}).fetch() + affected = prev.concat(eq, next) + # ok, for all possibly affected messages, see if the followup field is + # correct. + for i in [1...affected.length] by 1 + [ prev, curr ] = [ affected[i-1], affected[i] ] + f = model.computeMessageFollowup prev, curr + if (!!curr.followup) != f + console.log 'Updating followup status', curr._id, curr.nick + Messages.update curr._id, $set: followup: f + Messages.find({}).observe + added: (msg) -> check(msg.room_name, msg.timestamp, msg) + removed: (msg) -> check(msg.room_name, msg.timestamp) + changed: (nmsg, omsg) -> + check(omsg.room_name, omsg.timestamp) + check(nmsg.room_name, nmsg.timestamp, nmsg) + initiallyDefer = false + # ok, now we're going to (slowly) check all the messages, in chunks, + # at startup. We're throttling this so we don't hose the server on + # restart. + [checked,alleq] = [0,false] + CHUNK_SIZE = 50 # messages + CHUNK_PACE = 10 # seconds + checkChunk = throttle -> + cur = if alleq + Messages.find(timestamp: checked) + else + Messages.find({timestamp: $gt: checked},{sort:[['timestamp','asc']], limit: CHUNK_SIZE}) + lastTimestamp = null + cur.forEach (msg) -> + lastTimestamp = msg.timestamp + check(msg.room_name, msg.timestamp, msg) + if alleq + alleq = false + checkChunk() + else if lastTimestamp? + checked = lastTimestamp + alleq = true + checkChunk() + else + console.log 'Done checking followups.' + , CHUNK_PACE*1000 + checkChunk() + +# Pages +# ensure old pages have the `archived` field +Meteor.startup -> + model.Pages.find(archived: $exists: false).forEach (p) -> + model.Pages.update p._id, $set: archived: false +# move messages to oldmessages collection +queueMessageArchive = throttle -> + p = model.Pages.findOne({archived: false, next: $ne: null}, {sort:[['to','asc']]}) + return unless p? + limit = 2 * MESSAGE_PAGE + loop + msgs = model.Messages.find({room_name: p.room_name, timestamp: $lt: p.to}, \ + {sort:[['to','asc']], limit: limit, reactive: false}).fetch() + model.OldMessages.upsert(m._id, m) for m in msgs + model.Pages.update(p._id, $set: archived: true) if msgs.length < limit + model.Messages.remove(m._id) for m in msgs + break if msgs.length < limit + queueMessageArchive() +, 60*1000 # no more than once a minute +# watch messages collection and create pages as necessary +do -> + unpaged = Object.create(null) + model.Messages.find({}, sort:[['timestamp','asc']]).observe + added: (msg) -> + room_name = msg.room_name + # don't count pms (so we don't end up with a blank 'page') + return if msg.to + # add to (conservative) count of unpaged messages + # (this message might already be in a page, but we'll catch that below) + unpaged[room_name] = (unpaged[room_name] or 0) + 1 + return if unpaged[room_name] < MESSAGE_PAGE + # recompute page parameters before adding a new page + # (be safe in case we had out-of-order observations) + # find highest existing page + p = model.Pages.findOne({room_name: room_name}, {sort:[['to','desc']]})\ + or { _id: null, room_name: room_name, from: -1, to: 0 } + # count the number of unpaged messages + m = model.Messages.find(\ + {room_name: room_name, to: null, timestamp: $gte: p.to}, \ + {sort:[['timestamp','asc']], limit: MESSAGE_PAGE}).fetch() + if m.length < MESSAGE_PAGE + # false alarm: reset unpaged message count and continue + unpaged[room_name] = m.length + return + # ok, let's make a new page. this will include at least all the + # messages in m, possibly more (if there are additional messages + # added with timestamp == m[m.length-1].timestamp) + pid = model.Pages.insert + room_name: room_name + from: p.to + to: 1 + m[m.length-1].timestamp + prev: p._id + next: null + archived: false + if p._id? + model.Pages.update p._id, $set: next: pid + unpaged[room_name] = 0 + queueMessageArchive() if MOVE_OLD_PAGES +# migrate messages to old messages collection +(Meteor.startup queueMessageArchive) if MOVE_OLD_PAGES + +# Presence +# ensure old entries are timed out after 2*PRESENCE_KEEPALIVE_MINUTES +# some leeway here to account for client/server time drift +Meteor.setInterval -> + #console.log "Removing entries older than", (UTCNow() - 5*60*1000) + removeBefore = model.UTCNow() - (2*model.PRESENCE_KEEPALIVE_MINUTES*60*1000) + model.Presence.remove timestamp: $lt: removeBefore +, 60*1000 +# generate automatic " entered " and left room" messages +# as the presence set changes +initiallySuppressPresence = true +model.Presence.find(present: true).observe + added: (presence) -> + return if initiallySuppressPresence + return if presence.room_name is 'oplog/0' + # look up a real name, if there is one + n = model.Nicks.findOne canon: canonical(presence.nick) + name = getTag(n, 'Real Name') or presence.nick + model.Messages.insert + system: true + nick: presence.nick + to: null + presence: 'join' + body: "#{name} joined the room." + bodyIsHtml: false + room_name: presence.room_name + timestamp: model.UTCNow() + removed: (presence) -> + return if initiallySuppressPresence + return if presence.room_name is 'oplog/0' + # look up a real name, if there is one + n = model.Nicks.findOne canon: canonical(presence.nick) + name = getTag(n, 'Real Name') or presence.nick + model.Messages.insert + system: true + nick: presence.nick + to: null + presence: 'part' + body: "#{name} left the room." + bodyIsHtml: false + room_name: presence.room_name + timestamp: model.UTCNow() +# turn on presence notifications once initial observation set has been +# processed. (observe doesn't return on server until initial observation +# is complete.) +initiallySuppressPresence = false diff --git a/server/drive.coffee b/server/drive.coffee index be76b5b8e..fc486f13b 100644 --- a/server/drive.coffee +++ b/server/drive.coffee @@ -1,3 +1,7 @@ +'use strict' + +import { Drive, FailDrive } from './imports/drive.coffee' + # helper functions to perform Google Drive operations # Credentials @@ -8,228 +12,6 @@ if Meteor.settings.password? EMAIL = Meteor.settings.email or '571639156428@developer.gserviceaccount.com' SCOPES = ['https://www.googleapis.com/auth/drive'] -# Drive folder settings -ROOT_FOLDER_NAME = Meteor.settings.folder or "MIT Mystery Hunt 2014" -CODEX_ACCOUNT = 'zouchenuttall@gmail.com' -# FYI: password for CODEX_ACCOUNT is Meteor.settings.password -WORKSHEET_NAME = (name) -> "Worksheet: #{name}" - -# Constants -GDRIVE_FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder' -GDRIVE_SPREADSHEET_MIME_TYPE = 'application/vnd.google-apps.spreadsheet' -XLSX_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' -MAX_RESULTS = 200 -SPREADSHEET_TEMPLATE = Assets.getBinary 'spreadsheet-template.xlsx' - -# fetch the API and authorize -drive = null -rootFolder = null -ringhuntersFolder = null -debug = {} - -quote = (str) -> - "'" + str.replace(/([\'\\])/g, '\\$1') + "'" - -checkAuth = (type) -> - return true if drive? - console.warn "Skipping Google Drive operation:", type - false - -wrapCheck = (f, type) -> - () -> - return unless checkAuth type - f.apply(this, arguments) - -userRateExceeded = (error) -> - return false unless error.code == 403 - for subError in error.errors - if subError.domain is "usageLimits" and subError.reason is "userRateLimitExceeded" - return true - return false - -delays = [100, 250, 500, 1000, 2000, 5000, 10000] - -afterDelay = (ix, base, name, params, callback) -> - try - r = Gapi.exec base, name, params - callback null, r - catch error - if ix >= delays.length or not userRateExceeded(error) - callback error, null - return - console.warn "Rate limited for #{name}; Will retry after #{delays[ix]}ms" - later = -> - afterDelay ix+1, base, name, params, callback - Meteor.setTimeout later, delays[ix] - - -apiThrottle = Meteor.wrapAsync (base, name, params, callback) -> - afterDelay 0, base, name, params, callback - -ensureFolder = (name, parent) -> - # check to see if the folder already exists - resp = apiThrottle drive.children, 'list', - folderId: parent or 'root' - q: "title=#{quote name}" - maxResults: 1 - if resp.items.length > 0 - resource = resp.items[0] - else - # create the folder - resource = - title: name - mimeType: GDRIVE_FOLDER_MIME_TYPE - resource.parents = [id: parent] if parent - resource = apiThrottle drive.files, 'insert', - resource: resource - # give the new folder the right permissions - ensurePermissions(resource.id) - resource - -samePerm = (p, pp) -> - (p.withLink or false) is (pp.withLink or false) and \ - p.role is pp.role and \ - p.type is pp.type and \ - if p.type is 'anyone' - true - else if ('value' of p) and ('value' of pp) - (p.value is pp.value) - else # hack! google doesn't return the full email address in the permission - (p.type is 'user' and p.value is CODEX_ACCOUNT and pp.name is 'Zouche Nuttall') - -ensurePermissions = (id) -> - # give permissions to both anyone with link and to the primary - # service acount. the service account must remain the owner in - # order to be able to rename the folder - perms = [ - # edit permissions to codex account - withLink: false - role: 'writer' - type: 'user' - value: CODEX_ACCOUNT - , - # edit permissions for anyone with link - withLink: true - role: 'writer' - type: 'anyone' - ] - resp = apiThrottle drive.permissions, 'list', (fileId: id) - perms.forEach (p) -> - # does this permission already exist? - exists = resp.items.some (pp) -> samePerm(p, pp) - unless exists - apiThrottle drive.permissions, 'insert', - fileId: id - resource: p - 'ok' - -createPuzzle = (name) -> - folder = ensureFolder name, rootFolder - # is the spreadsheet already there? - spreadsheet = (apiThrottle drive.children, 'list', - folderId: folder.id - q: "title=#{quote WORKSHEET_NAME name} and mimeType=#{quote GDRIVE_SPREADSHEET_MIME_TYPE}" - maxResults: 1 - ).items[0] - unless spreadsheet? - # create an new spreadsheet from our template - spreadsheet = - title: WORKSHEET_NAME name - mimeType: XLSX_MIME_TYPE - parents: [id: folder.id] - spreadsheet = apiThrottle drive.files, 'insert', - convert: true - body: spreadsheet # this is only necessary due to bug in gapi, afaict - resource: spreadsheet - media: - mimeType: XLSX_MIME_TYPE - body: SPREADSHEET_TEMPLATE - ensurePermissions(spreadsheet.id) - return { - id: folder.id - spreadId: spreadsheet.id - } - -findPuzzle = (name) -> - resp = apiThrottle drive.children, 'list', - folderId: rootFolder - q: "title=#{quote name} and mimeType=#{quote GDRIVE_FOLDER_MIME_TYPE}" - maxResults: 1 - folder = resp.items[0] - return null unless folder? - # look for spreadsheet - resp = apiThrottle drive.children, 'list', - folderId: folder.id - q: "title=#{quote WORKSHEET_NAME name}" - maxResults: 1 - return { - id: folder.id - spreadId: resp.items[0]?.id - } - -listPuzzles = -> - results = [] - resp = {} - loop - resp = apiThrottle drive.children, 'list', - folderId: rootFolder - q: "mimeType=#{quote GDRIVE_FOLDER_MIME_TYPE}" - maxResults: MAX_RESULTS - pageToken: resp.nextPageToken - Array.prototype.push.apply(results, resp.items) - break unless resp.nextPageToken? - results - -renamePuzzle = (name, id, spreadId) -> - apiThrottle drive.files, 'patch', - fileId: id - resource: - title: name - if spreadId? - apiThrottle drive.files, 'patch', - fileId: spreadId - resource: - title: (WORKSHEET_NAME name) - 'ok' - -rmrfFolder = (id) -> - loop - resp = {} - loop - # delete subfolders - resp = apiThrottle drive.children, 'list', - folderId: id - q: "mimeType=#{quote GDRIVE_FOLDER_MIME_TYPE}" - maxResults: MAX_RESULTS - pageToken: resp.nextPageToken - resp.items.forEach (item) -> - rmrfFolder item.id - break unless resp.nextPageToken? - loop - # delete non-folder stuff - resp = apiThrottle drive.children, 'list', - folderId: id - q: "mimeType!=#{quote GDRIVE_FOLDER_MIME_TYPE}" - maxResults: MAX_RESULTS - pageToken: resp.nextPageToken - resp.items.forEach (item) -> - apiThrottle drive.files, 'delete', (fileId: item.id) - break unless resp.nextPageToken? - # are we done? look for remaining items owned by us - resp = apiThrottle drive.children, 'list', - folderId: id - q: "#{quote EMAIL} in owners" - maxResults: 1 - break if resp.items.length is 0 - # folder empty; delete the folder and we're done - apiThrottle drive.files, 'delete', (fileId: id) - 'ok' - -deletePuzzle = (id) -> rmrfFolder(id) - -# purge `rootFolder` and everything in it -purge = () -> rmrfFolder(rootFolder) - # Intialize APIs and load rootFolder do -> try @@ -238,36 +20,11 @@ do -> jwt = new Gapi.apis.auth.JWT(EMAIL, null, KEY, SCOPES) jwt.credentials = Gapi.authorize(jwt); # record the API and auth info - drive = Gapi.apis.drive('v2') + api = Gapi.apis.drive('v2') Gapi.registerAuth jwt - # Look up the root folder - resource = ensureFolder ROOT_FOLDER_NAME + share.drive = new Drive api console.log "Google Drive authorized and activated" - rootFolder = resource.id - # Create a special folder for uploads to ringhunters chat - resource = ensureFolder 'Ringhunters Uploads', rootFolder - ringhuntersFolder = resource.id - # for debugging/development - debug.drive = drive - debug.jwt = jwt catch error console.warn "Error trying to retrieve drive API:", error.__proto__ console.warn "Google Drive integration disabled." - drive = null - -# exports -share.drive = - # debugging/devel - debug: debug - ensureFolder: ensureFolder - ensurePermissions: ensurePermissions - # main stuff - createPuzzle: wrapCheck createPuzzle, 'createPuzzle' - deletePuzzle: wrapCheck deletePuzzle, 'deletePuzzle' - renamePuzzle: wrapCheck renamePuzzle, 'renamePuzzle' - listPuzzles: wrapCheck listPuzzles, 'listPuzzles' - findPuzzle: wrapCheck findPuzzle, 'findPuzzle' - purge: wrapCheck purge, 'purge' - # exported constants - rootFolder: rootFolder - ringhuntersFolder: ringhuntersFolder + share.drive = new FailDrive diff --git a/server/imports/drive.coffee b/server/imports/drive.coffee new file mode 100644 index 000000000..c672932c7 --- /dev/null +++ b/server/imports/drive.coffee @@ -0,0 +1,224 @@ +'use strict' + +# Drive folder settings +ROOT_FOLDER_NAME = Meteor.settings.folder or 'MIT Mystery Hunt 2014' +CODEX_ACCOUNT = 'zouchenuttall@gmail.com' +CODEX_HUMAN_NAME = 'Zouche Nuttall' +WORKSHEET_NAME = (name) -> "Worksheet: #{name}" + +# Constants +GDRIVE_FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder' +GDRIVE_SPREADSHEET_MIME_TYPE = 'application/vnd.google-apps.spreadsheet' +XLSX_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' +MAX_RESULTS = 200 +SPREADSHEET_TEMPLATE = Assets.getBinary 'spreadsheet-template.xlsx' + +quote = (str) -> "'#{str.replace(/([\'\\])/g, '\\$1')}'" + +samePerm = (p, pp) -> + (p.withLink or false) is (pp.withLink or false) and \ + p.role is pp.role and \ + p.type is pp.type and \ + if p.type is 'anyone' + true + else if ('value' of p) and ('value' of pp) + (p.value is pp.value) + else # hack! google doesn't return the full email address in the permission + (p.type is 'user' and p.value is CODEX_ACCOUNT and pp.name is CODEX_HUMAN_NAME) + +userRateExceeded = (error) -> + return false unless error.code == 403 + for subError in error.errors + if subError.domain is 'usageLimits' and subError.reason is 'userRateLimitExceeded' + return true + return false + +delays = [100, 250, 500, 1000, 2000, 5000, 10000] + +afterDelay = (ix, base, name, params, callback) -> + try + r = Gapi.exec base, name, params + callback null, r + catch error + if ix >= delays.length or not userRateExceeded(error) + callback error, null + return + console.warn "Rate limited for #{name}; Will retry after #{delays[ix]}ms" + later = -> + afterDelay ix+1, base, name, params, callback + Meteor.setTimeout later, delays[ix] + +apiThrottle = Meteor.wrapAsync (base, name, params, callback) -> + afterDelay 0, base, name, params, callback + +ensurePermissions = (drive, id) -> + # give permissions to both anyone with link and to the primary + # service acount. the service account must remain the owner in + # order to be able to rename the folder + perms = [ + # edit permissions to codex account + withLink: false + role: 'writer' + type: 'user' + value: CODEX_ACCOUNT + , + # edit permissions for anyone with link + withLink: true + role: 'writer' + type: 'anyone' + ] + resp = apiThrottle drive.permissions, 'list', fileId: id + perms.forEach (p) -> + # does this permission already exist? + exists = resp.items.some (pp) -> samePerm p, pp + unless exists + apiThrottle drive.permissions, 'insert', + fileId: id + resource: p + 'ok' + +spreadsheetSettings = + titleFunc: WORKSHEET_NAME + driveMimeType: GDRIVE_SPREADSHEET_MIME_TYPE + uploadMimeType: XLSX_MIME_TYPE + uploadTemplate: SPREADSHEET_TEMPLATE + +ensure = (drive, name, folder, settings) -> + doc = apiThrottle drive.children, 'list', + folderId: folder.id + q: "title=#{quote settings.titleFunc name} and mimeType=#{quote settings.driveMimeType}" + maxResults: 1 + .items[0] + unless doc? + doc = + title: settings.titleFunc name + mimeType: settings.uploadMimeType + parents: [id: folder.id] + doc = apiThrottle drive.files, 'insert', + convert: true + body: doc + resource: doc + media: + mimeType: settings.uploadMimeType + body: settings.uploadTemplate + ensurePermissions drive, doc.id + return doc + +ensureFolder = (drive, name, parent) -> + # check to see if the folder already exists + resp = apiThrottle drive.children, 'list', + folderId: parent or 'root' + q: "title=#{quote name}" + maxResults: 1 + if resp.items.length > 0 + resource = resp.items[0] + else + # create the folder + resource = + title: name + mimeType: GDRIVE_FOLDER_MIME_TYPE + resource.parents = [id: parent] if parent + resource = apiThrottle drive.files, 'insert', + resource: resource + # give the new folder the right permissions + ensurePermissions drive, resource.id + resource + +rmrfFolder = (drive, id) -> + resp = {} + loop + # delete subfolders + resp = apiThrottle drive.children, 'list', + folderId: id + q: "mimeType=#{quote GDRIVE_FOLDER_MIME_TYPE}" + maxResults: MAX_RESULTS + pageToken: resp.nextPageToken + resp.items.forEach (item) -> + rmrfFolder item.id + break unless resp.nextPageToken? + loop + # delete non-folder stuff + resp = apiThrottle drive.children, 'list', + folderId: id + q: "mimeType!=#{quote GDRIVE_FOLDER_MIME_TYPE}" + maxResults: MAX_RESULTS + pageToken: resp.nextPageToken + resp.items.forEach (item) -> + apiThrottle drive.files, 'delete', fileId: item.id + break unless resp.nextPageToken? + # folder empty; delete the folder and we're done + apiThrottle drive.files, 'delete', fileId: id + 'ok' + +export class Drive + constructor: (@drive) -> + @rootFolder = (ensureFolder @drive, ROOT_FOLDER_NAME).id + @ringhuntersFolder = (ensureFolder @drive, 'Ringhunters Uploads', @rootFolder).id + + createPuzzle: (name) -> + folder = ensureFolder @drive, name, @rootFolder + # is the spreadsheet already there? + spreadsheet = ensure @drive, name, folder, spreadsheetSettings + return { + id: folder.id + spreadId: spreadsheet.id + } + + findPuzzle: (name) -> + resp = apiThrottle @drive.children, 'list', + folderId: @rootFolder + q: "title=#{quote name} and mimeType=#{quote GDRIVE_FOLDER_MIME_TYPE}" + maxResults: 1 + folder = resp.items[0] + return null unless folder? + # TODO: batch these requests together. + # look for spreadsheet + spread = apiThrottle @drive.children, 'list', + folderId: folder.id + q: "title=#{quote WORKSHEET_NAME name}" + maxResults: 1 + return { + id: folder.id + spreadId: spread.items[0]?.id + } + + listPuzzles: -> + results = [] + resp = {} + loop + resp = apiThrottle @drive.children, 'list', + folderId: @rootFolder + q: "mimeType=#{quote GDRIVE_FOLDER_MIME_TYPE}" + maxResults: MAX_RESULTS + pageToken: resp.nextPageToken + results.push resp.items... + break unless resp.nextPageToken? + results + + renamePuzzle: (name, id, spreadId) -> + apiThrottle @drive.files, 'patch', + fileId: id + resource: + title: name + if spreadId? + apiThrottle @drive.files, 'patch', + fileId: spreadId + resource: + title: WORKSHEET_NAME name + 'ok' + + deletePuzzle: (id) -> rmrfFolder @drive, id + + # purge `rootFolder` and everything in it + purge: -> rmrfFolder @drive, rootFolder + +# generate functions +skip = (type) -> -> console.warn "Skipping Google Drive operation:", type + +export class FailDrive + createPuzzle: skip 'createPuzzle' + findPuzzle: skip 'findPuzzle' + listPuzzles: skip 'listPuzzles' + renamePuzzle: skip 'renamePuzzle' + deletePuzzle: skip 'deletePuzzle' + purge: skip 'purge' \ No newline at end of file diff --git a/server/imports/drive.test.coffee b/server/imports/drive.test.coffee new file mode 100644 index 000000000..a42f0ce5c --- /dev/null +++ b/server/imports/drive.test.coffee @@ -0,0 +1,330 @@ +'use strict' + +import chai from 'chai' +import sinon from 'sinon' +import { Drive } from './drive.coffee' + +GIVEN_OWNER_PERM = + withLink: false + role: 'writer' + type: 'user' + value: 'zouchenuttall@gmail.com' + +RECEIVED_OWNER_PERM = + withLink: false + role: 'writer' + type: 'user' + name: 'Zouche Nuttall' + +EVERYONE_PERM = + # edit permissions for anyone with link + withLink: true + role: 'writer' + type: 'anyone' +defaultPerms = [GIVEN_OWNER_PERM, EVERYONE_PERM] +receivedPerms = [RECEIVED_OWNER_PERM, EVERYONE_PERM] + +describe 'drive', -> + clock = null + api = null + gapi = null + + beforeEach -> + clock = sinon.useFakeTimers 7 + api = + children: 'children' + files: 'files' + permissions: 'permissions' + gapi = sinon.mock Gapi + + afterEach -> + sinon.verifyAndRestore() + + it 'propagates errors', -> + gapi.expects('exec').once().throws code: 400 + chai.assert.throws -> + new Drive api + + it 'creates folder', -> + gapi.expects('exec').withArgs api.children, 'list', sinon.match + folderId: 'root' + q: 'title=\'MIT Mystery Hunt 2014\'' + maxResults: 1 + .returns items: [] + gapi.expects('exec').withArgs api.files, 'insert', sinon.match + resource: + title: 'MIT Mystery Hunt 2014' + mimeType: 'application/vnd.google-apps.folder' + .returns + id: 'hunt' + title: 'MIT Mystery Hunt 2014' + mimeType: 'application/vnd.google-apps.folder' + gapi.expects('exec').withArgs api.permissions, 'list', sinon.match + fileId: 'hunt' + .returns items: [] + defaultPerms.forEach (perm) -> + gapi.expects('exec').withArgs api.permissions, 'insert', sinon.match + fileId: 'hunt' + resource: perm + gapi.expects('exec').withArgs api.children, 'list', sinon.match + folderId: 'hunt' + q: 'title=\'Ringhunters Uploads\'' + maxResults: 1 + .returns items: [] + gapi.expects('exec').withArgs api.files, 'insert', sinon.match + resource: + title: 'Ringhunters Uploads' + mimeType: 'application/vnd.google-apps.folder' + .returns + id: 'uploads' + title: 'Ringhunters Uploads' + mimeType: 'application/vnd.google-apps.folder' + gapi.expects('exec').withArgs api.permissions, 'list', sinon.match + fileId: 'uploads' + .returns items: [] + defaultPerms.forEach (perm) -> + gapi.expects('exec').withArgs api.permissions, 'insert', sinon.match + fileId: 'uploads' + resource: perm + new Drive api + + describe 'when folders already exist', -> + drive = null + beforeEach -> + gapi.expects('exec').withArgs api.children, 'list', sinon.match + folderId: 'root' + q: 'title=\'MIT Mystery Hunt 2014\'' + maxResults: 1 + .returns items: [ + id: 'hunt' + title: 'MIT Mystery Hunt 2014' + mimeType: 'application/vnd.google-apps.folder' + ] + gapi.expects('exec').withArgs api.permissions, 'list', sinon.match + fileId: 'hunt' + .returns items: receivedPerms + gapi.expects('exec').withArgs api.children, 'list', sinon.match + folderId: 'hunt' + q: 'title=\'Ringhunters Uploads\'' + maxResults: 1 + .returns items: [ + id: 'uploads' + title: 'Ringhunters Uploads' + mimeType: 'application/vnd.google-apps.folder' + parents: [id: 'hunt'] + ] + gapi.expects('exec').withArgs api.permissions, 'list', sinon.match + fileId: 'uploads' + .returns items: receivedPerms + drive = new Drive api + + it 'retries on throttle', -> + gapi.expects('exec').withArgs api.children, 'list', sinon.match + folderId: 'hunt' + q: 'title=\'New Puzzle\'' + maxResults: 1 + .exactly(8).callsFake -> + process.nextTick -> clock.next() + throw + code: 403 + errors: [ + domain: 'usageLimits' + reason: 'userRateLimitExceeded' + ] + chai.assert.throws -> + drive.createPuzzle 'New Puzzle' + + describe 'createPuzzle', -> + it 'creates', -> + gapi.expects('exec').withArgs api.children, 'list', sinon.match + folderId: 'hunt' + q: 'title=\'New Puzzle\'' + maxResults: 1 + .returns items: [] + gapi.expects('exec').withArgs api.files, 'insert', sinon.match + resource: + title: 'New Puzzle' + mimeType: 'application/vnd.google-apps.folder' + parents: sinon.match.some sinon.match id: 'hunt' + .returns + id: 'newpuzzle' + title: 'New Puzzle' + mimeType: 'application/vnd.google-apps.folder' + parents: [id: 'hunt'] + gapi.expects('exec').withArgs api.permissions, 'list', sinon.match + fileId: 'newpuzzle' + .returns items: [] + defaultPerms.forEach (perm) -> + gapi.expects('exec').withArgs api.permissions, 'insert', sinon.match + fileId: 'newpuzzle' + resource: perm + gapi.expects('exec').withArgs api.children, 'list', sinon.match + folderId: 'newpuzzle' + maxResults: 1 + q: "title='Worksheet: New Puzzle' and mimeType='application/vnd.google-apps.spreadsheet'" + .returns items: [] + sheet = sinon.match + title: 'Worksheet: New Puzzle' + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + parents: sinon.match.some sinon.match id: 'newpuzzle' + gapi.expects('exec').withArgs api.files, 'insert', sinon.match + body: sheet + resource: sheet + convert: true + media: sinon.match + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + body: sinon.match.instanceOf Uint8Array + .returns + id: 'newsheet' + title: 'Worksheet: New Puzzle' + mimeType: 'application/vnd.google-apps.spreadsheet' + parents: [id: 'newpuzzle'] + gapi.expects('exec').withArgs api.permissions, 'list', sinon.match + fileId: 'newsheet' + .returns items: [] + defaultPerms.forEach (perm) -> + gapi.expects('exec').withArgs api.permissions, 'insert', sinon.match + fileId: 'newsheet' + resource: perm + drive.createPuzzle 'New Puzzle' + + it 'returns existing', -> + gapi.expects('exec').withArgs api.children, 'list', sinon.match + folderId: 'hunt' + q: 'title=\'New Puzzle\'' + maxResults: 1 + .returns items: [ + id: 'newpuzzle' + title: 'New Puzzle' + mimeType: 'application/vnd.google-apps.folder' + parents: [id: 'hunt'] + ] + gapi.expects('exec').withArgs api.permissions, 'list', sinon.match + fileId: 'newpuzzle' + .returns items: receivedPerms + gapi.expects('exec').withArgs api.children, 'list', sinon.match + folderId: 'newpuzzle' + maxResults: 1 + q: "title='Worksheet: New Puzzle' and mimeType='application/vnd.google-apps.spreadsheet'" + .returns items: [ + id: 'newsheet' + title: 'Worksheet: New Puzzle' + mimeType: 'application/vnd.google-apps.spreadsheet' + parents: [id: 'newpuzzle'] + ] + gapi.expects('exec').withArgs api.permissions, 'list', sinon.match + fileId: 'newsheet' + .returns items: receivedPerms + drive.createPuzzle 'New Puzzle' + + describe 'findPuzzle', -> + it 'returns null when no puzzle', -> + gapi.expects('exec').withArgs api.children, 'list', sinon.match + folderId: 'hunt' + q: 'title=\'New Puzzle\' and mimeType=\'application/vnd.google-apps.folder\'' + maxResults: 1 + # pageToken: undefined + .returns items: [] + chai.assert.isNull drive.findPuzzle 'New Puzzle' + + it 'returns spreadsheet', -> + gapi.expects('exec').withArgs api.children, 'list', sinon.match + folderId: 'hunt' + q: 'title=\'New Puzzle\' and mimeType=\'application/vnd.google-apps.folder\'' + maxResults: 1 + # pageToken: undefined + .returns items: [ + id: 'newpuzzle' + title: 'New Puzzle' + mimeType: 'application/vnd.google-apps.folder' + parents: [id: 'hunt'] + ] + gapi.expects('exec').withArgs api.children, 'list', sinon.match + folderId: 'newpuzzle' + maxResults: 1 + q: "title='Worksheet: New Puzzle'" + .returns items: [ + id: 'newsheet' + title: 'Worksheet: New Puzzle' + mimeType: 'application/vnd.google-apps.spreadsheet' + parents: [id: 'newpuzzle'] + ] + chai.assert.deepEqual drive.findPuzzle('New Puzzle'), + id: 'newpuzzle' + spreadId: 'newsheet' + + it 'listPuzzles returns list', -> + item1 = + id: 'newpuzzle' + title: 'New Puzzle' + mimeType: 'application/vnd.google-apps.folder' + parents: [id: 'hunt'] + item2 = + id: 'oldpuzzle' + title: 'Old Puzzle' + mimeType: 'application/vnd.google-apps.folder' + parents: [id: 'hunt'] + gapi.expects('exec').withArgs api.children, 'list', sinon.match + folderId: 'hunt' + q: 'mimeType=\'application/vnd.google-apps.folder\'' + maxResults: 200 + # pageToken: undefined + .returns + items: [item1] + nextPageToken: 'token' + gapi.expects('exec').withArgs api.children, 'list', sinon.match + folderId: 'hunt' + q: 'mimeType=\'application/vnd.google-apps.folder\'' + maxResults: 200 + pageToken: 'token' + .returns + items: [item2] + chai.assert.sameDeepOrderedMembers drive.listPuzzles(), [item1, item2] + + it 'renamePuzzle renames', -> + gapi.expects('exec').withArgs api.files, 'patch', sinon.match + fileId: 'newpuzzle' + resource: sinon.match title: 'Old Puzzle' + gapi.expects('exec').withArgs api.files, 'patch', sinon.match + fileId: 'newsheet' + resource: sinon.match title: 'Worksheet: Old Puzzle' + drive.renamePuzzle 'Old Puzzle', 'newpuzzle', 'newsheet' + + it 'deletePuzzle deletes', -> + gapi.expects('exec').withArgs api.children, 'list', sinon.match + folderId: 'newpuzzle' + q: 'mimeType=\'application/vnd.google-apps.folder\'' + maxResults: 200 + .returns items: [] # Puzzles don't have folders + gapi.expects('exec').withArgs api.children, 'list', sinon.match + folderId: 'newpuzzle' + q: 'mimeType!=\'application/vnd.google-apps.folder\'' + maxResults: 200 + .returns + items: [ + id: 'newsheet' + title: 'Worksheet: New Puzzle' + mimeType: 'application/vnd.google-apps.spreadsheet' + parents: [id: 'newpuzzle'] + ] + nextPageToken: 'token' + gapi.expects('exec').withArgs api.files, 'delete', sinon.match + fileId: 'newsheet' + gapi.expects('exec').withArgs api.children, 'list', sinon.match + folderId: 'newpuzzle' + q: 'mimeType!=\'application/vnd.google-apps.folder\'' + maxResults: 200 + pageToken: 'token' + .returns + items: [ + id: 'newdoc' + title: 'Notes: New Puzzle' + mimeType: 'application/vnd.google-apps.document' + parents: [id: 'newpuzzle'] + ] + gapi.expects('exec').withArgs api.files, 'delete', sinon.match + fileId: 'newdoc' + gapi.expects('exec').withArgs api.files, 'delete', sinon.match + fileId: 'newpuzzle' + drive.deletePuzzle 'newpuzzle' + diff --git a/server/emoji.coffee b/server/imports/emoji.coffee similarity index 95% rename from server/emoji.coffee rename to server/imports/emoji.coffee index 1d9035afe..8dd5369d6 100644 --- a/server/emoji.coffee +++ b/server/imports/emoji.coffee @@ -14,6 +14,6 @@ db.default.forEach (entry) -> # on client-side to render these? But for server-side storage # and chat bandwidth, definitely better to have direct unicode # stored in the DB. -share.emojify = (s) -> +export default emojify = (s) -> s.replace /:([+]?[-a-z0-9_]+):/g, (full, name) -> emojiMap.get(name) or full diff --git a/server/emoji.json b/server/imports/emoji.json similarity index 100% rename from server/emoji.json rename to server/imports/emoji.json diff --git a/server/imports/emoji.test.coffee b/server/imports/emoji.test.coffee new file mode 100644 index 000000000..daff36c80 --- /dev/null +++ b/server/imports/emoji.test.coffee @@ -0,0 +1,9 @@ +import emojify from './emoji.coffee' +import chai from 'chai' + +describe 'emojify', -> + it 'replaces multiple emoji', -> + chai.assert.equal emojify(':wolf: in a :tophat:'), '🐺 in a 🎩' + + it 'ignores non-emoji', -> + chai.assert.equal emojify(':fox_face: :raccoon: :rabbit:'), '🦊 :raccoon: 🐰' From 041a02e4a708842ccd37eedeb8d42b4fb35e685d Mon Sep 17 00:00:00 2001 From: Dan Rosart Date: Sat, 11 Aug 2018 21:45:53 -0700 Subject: [PATCH 2/4] Fix some bad trailing newlines. --- lib/methods/cancelCallIn.test.coffee | 1 - lib/methods/incorrectCallIn.test.coffee | 2 +- lib/methods/newQuip.test.coffee | 4 ---- lib/methods/setPresence.test.coffee | 1 - server/imports/drive.coffee | 2 +- 5 files changed, 2 insertions(+), 8 deletions(-) diff --git a/lib/methods/cancelCallIn.test.coffee b/lib/methods/cancelCallIn.test.coffee index 4dc0640f5..1c2bb0989 100644 --- a/lib/methods/cancelCallIn.test.coffee +++ b/lib/methods/cancelCallIn.test.coffee @@ -54,4 +54,3 @@ describe 'cancelCallIn', -> it 'oplogs', -> chai.assert.lengthOf model.Messages.find({type: type, id: puzzle}).fetch(), 1 - \ No newline at end of file diff --git a/lib/methods/incorrectCallIn.test.coffee b/lib/methods/incorrectCallIn.test.coffee index 5e683d3c4..a7ca51faa 100644 --- a/lib/methods/incorrectCallIn.test.coffee +++ b/lib/methods/incorrectCallIn.test.coffee @@ -63,4 +63,4 @@ describe 'incorrectCallIn', -> chai.assert.lengthOf model.Messages.find(room_name: "#{type}/#{puzzle}").fetch(), 1 it "notifies general chat", -> - chai.assert.lengthOf model.Messages.find(room_name: 'general/0').fetch(), 1 \ No newline at end of file + chai.assert.lengthOf model.Messages.find(room_name: 'general/0').fetch(), 1 diff --git a/lib/methods/newQuip.test.coffee b/lib/methods/newQuip.test.coffee index 0335cfb76..32aa789c2 100644 --- a/lib/methods/newQuip.test.coffee +++ b/lib/methods/newQuip.test.coffee @@ -39,7 +39,3 @@ describe 'newQuip', -> it 'oplogs', -> chai.assert.lengthOf model.Messages.find({type: 'quips', id: id}).fetch(), 1 - - - - \ No newline at end of file diff --git a/lib/methods/setPresence.test.coffee b/lib/methods/setPresence.test.coffee index 86c0884bf..de52a422f 100644 --- a/lib/methods/setPresence.test.coffee +++ b/lib/methods/setPresence.test.coffee @@ -131,4 +131,3 @@ describe 'setPresence', -> present: true foreground: false foreground_uuid: '23456' - \ No newline at end of file diff --git a/server/imports/drive.coffee b/server/imports/drive.coffee index c672932c7..777a2ece5 100644 --- a/server/imports/drive.coffee +++ b/server/imports/drive.coffee @@ -221,4 +221,4 @@ export class FailDrive listPuzzles: skip 'listPuzzles' renamePuzzle: skip 'renamePuzzle' deletePuzzle: skip 'deletePuzzle' - purge: skip 'purge' \ No newline at end of file + purge: skip 'purge' From b884359e57c6452813fa6374730dbf7500d60b45 Mon Sep 17 00:00:00 2001 From: Dan Rosart Date: Sun, 9 Dec 2018 11:35:52 -0800 Subject: [PATCH 3/4] Update meteor test driver version --- .meteor/versions | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.meteor/versions b/.meteor/versions index cf1b8e0da..4cbdcd6c1 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -55,7 +55,8 @@ meteor@1.8.2 meteor-base@1.2.0 meteorhacks:picker@1.0.3 meteortesting:browser-tests@1.0.0 -meteortesting:mocha@1.0.0 +meteortesting:mocha@1.1.1 +meteortesting:mocha-core@1.0.1 minifier-css@1.2.16 minifier-js@2.2.2 minimongo@1.4.3 @@ -69,7 +70,6 @@ mongo-id@1.0.6 npm-mongo@2.2.33 observe-sequence@1.0.16 ordered-dict@1.0.9 -practicalmeteor:mocha-core@1.0.1 promise@0.10.0 random@1.0.10 reactive-dict@1.2.0 From 020b235ec569efea60fa99652f9de370b6568f3b Mon Sep 17 00:00:00 2001 From: Dan Rosart Date: Thu, 10 Jan 2019 23:57:17 -0800 Subject: [PATCH 4/4] Sort messags to archive by timestamp Rather than by who the message is to. --- server/batch.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/batch.coffee b/server/batch.coffee index 7400479bb..944d226b3 100644 --- a/server/batch.coffee +++ b/server/batch.coffee @@ -210,7 +210,7 @@ queueMessageArchive = throttle -> limit = 2 * MESSAGE_PAGE loop msgs = model.Messages.find({room_name: p.room_name, timestamp: $lt: p.to}, \ - {sort:[['to','asc']], limit: limit, reactive: false}).fetch() + {sort:[['timestamp','asc']], limit: limit, reactive: false}).fetch() model.OldMessages.upsert(m._id, m) for m in msgs model.Pages.update(p._id, $set: archived: true) if msgs.length < limit model.Messages.remove(m._id) for m in msgs