From 2ae5bcdeaf5df5c58716dcea8cc672afddef3f5c Mon Sep 17 00:00:00 2001 From: Agata-Andrzejewska Date: Tue, 10 Apr 2018 20:30:02 +0200 Subject: [PATCH 1/6] write common tests for ideas and challenges CRUD and make them work --- app.js | 1 + collections.js | 4 + controllers/authorize.js | 1 - controllers/dit-tags.js | 115 ++++ controllers/dits.js | 309 +++++++++++ controllers/goto/challenges.js | 16 + controllers/ideas.js | 2 - controllers/validators/challenge-tags.js | 9 + controllers/validators/challenges.js | 18 + controllers/validators/dit-tags.js | 0 .../validators/schema/challenge-tags.js | 52 ++ controllers/validators/schema/challenges.js | 222 ++++++++ controllers/validators/schema/definitions.js | 6 + controllers/validators/schema/ideas.js | 8 +- controllers/validators/schema/index.js | 3 +- controllers/validators/schema/paths.js | 1 + models/dit/index.js | 361 +++++++++++++ models/dit/schema.js | 6 + models/index.js | 3 +- routes/challenges.js | 64 +++ serializers/challenges.js | 71 +++ serializers/index.js | 5 +- test/dits.js | 507 ++++++++++++++++++ test/handle-database.js | 32 ++ 24 files changed, 1806 insertions(+), 10 deletions(-) create mode 100644 controllers/dit-tags.js create mode 100644 controllers/dits.js create mode 100644 controllers/goto/challenges.js create mode 100644 controllers/validators/challenge-tags.js create mode 100644 controllers/validators/challenges.js create mode 100644 controllers/validators/dit-tags.js create mode 100644 controllers/validators/schema/challenge-tags.js create mode 100644 controllers/validators/schema/challenges.js create mode 100644 models/dit/index.js create mode 100644 models/dit/schema.js create mode 100644 routes/challenges.js create mode 100644 serializers/challenges.js create mode 100644 test/dits.js diff --git a/app.js b/app.js index 839067a..cd9f786 100644 --- a/app.js +++ b/app.js @@ -71,6 +71,7 @@ app.use('/messages', require('./routes/messages')); app.use('/account', require('./routes/account')); app.use('/contacts', require('./routes/contacts')); app.use('/ideas', require('./routes/ideas')); +app.use('/challenges', require('./routes/challenges')); // vote for ideas, ... app.use('/ideas', require('./routes/votes')); app.use('/comments', require('./routes/votes')); diff --git a/collections.js b/collections.js index b460560..8fce9aa 100644 --- a/collections.js +++ b/collections.js @@ -78,6 +78,10 @@ module.exports = { type: 'document' }, + challenges: { + type: 'document' + }, + ideaTags: { type: 'edge', from: ['ideas'], diff --git a/controllers/authorize.js b/controllers/authorize.js index f49597d..e2fb04b 100644 --- a/controllers/authorize.js +++ b/controllers/authorize.js @@ -8,7 +8,6 @@ // Authorize only logged user function onlyLogged(req, res, next) { if (req.auth.logged === true) return next(); - return res.status(403).json({ errors: ['Not Authorized'] }); // TODO improve the error } diff --git a/controllers/dit-tags.js b/controllers/dit-tags.js new file mode 100644 index 0000000..8f49af0 --- /dev/null +++ b/controllers/dit-tags.js @@ -0,0 +1,115 @@ +'use strict'; + +const path = require('path'); + +const models = require(path.resolve('./models')), + serialize = require(path.resolve('./serializers')).serialize; + +/** + * Controller for POST /dits/:id/tags + * Adds a tag to a dit + */ +async function post(req, res, next) { + try { + // gather data from request + const { tagname } = req.body.tag; + const ditId = req.params.id; + const username = req.auth.username; + + // save new dit-tag to database + const newDitTag = await models.ditTag.create(ditId, tagname, { }, username); + + // serialize response body + const responseBody = serialize.ditTag(newDitTag); + + // respond + return res.status(201).json(responseBody); + } catch (e) { + + // handle errors + switch (e.code) { + // duplicate dit-tag + case 409: { + return res.status(409).end(); + } + // missing dit or tag or creator + case 404: { + const errors = e.missing.map(miss => ({ status: 404, detail: `${miss} not found`})); + return res.status(404).json({ errors }); + } + // dit creator is not me + case 403: { + return res.status(403).json({ errors: [ + { status: 403, detail: 'not logged in as dit creator' } + ]}); + } + // unexpected error + default: { + return next(e); + } + } + + } +} + +/** + * Read list of tags of dit + * GET /dits/:id/tags + */ +async function get(req, res, next) { + try { + // read dit id + const { id } = req.params; + + // read ditTags from database + const ditTags = await models.ditTag.readTagsOfDit(id); + + // serialize response body + const responseBody = serialize.ditTag(ditTags); + + // respond + return res.status(200).json(responseBody); + } catch (e) { + // error when idea doesn't exist + if (e.code === 404) { + return res.status(404).json({ errors: [{ + status: 404, + detail: '${ditType} not found' + }] }); + } + + // handle unexpected error + return next(e); + } +} + +/** + * Remove tag from idea + * DELETE /dits/:id/tags/:tagname + */ +async function del(req, res, next) { + try { + const { id, tagname } = req.params; + const { username } = req.auth; + + await models.ditTag.remove(id, tagname, username); + + return res.status(204).end(); + } catch (e) { + switch (e.code) { + case 404: { + return res.status(404).end(); + } + case 403: { + return res.status(403).json({ errors: [ + { status: 403, detail: 'not logged in as ${ditType} creator' } + ] }); + } + default: { + return next(e); + } + } + } +} + +module.exports = { post, get, del }; diff --git a/controllers/dits.js b/controllers/dits.js new file mode 100644 index 0000000..352f545 --- /dev/null +++ b/controllers/dits.js @@ -0,0 +1,309 @@ +'use strict'; + +const path = require('path'), + models = require(path.resolve('./models')), + serialize = require(path.resolve('./serializers')).serialize; + +/** + * Create dit + */ +async function post(req, res, next) { + try { + // gather data + const { title, detail, ditType } = req.body; + const creator = req.auth.username; + // save the idea to database + const newDit = await models.dit.create(ditType, { title, detail, creator }); + + // serialize the idea (JSON API) + let serializedDit; + switch(ditType){ + case 'idea': { + serializedDit = serialize.idea(newDit); + break; + } + case 'challenge': { + serializedDit = serialize.challenge(newDit); + break; + } + } + // respond + return res.status(201).json(serializedDit); + + } catch (e) { + return next(e); + } +} + +/** + * Read idea by id + */ +async function get(req, res, next) { + try { + // gather data + const { id } = req.params; + const { username } = req.auth; + const ditType = req.baseUrl.slice(1,-1); + + // read the idea from database + const dit = await models.dit.read(ditType, id); + + if (!dit) return res.status(404).json({ }); + + // see how many votes were given to dit and if/how logged user voted (0, -1, 1) + dit.votes = await models.vote.readVotesTo({ type: ditType+'s', id }); + dit.myVote = await models.vote.read({ from: username, to: { type: ditType+'s', id } }); + + // serialize the idea (JSON API) + let serializedDit; + switch(ditType){ + case 'idea': { + serializedDit = serialize.idea(dit); + break; + } + case 'challenge': { + serializedDit = serialize.challenge(dit); + break; + } + } + + // respond + return res.status(200).json(serializedDit); + + } catch (e) { + return next(e); + } +} + +/** + * Update idea's title or detail + * PATCH /ideas/:id + */ +async function patch(req, res, next) { + let ditType; + try { + // gather data + const { title, detail } = req.body; + const { id } = req.params; + ditType = req.baseUrl.slice(1,-1); + const { username } = req.auth; + + // update idea in database + const dit = await models.dit.update(ditType, id, { title, detail }, username); + + // serialize the idea (JSON API) + let serializedDit; + switch(ditType){ + case 'idea': { + serializedDit = serialize.idea(dit); + break; + } + case 'challenge': { + serializedDit = serialize.challenge(dit); + break; + } + } + + // respond + return res.status(200).json(serializedDit); + } catch (e) { + // handle errors + switch (e.code) { + case 403: { + return res.status(403).json({ + errors: [{ status: 403, detail: 'only creator can update' }] + }); + } + case 404: { + return res.status(404).json({ + errors: [{ status: 404, detail: `${ditType} not found` }] + }); + } + default: { + return next(e); + } + } + } +} + +/** + * Get ideas with my tags + */ +async function getDitsWithMyTags(req, res, next) { + try { + // gather data + const { username } = req.auth; + const { page: { offset = 0, limit = 10 } = { } } = req.query; + + // read the ideas from database + const foundIdeas = await models.idea.withMyTags(username, { offset, limit }); + + // serialize + const serializedIdeas = serialize.idea(foundIdeas); + + // respond + return res.status(200).json(serializedIdeas); + + } catch (e) { + return next(e); + } +} + +/** + * Get ideas with specified tags + */ +async function getDitsWithTags(req, res, next) { + try { + + // gather data + const { page: { offset = 0, limit = 10 } = { } } = req.query; + const { withTags: tagnames } = req.query.filter; + + // read the ideas from database + const foundIdeas = await models.idea.withTags(tagnames, { offset, limit }); + + // serialize + const serializedIdeas = serialize.idea(foundIdeas); + + // respond + return res.status(200).json(serializedIdeas); + + } catch (e) { + return next(e); + } +} + +/** + * Get new ideas + */ +async function getNewDits(req, res, next) { + try { + const { page: { offset = 0, limit = 5 } = { } } = req.query; + + // read ideas from database + const foundIdeas = await models.idea.findNew({ offset, limit }); + + // serialize + const serializedIdeas = serialize.idea(foundIdeas); + + // respond + return res.status(200).json(serializedIdeas); + + } catch (e) { + return next(e); + } +} + +/** + * Get random ideas + */ +async function getRandomDits(req, res, next) { + try { + const { page: { limit = 1 } = { } } = req.query; + + // read ideas from database + const foundIdeas = await models.idea.random({ limit }); + + // serialize + const serializedIdeas = serialize.idea(foundIdeas); + + // respond + return res.status(200).json(serializedIdeas); + + } catch (e) { + return next(e); + } +} + +/** + * Get ideas with specified creators + */ +async function getDitsWithCreators(req, res, next) { + try { + // gather data + const { page: { offset = 0, limit = 10 } = { } } = req.query; + const { creators } = req.query.filter; + + // read ideas from database + const foundIdeas = await models.idea.findWithCreators(creators, { offset, limit }); + + // serialize + const serializedIdeas = serialize.idea(foundIdeas); + + // respond + return res.status(200).json(serializedIdeas); + + } catch (e) { + return next(e); + } +} + +/** + * Get ideas commented by specified users + */ +async function getDitsCommentedBy(req, res, next) { + try { + // gather data + const { page: { offset = 0, limit = 10 } = { } } = req.query; + const { commentedBy } = req.query.filter; + + // read ideas from database + const foundIdeas = await models.idea.findCommentedBy(commentedBy, { offset, limit }); + + // serialize + const serializedIdeas = serialize.idea(foundIdeas); + + // respond + return res.status(200).json(serializedIdeas); + + } catch (e) { + return next(e); + } +} + +/** + * Get highly voted ideas with an optional parameter of minimum votes + */ +async function getDitsHighlyVoted(req, res, next) { + try { + // gather data + const { page: { offset = 0, limit = 5 } = { } } = req.query; + const { highlyVoted } = req.query.filter; + + // read ideas from database + const foundIdeas = await models.idea.findHighlyVoted(highlyVoted, { offset, limit }); + + // serialize + const serializedIdeas = serialize.idea(foundIdeas); + + // respond + return res.status(200).json(serializedIdeas); + + } catch (e) { + return next(e); + } +} + +/** + * Get trending ideas + */ +async function getDitsTrending(req, res, next) { + try { + // gather data + const { page: { offset = 0, limit = 5 } = { } } = req.query; + + // read ideas from database + const foundIdeas = await models.idea.findTrending({ offset, limit }); + + // serialize + const serializedIdeas = serialize.idea(foundIdeas); + + // respond + return res.status(200).json(serializedIdeas); + + } catch (e) { + return next(e); + } +} + +module.exports = { get, getDitsCommentedBy, getDitsHighlyVoted, getDitsTrending, getDitsWithCreators, getDitsWithMyTags, getDitsWithTags, getNewDits, getRandomDits, patch, post }; diff --git a/controllers/goto/challenges.js b/controllers/goto/challenges.js new file mode 100644 index 0000000..2dca1d3 --- /dev/null +++ b/controllers/goto/challenges.js @@ -0,0 +1,16 @@ +'use strict'; + +const route = require('./goto'); + +module.exports = { + get: { + withMyTags: route(['query.filter.withMyTags']), + withTags: route(['query.filter.withTags']), + new: route(['query.sort'], 'newQuery'), + random: route(['query.filter.random']), + withCreators: route(['query.filter.creators']), + commentedBy: route(['query.filter.commentedBy']), + highlyVoted: route(['query.filter.highlyVoted']), + trending: route(['query.filter.trending']) + }, +}; diff --git a/controllers/ideas.js b/controllers/ideas.js index ef5e1de..29bb4b8 100644 --- a/controllers/ideas.js +++ b/controllers/ideas.js @@ -16,10 +16,8 @@ async function post(req, res, next) { // save the idea to database const newIdea = await models.idea.create({ title, detail, creator }); - // serialize the idea (JSON API) const serializedIdea = serialize.idea(newIdea); - // respond return res.status(201).json(serializedIdea); diff --git a/controllers/validators/challenge-tags.js b/controllers/validators/challenge-tags.js new file mode 100644 index 0000000..c61fbcc --- /dev/null +++ b/controllers/validators/challenge-tags.js @@ -0,0 +1,9 @@ +'use strict'; + +const validate = require('./validate-by-schema'); + +const del = validate('deleteChallengeTag'); +const get = validate('getChallengeTags'); +const post = validate('postChallengeTags'); + +module.exports = { del, get, post }; diff --git a/controllers/validators/challenges.js b/controllers/validators/challenges.js new file mode 100644 index 0000000..b0e1a3d --- /dev/null +++ b/controllers/validators/challenges.js @@ -0,0 +1,18 @@ + +'use strict'; + +const validate = require('./validate-by-schema'); + +module.exports = { + get: validate('getChallenge'), + getChallengesCommentedBy: validate('getChallengesCommentedBy'), + getChallengesHighlyVoted: validate('getChallengesHighlyVoted'), + getChallengesTrending: validate('getChallengesTrending'), + getChallengesWithCreators: validate('getChallengesWithCreators'), + getChallengesWithMyTags: validate('getChallengesWithMyTags'), + getChallengesWithTags: validate('getChallengesWithTags'), + getNewChallenges: validate('getNewChallenges'), + getRandomChallenges: validate('getRandomChallenges'), + patch: validate('patchChallenge', [['params.id', 'body.id']]), + post: validate('postChallenges') +}; diff --git a/controllers/validators/dit-tags.js b/controllers/validators/dit-tags.js new file mode 100644 index 0000000..e69de29 diff --git a/controllers/validators/schema/challenge-tags.js b/controllers/validators/schema/challenge-tags.js new file mode 100644 index 0000000..38f5fad --- /dev/null +++ b/controllers/validators/schema/challenge-tags.js @@ -0,0 +1,52 @@ +'use strict'; + +const { id, tagname } = require('./paths'); + +const getChallengeTags = { + properties: { + params: { + properties: { id }, + required: ['id'], + additionalProperties: false + }, + }, + required: ['params'] +}; + +const postChallengeTags = { + properties: { + body: { + properties: { + tag: { + properties: { tagname }, + required: ['tagname'], + additionalProperties: false + } + }, + required: ['tag'], + additionalProperties: false + }, + params: { + properties: { id }, + required: ['id'], + additionalProperties: false + }, + }, + required: ['body', 'params'] +}; + +const deleteChallengeTag = { + properties: { + params: { + properties: { + id, + tagname + }, + required: ['id', 'tagname'], + additionalProperties: false + }, + }, + required: ['params'] +}; + +module.exports = { deleteChallengeTag, getChallengeTags, postChallengeTags }; diff --git a/controllers/validators/schema/challenges.js b/controllers/validators/schema/challenges.js new file mode 100644 index 0000000..ec8ba03 --- /dev/null +++ b/controllers/validators/schema/challenges.js @@ -0,0 +1,222 @@ +'use strict'; + +const { title, detail, ditType, id, page, pageOffset0, random, tagsList, usersList } = require('./paths'); + +const postChallenges = { + properties: { + body: { + properties: { + title, + detail, + ditType + }, + required: ['title', 'detail', 'ditType'], + additionalProperties: false + } + }, + required: ['body'] +}; + +const getChallenge = { + properties: { + params: { + properties: { id }, + required: ['id'], + additionalProperties: false + } + }, + required: ['params'] +}; + +const patchChallenge = { + properties: { + params: { + properties: { id }, + required: ['id'], + additionalProperties: false + }, + body: { + oneOf: [ + { + properties: { title, detail, id }, + required: ['title', 'detail', 'id'], + additionalProperties: false + }, + { + properties: { title, id }, + required: ['title', 'id'], + additionalProperties: false + }, + { + properties: { detail, id }, + required: ['detail', 'id'], + additionalProperties: false + } + ] + } + }, + required: ['body', 'params'] +}; + +const getChallengesWithMyTags = { + properties: { + query: { + properties: { + filter: { + properties: { + withMyTags: { + enum: [''] + } + }, + required: ['withMyTags'], + additionalProperties: false + }, + page + }, + required: ['filter'], + additionalProperties: false + }, + }, + required: ['query'] +}; + +const getChallengesWithTags = { + properties: { + query: { + properties: { + filter: { + properties: { + withTags: tagsList + }, + required: ['withTags'], + additionalProperties: false + }, + page + }, + required: ['filter'], + additionalProperties: false + } + }, + required: ['query'] +}; + +const getNewChallenges = { + properties: { + query: { + properties: { + sort: { + enum: ['-created'] + }, + page + }, + required: ['sort'], + additionalProperties: false + }, + }, + required: ['query'] +}; + +const getRandomChallenges = { + properties: { + query: { + properties: { + filter: { + properties: { random }, + required: ['random'], + additionalProperties: false + }, + page: pageOffset0 + }, + required: ['filter'], + additionalProperties: false + }, + }, + required: ['query'] +}; + +const getChallengesWithCreators = { + properties: { + query: { + properties: { + filter: { + properties: { + creators: usersList + }, + required: ['creators'], + additionalProperties: false + }, + page + }, + required: ['filter'], + additionalProperties: false + }, + }, + required: ['query'] +}; + +const getChallengesCommentedBy = { + properties: { + query: { + properties: { + filter: { + properties: { + commentedBy: usersList + }, + required: ['commentedBy'], + additionalProperties: false + }, + page + }, + required: ['filter'], + additionalProperties: false + }, + }, + required: ['query'] +}; + +const getChallengesHighlyVoted = { + properties: { + query: { + properties: { + filter: { + properties: { + highlyVoted: { + type: 'number' + } + }, + required: ['highlyVoted'], + additionalProperties: false + }, + page + }, + required: ['filter'], + additionalProperties: false + }, + }, + required: ['query'] +}; + +const getChallengesTrending = { + properties: { + query: { + properties: { + filter: { + properties: { + trending: { + type: 'string', + enum: [''] + } + }, + required: ['trending'], + additionalProperties: false + }, + page + }, + required: ['filter'], + additionalProperties: false + }, + }, + required: ['query'] +}; + +module.exports = { getChallenge, getChallengesCommentedBy, getChallengesHighlyVoted, getChallengesTrending, getChallengesWithCreators, getChallengesWithMyTags, getChallengesWithTags, getNewChallenges, getRandomChallenges, patchChallenge, postChallenges }; diff --git a/controllers/validators/schema/definitions.js b/controllers/validators/schema/definitions.js index ea820bd..016310d 100644 --- a/controllers/validators/schema/definitions.js +++ b/controllers/validators/schema/definitions.js @@ -118,6 +118,12 @@ module.exports = { maxLength: 2048 } }, + dit: { + ditType: { + type: 'string', + enum: ['idea', 'challenge'] + } + }, message: { body: { type: 'string', diff --git a/controllers/validators/schema/ideas.js b/controllers/validators/schema/ideas.js index 0699934..2f34022 100644 --- a/controllers/validators/schema/ideas.js +++ b/controllers/validators/schema/ideas.js @@ -1,14 +1,16 @@ 'use strict'; -const { title, detail, id, keywordsList, page, pageOffset0, random, tagsList, usersList } = require('./paths'); +const { title, detail, ditType, id, keywordsList, page, pageOffset0, random, tagsList, usersList } = require('./paths'); + const postIdeas = { properties: { body: { properties: { title, - detail + detail, + ditType }, - required: ['title', 'detail'], + required: ['title', 'detail', 'ditType'], additionalProperties: false } }, diff --git a/controllers/validators/schema/index.js b/controllers/validators/schema/index.js index a681495..13208cc 100644 --- a/controllers/validators/schema/index.js +++ b/controllers/validators/schema/index.js @@ -3,6 +3,7 @@ const account = require('./account'), authenticate = require('./authenticate'), avatar = require('./avatar'), + challenges = require('./challenges'), comments = require('./comments'), contacts = require('./contacts'), definitions = require('./definitions'), @@ -16,5 +17,5 @@ const account = require('./account'), votes = require('./votes'); -module.exports = Object.assign({ definitions }, account, authenticate, avatar, +module.exports = Object.assign({ definitions }, account, authenticate, avatar, challenges, comments, contacts, ideas, ideaTags, messages, params, tags, users, userTags, votes); diff --git a/controllers/validators/schema/paths.js b/controllers/validators/schema/paths.js index 2919065..fbc59fa 100644 --- a/controllers/validators/schema/paths.js +++ b/controllers/validators/schema/paths.js @@ -24,6 +24,7 @@ module.exports = { keywordsList: { $ref: 'sch#/definitions/query/keywordsList' }, ideaId: { $ref : 'sch#/definitions/idea/ideaId' }, title: { $ref: 'sch#/definitions/idea/titl' }, + ditType: { $ref: 'sch#/definitions/dit/ditType'}, detail: { $ref: 'sch#/definitions/idea/detail' }, content: { $ref: 'sch#/definitions/comment/content' }, id: { $ref: 'sch#/definitions/shared/objectId' }, diff --git a/models/dit/index.js b/models/dit/index.js new file mode 100644 index 0000000..c42eb3c --- /dev/null +++ b/models/dit/index.js @@ -0,0 +1,361 @@ +const _ = require('lodash'), + path = require('path'); + +const Model = require(path.resolve('./models/model')), + schema = require('./schema'); + +class Idea extends Model { + + /** + * Create an idea + */ + static async create(ditType, { title, detail, created, creator }) { + // create the idea + const dit = schema({ title, detail, created }); + const ditCollection = ditType + 's'; + const query = ` + FOR u IN users FILTER u.username == @creator + INSERT MERGE(@dit, { creator: u._id }) IN @@ditCollection + LET creator = MERGE(KEEP(u, 'username'), u.profile) + LET savedDit = MERGE(KEEP(NEW, 'title', 'detail', 'created'), { id: NEW._key }, { creator }) + RETURN savedDit`; + const params = { creator, dit, '@ditCollection': ditCollection }; + + const cursor = await this.db.query(query, params); + + const out = await cursor.all(); + + if (out.length !== 1) return null; + + return out[0]; + } + + /** + * Read the idea by id (_key in db). + */ + static async read(ditType, id) { + const ditCollection = ditType + 's'; + + const query = ` + FOR i IN @@ditCollection FILTER i._key == @id + LET creator = (FOR u IN users FILTER u._id == i.creator + RETURN MERGE(KEEP(u, 'username'), u.profile))[0] + RETURN MERGE(KEEP(i, 'title', 'detail', 'created'), { id: i._key}, { creator })`; + const params = { id, '@ditCollection': ditCollection }; + const cursor = await this.db.query(query, params); + const out = await cursor.all(); + return out[0]; + + } + + /** + * Update an idea + */ + static async update(ditType, id, newData, username) { + const dit = _.pick(newData, ['title', 'detail']); + const ditCollection = ditType + 's'; + + const query = ` + // read [user] + LET us = (FOR u IN users FILTER u.username == @username RETURN u) + // read [idea] + LET is = (FOR i IN @@ditCollection FILTER i._key == @id RETURN i) + // update idea if and only if user matches idea creator + LET newis = ( + FOR i IN is FOR u IN us FILTER u._id == i.creator + UPDATE i WITH @dit IN @@ditCollection + LET creator = MERGE(KEEP(u, 'username'), u.profile) + RETURN MERGE(KEEP(NEW, 'title', 'detail', 'created'), { id: NEW._key }, { creator }) + ) + // return old and new idea (to decide what is the error) + RETURN [is[0], newis[0]]`; + const params = { id, dit, username, '@ditCollection': ditCollection }; + const cursor = await this.db.query(query, params); + const [[oldDit, newDit]] = await cursor.all(); + + // if nothing was updated, throw error + if (!newDit) { + const e = new Error('not updated'); + // if old idea was found, then user doesn't have sufficient writing rights, + // otherwise idea not found + e.code = (oldDit) ? 403 : 404; + throw e; + } + + return newDit; + } + + /** + * Read ideas with my tags + */ + static async withMyTags(username, { offset, limit }) { + + const query = ` + // gather the ideas related to me + FOR me IN users FILTER me.username == @username + FOR t, ut IN 1..1 ANY me OUTBOUND userTag + FOR i IN 1..1 ANY t INBOUND ideaTags + LET relevance = ut.relevance + LET tg = KEEP(t, 'tagname') + SORT relevance DESC + // collect found tags together + COLLECT idea=i INTO collected KEEP relevance, tg + LET c = (DOCUMENT(idea.creator)) + LET creator = MERGE(KEEP(c, 'username'), c.profile) + // sort ideas by sum of relevances of related userTags + LET relSum = SUM(collected[*].relevance) + SORT relSum DESC + // format for output + LET ideaOut = MERGE(KEEP(idea, 'title', 'detail', 'created'), { id: idea._key}, { creator }) + LET tagsOut = collected[*].tg + // limit + LIMIT @offset, @limit + // respond + RETURN { idea: ideaOut, tags: tagsOut }`; + const params = { username, offset, limit }; + const cursor = await this.db.query(query, params); + const out = await cursor.all(); + + // generate idea-tags ids, and add them as attributes to each idea + // and return array of the ideas + return out.map(({ idea, tags }) => { + idea.ideaTags = tags.map(({ tagname }) => ({ + id: `${idea.id}--${tagname}`, + idea, + tag: { tagname } + })); + return idea; + }); + } + + /** + * Read ideas with tags + * @param {string[]} tagnames - list of tagnames to search with + * @param {integer} offset - pagination offset + * @param {integer} limit - pagination limit + * @returns {Promise} - list of found ideas + */ + static async withTags(tagnames, { offset, limit }) { + const query = ` + // find the provided tags + FOR t IN tags FILTER t.tagname IN @tagnames + SORT t.tagname + LET tg = KEEP(t, 'tagname') + // find the related ideas + FOR i IN 1..1 ANY t INBOUND ideaTags + // collect tags of each idea together + COLLECT idea=i INTO collected KEEP tg + // sort ideas by amount of matched tags, and from oldest + SORT LENGTH(collected) DESC, idea.created ASC + // read and format creator + LET c = (DOCUMENT(idea.creator)) + LET creator = MERGE(KEEP(c, 'username'), c.profile) + // format for output + LET ideaOut = MERGE(KEEP(idea, 'title', 'detail', 'created'), { id: idea._key}, { creator }) + LET tagsOut = collected[*].tg + // limit + LIMIT @offset, @limit + // respond + RETURN { idea: ideaOut, tags: tagsOut }`; + const params = { tagnames, offset, limit }; + const cursor = await this.db.query(query, params); + const out = await cursor.all(); + + // generate idea-tags ids, and add them as attributes to each idea + // and return array of the ideas + return out.map(({ idea, tags }) => { + idea.ideaTags = tags.map(({ tagname }) => ({ + id: `${idea.id}--${tagname}`, + idea, + tag: { tagname } + })); + return idea; + }); + } + + /** + * Read new ideas + */ + static async findNew({ offset, limit }) { + + const query = ` + FOR idea IN ideas + // sort from newest + SORT idea.created DESC + LIMIT @offset, @limit + // find creator + LET c = (DOCUMENT(idea.creator)) + LET creator = MERGE(KEEP(c, 'username'), c.profile) + // format for output + LET ideaOut = MERGE(KEEP(idea, 'title', 'detail', 'created'), { id: idea._key}, { creator }) + // limit + // respond + RETURN ideaOut`; + const params = { offset, limit }; + const cursor = await this.db.query(query, params); + return await cursor.all(); + } + + /** + * Read random ideas + * @param {number} [limit] - max amount of random ideas to return + */ + static async random({ limit }) { + + const query = ` + FOR idea IN ideas + // sort from newest + SORT RAND() + LIMIT @limit + // find creator + LET c = (DOCUMENT(idea.creator)) + LET creator = MERGE(KEEP(c, 'username'), c.profile) + // format for output + LET ideaOut = MERGE(KEEP(idea, 'title', 'detail', 'created'), { id: idea._key}, { creator }) + // limit + // respond + RETURN ideaOut`; + const params = { limit }; + const cursor = await this.db.query(query, params); + return await cursor.all(); + } + + /** + * Read ideas with specified creators + * @param {string[]} usernames - list of usernames to search with + * @param {integer} offset - pagination offset + * @param {integer} limit - pagination limit + * @returns {Promise} - list of found ideas + */ + static async findWithCreators(creators, { offset, limit }) { + // TODO to be checked for query optimization or unnecessary things + const query = ` + LET creators = (FOR u IN users FILTER u.username IN @creators RETURN u) + FOR idea IN ideas FILTER idea.creator IN creators[*]._id + // find creator + LET c = (DOCUMENT(idea.creator)) + // format for output + LET creator = MERGE(KEEP(c, 'username'), c.profile) + LET ideaOut = MERGE(KEEP(idea, 'title', 'detail', 'created'), { id: idea._key}, { creator }) + // sort from newest + SORT idea.created DESC + // limit + LIMIT @offset, @limit + // respond + RETURN ideaOut`; + + const params = { offset, limit , creators }; + const cursor = await this.db.query(query, params); + return await cursor.all(); + } + + + /** + * Read ideas commented by specified users + * @param {string[]} usernames - list of usernames to search with + * @param {integer} offset - pagination offset + * @param {integer} limit - pagination limit + * @returns {Promise} - list of found ideas + */ + static async findCommentedBy(commentedBy, { offset, limit }) { + + const query = ` + FOR user IN users + FILTER user.username IN @commentedBy + FOR comment IN comments + FILTER comment.creator == user._id + AND IS_SAME_COLLECTION('ideas', comment.primary) + FOR idea IN ideas + FILTER idea._id == comment.primary + COLLECT i = idea + // sort from newest + SORT i.created DESC + LIMIT @offset, @limit + RETURN i`; + + const params = { commentedBy, offset, limit }; + const cursor = await this.db.query(query, params); + return await cursor.all(); + } + + + /** + * Read highly voted ideas + * @param {string[]} voteSumBottomLimit - minimal query voteSum + * @param {integer} offset - pagination offset + * @param {integer} limit - pagination limit + * @returns {Promise} - list of found ideas + */ + static async findHighlyVoted(voteSumBottomLimit, { offset, limit }) { + const query = ` + FOR idea IN ideas + LET ideaVotes = (FOR vote IN votes FILTER idea._id == vote._to RETURN vote) + // get sum of each idea's votes values + LET voteSum = SUM(ideaVotes[*].value) + // set bottom limit of voteSum + FILTER voteSum >= @voteSumBottomLimit + // find creator + LET c = (DOCUMENT(idea.creator)) + LET creator = MERGE(KEEP(c, 'username'), c.profile) + LET ideaOut = MERGE(KEEP(idea, 'title', 'detail', 'created'), { id: idea._key}, { creator }, { voteSum }) + + // sort by amount of votes + SORT ideaOut.voteSum DESC, ideaOut.created DESC + LIMIT @offset, @limit + RETURN ideaOut`; + + const params = { voteSumBottomLimit, offset, limit }; + const cursor = await this.db.query(query, params); + return await cursor.all(); + } + + + /** + * Read trending ideas + * @param {integer} offset - pagination offset + * @param {integer} limit - pagination limit + * @returns {Promise} - list of found ideas + */ + static async findTrending({ offset, limit }) { + const now = Date.now(); + const oneWeek = 604800000; // 1000 * 60 * 60 * 24 * 7 + const threeWeeks = 1814400000; // 1000 * 60 * 60 * 24 * 21 + const threeMonths = 7776000000; // 1000 * 60 * 60 * 24 * 90 + const weekAgo = now - oneWeek; + const threeWeeksAgo = now - threeWeeks; + const threeMonthsAgo = now - threeMonths; + + // for each idea we are counting 'rate' + // rate is the sum of votes/day in the last three months + // votes/day from last week are taken with wage 3 + // votes/day from two weeks before last week are taken with wage 2 + // votes/day from the rest of days are taken with wage 1 + const query = ` + FOR idea IN ideas + FOR vote IN votes + FILTER idea._id == vote._to + // group by idea id + COLLECT id = idea + // get sum of each idea's votes values from last week, last three weeks and last three months + AGGREGATE rateWeek = SUM((vote.value * TO_NUMBER( @weekAgo <= vote.created))/7), + rateThreeWeeks = SUM((vote.value * TO_NUMBER( @threeWeeksAgo <= vote.created && vote.created <= @weekAgo))/14), + rateThreeMonths = SUM((vote.value * TO_NUMBER( @threeMonthsAgo <= vote.created && vote.created <= @threeWeeksAgo))/69) + // find creator + LET c = (DOCUMENT(id.creator)) + LET creator = MERGE(KEEP(c, 'username'), c.profile) + LET ideaOut = MERGE(KEEP(id, 'title', 'detail', 'created'), { id: id._key}, { creator }) + LET rates = 3*rateWeek + 2*rateThreeWeeks + rateThreeMonths + FILTER rates > 0 + // sort by sum of rates + SORT rates DESC + LIMIT @offset, @limit + RETURN ideaOut`; + + const params = { weekAgo, threeWeeksAgo, threeMonthsAgo, offset, limit }; + const cursor = await this.db.query(query, params); + return await cursor.all(); + } +} + + +module.exports = Idea; diff --git a/models/dit/schema.js b/models/dit/schema.js new file mode 100644 index 0000000..fbcd484 --- /dev/null +++ b/models/dit/schema.js @@ -0,0 +1,6 @@ +'use strict'; + +module.exports = function ({ title, detail, created = Date.now() }) { + return { title, detail, created }; +}; + diff --git a/models/index.js b/models/index.js index 58013cc..51b0a0a 100644 --- a/models/index.js +++ b/models/index.js @@ -2,6 +2,7 @@ const comment = require('./comment'), contact = require('./contact'), + dit = require('./dit'), idea = require('./idea'), ideaTag = require('./idea-tag'), message = require('./message'), @@ -22,4 +23,4 @@ const models = { } }; -module.exports = Object.assign(models, { comment, contact, idea, ideaTag, message, model, tag, user, userTag, vote }); +module.exports = Object.assign(models, { comment, contact, dit, idea, ideaTag, message, model, tag, user, userTag, vote }); diff --git a/routes/challenges.js b/routes/challenges.js new file mode 100644 index 0000000..a11c484 --- /dev/null +++ b/routes/challenges.js @@ -0,0 +1,64 @@ +'use strict'; + +const express = require('express'), + path = require('path'), + router = express.Router(); + +const authorize = require(path.resolve('./controllers/authorize')), + ditControllers = require(path.resolve('./controllers/dits')), + ditTagControllers = require(path.resolve('./controllers/dit-tags')), + challengeValidators = require(path.resolve('./controllers/validators/challenges')), + challengeTagValidators = require(path.resolve('./controllers/validators/challenge-tags')), + { parse } = require(path.resolve('./controllers/validators/parser')), + // TODO seems quite hard to deal with it right now + go = require(path.resolve('./controllers/goto/challenges')); + +router.route('/') + // post a new challenge + .post(authorize.onlyLogged, challengeValidators.post, ditControllers.post); + +// get challenges with my tags +router.route('/') + .get(go.get.withMyTags, authorize.onlyLogged, parse, challengeValidators.getChallengesWithMyTags, ditControllers.getDitsWithMyTags); + +// get new challenges +router.route('/') + .get(go.get.new, authorize.onlyLogged, parse, challengeValidators.getNewChallenges, ditControllers.getNewDits); + +// get challenges with specified tags +router.route('/') + .get(go.get.withTags, authorize.onlyLogged, parse, challengeValidators.getChallengesWithTags, ditControllers.getDitsWithTags); + +// get random challenges +router.route('/') + .get(go.get.random, authorize.onlyLogged, parse, challengeValidators.getRandomChallenges, ditControllers.getRandomDits); + +// get challenges with creators +router.route('/') + .get(go.get.withCreators, authorize.onlyLogged, parse, challengeValidators.getChallengesWithCreators, ditControllers.getDitsWithCreators); + +// get challenges commented by specified users +router.route('/') + .get(go.get.commentedBy, authorize.onlyLogged, parse, challengeValidators.getChallengesCommentedBy, ditControllers.getDitsCommentedBy); + +// get challenges commented by specified users +router.route('/') + .get(go.get.highlyVoted, authorize.onlyLogged, parse, challengeValidators.getChallengesHighlyVoted, ditControllers.getDitsHighlyVoted); + +// get trending challenges +router.route('/') + .get(go.get.trending, authorize.onlyLogged, parse, challengeValidators.getChallengesTrending, ditControllers.getDitsTrending); + +router.route('/:id') + // read challenge by id + .get(authorize.onlyLogged, challengeValidators.get, ditControllers.get) + .patch(authorize.onlyLogged, challengeValidators.patch, ditControllers.patch); + +router.route('/:id/tags') + .post(authorize.onlyLogged, challengeTagValidators.post, ditTagControllers.post) + .get(authorize.onlyLogged, challengeTagValidators.get, ditTagControllers.get); + +router.route('/:id/tags/:tagname') + .delete(authorize.onlyLogged, challengeTagValidators.del, ditTagControllers.del); + +module.exports = router; diff --git a/serializers/challenges.js b/serializers/challenges.js new file mode 100644 index 0000000..c46526d --- /dev/null +++ b/serializers/challenges.js @@ -0,0 +1,71 @@ +'use strict'; + +const path = require('path'), + Serializer = require('jsonapi-serializer').Serializer; + +const config = require(path.resolve('./config')); + +const challengeSerializer = new Serializer('challenges', { + id: 'id', + attributes: ['title', 'detail', 'created', 'creator', 'challengeTags'], + keyForAttribute: 'camelCase', + typeForAttribute(attribute) { + if (attribute === 'creator') { + return 'users'; + } + if (attribute === 'challengeTags') return 'challenge-tags'; + }, + creator: { + ref: 'username', + type: 'users', + attributes: ['username', 'givenName', 'familyName', 'description'], + includedLinks: { + self: (data, { username }) => `${config.url.all}/users/${username}` + }, + relationshipLinks: { + related: (data, { username }) => `${config.url.all}/users/${username}` + } + }, + // when we want to have challengeTags as relationships + challengeTags: { + ref: 'id', + attributes: ['challenge', 'tag', ''], + typeForAttribute(attribute) { + if (attribute === 'creator') { + return 'users'; + } + }, + relationshipLinks: { }, + includedLinks: { }, + // relationships + challenge: { + ref: 'id' + }, + tag: { + ref: 'tagname' + } + }, + dataMeta: { + votesUp(record, current) { + if (!current.votes) return; + return current.votes.filter(vote => vote.value === 1).length; + }, + votesDown(record, current) { + if (!current.votes) return; + return current.votes.filter(vote => vote.value === -1).length; + }, + myVote(record, current) { + if (!current.hasOwnProperty('myVote')) return; + return (current.myVote) ? current.myVote.value : 0; + }, + voteSum(record, current) { + return current.voteSum; + } + } +}); + +function challenge(data) { + return challengeSerializer.serialize(data); +} + +module.exports = { challenge }; diff --git a/serializers/index.js b/serializers/index.js index dc8fdd8..57b117f 100644 --- a/serializers/index.js +++ b/serializers/index.js @@ -2,7 +2,8 @@ const Deserializer = require('jsonapi-serializer').Deserializer; -const comments = require('./comments'), +const challenges = require('./challenges'), + comments = require('./comments'), contacts = require('./contacts'), ideas = require('./ideas'), ideaTags = require('./idea-tags'), @@ -44,6 +45,6 @@ function deserialize(req, res, next) { } module.exports = { - serialize: Object.assign({ }, comments, contacts, ideas, ideaTags, messages, tags, users, votes), + serialize: Object.assign({ }, challenges, comments, contacts, ideas, ideaTags, messages, tags, users, votes), deserialize }; diff --git a/test/dits.js b/test/dits.js new file mode 100644 index 0000000..b241177 --- /dev/null +++ b/test/dits.js @@ -0,0 +1,507 @@ +'use strict'; + +const path = require('path'), + should = require('should'); + +const agentFactory = require('./agent'), + dbHandle = require('./handle-database'), + models = require(path.resolve('./models')); + +/* + Tests for functionalities common for sll of the dits. + Those are: ideas, challenges + */ + +testDitsCommonFunctionalities('idea'); +testDitsCommonFunctionalities('challenge'); + +/* + Function takes type of a dit as an argument + and runs all of the test +*/ +function testDitsCommonFunctionalities(dit){ + describe('dits', () => { + let agent, + dbData, + loggedUser; + + afterEach(async () => { + await dbHandle.clear(); + }); + + beforeEach(() => { + agent = agentFactory(); + }); + + describe(`POST /${dit}s`, () => { + let newDitBody; + + beforeEach(() => { + newDitBody = { data: { + type: `${dit}s`, + attributes: { + title: `A testing ${dit} 1`, + detail: `This is a testing ${dit} detail.`, + ditType: `${dit}` + } + } }; + }); + + // put pre-data into database + beforeEach(async () => { + const data = { + users: 3, // how many users to make + verifiedUsers: [0, 1] // which users to make verified + }; + // create data in database + dbData = await dbHandle.fill(data); + + loggedUser = dbData.users[0]; + }); + + context('logged in', () => { + + beforeEach(() => { + agent = agentFactory.logged(loggedUser); + }); + + context('valid data', () => { + + it(`should create ${dit} and respond with 201`, async () => { + const response = await agent + .post(`/${dit}s`) + .send(newDitBody) + .expect(201) + .expect('Content-Type', /^application\/vnd\.api\+json/); + + // respond with the new dit + const newDitResponseBody = response.body; + // is response body correct? + should(newDitResponseBody).match({ + data: { + type: `${dit}s`, + attributes: { + title: `A testing ${dit} 1`, + detail: `This is a testing ${dit} detail.` + } + } + }); + + should(newDitResponseBody).have.propertyByPath('data', 'id'); + should(newDitResponseBody).have.propertyByPath('data', 'attributes', 'created'); + + // is the new dit saved in database? + const newDitDb = await models.dit.read(`${dit}`, response.body.data.id); + // does the dit id equal the dit key in database? + should(newDitDb.id).eql(response.body.data.id); + + // data should contain creator as relationship + should(newDitResponseBody) + .have.propertyByPath('data', 'relationships', 'creator') + .match({ + data: { + type: 'users', id: loggedUser.username + } + }); + }); + + }); + + context('invalid data', () => { + + it(`[empty ${dit} title] 400`, async () => { + // invalid body + newDitBody.data.attributes.title = ' '; + + await agent + .post(`/${dit}s`) + .send(newDitBody) + .expect(400) + .expect('Content-Type', /^application\/vnd\.api\+json/); + }); + + it(`[too long ${dit} title] 400`, async () => { + // invalid body + newDitBody.data.attributes.title = 'a'.repeat(257); + + await agent + .post(`/${dit}s`) + .send(newDitBody) + .expect(400) + .expect('Content-Type', /^application\/vnd\.api\+json/); + }); + + it(`[missing ${dit} title] 400`, async () => { + // invalid body + delete newDitBody.data.attributes.title; + + await agent + .post(`/${dit}s`) + .send(newDitBody) + .expect(400) + .expect('Content-Type', /^application\/vnd\.api\+json/); + }); + + it(`[too long ${dit} detail] 400`, async () => { + // invalid body + newDitBody.data.attributes.detail = 'a'.repeat(2049); + + await agent + .post(`/${dit}s`) + .send(newDitBody) + .expect(400) + .expect('Content-Type', /^application\/vnd\.api\+json/); + }); + + it(`[missing ${dit} detail] 400`, async () => { + // invalid body + delete newDitBody.data.attributes.detail; + + await agent + .post(`/${dit}s`) + .send(newDitBody) + .expect(400) + .expect('Content-Type', /^application\/vnd\.api\+json/); + }); + + it('[unexpected property] 400', async () => { + // invalid body + newDitBody.data.attributes.unexpected = 'asdf'; + + await agent + .post(`/${dit}s`) + .send(newDitBody) + .expect(400) + .expect('Content-Type', /^application\/vnd\.api\+json/); + }); + + it('[XSS in body] sanitize', async () => { + // body with XSS + newDitBody.data.attributes.detail = ` + foo + italic + bar + `; + const response = await agent + .post(`/${dit}s`) + .send(newDitBody) + .expect(201) + .expect('Content-Type', /^application\/vnd\.api\+json/); + + // respond with the new dit + const newDitResponseBody = response.body; + + should(newDitResponseBody).have.propertyByPath('data', 'attributes', 'detail').eql(`italic + bar`); + + }); + }); + }); + + context('not logged in', () => { + it('should say 403 Forbidden', async () => { + await agent + .post(`/${dit}s`) + .send(newDitBody) + .expect(403) + .expect('Content-Type', /^application\/vnd\.api\+json/); + }); + }); + }); + + describe(`GET /${dit}s/:id`, () => { + + let dit0; + + beforeEach(async () => { + const data = { + users: 3, // how many users to make + verifiedUsers: [0, 1], // which users to make verified + [`${dit}s`]: [[{}, 0]] + }; + // create data in database + dbData = await dbHandle.fill(data); + loggedUser = dbData.users[0]; + dit0 = dbData[`${dit}s`][0]; + }); + + context('logged', () => { + + beforeEach(() => { + agent = agentFactory.logged(loggedUser); + }); + + context('valid', () => { + it(`[exists] read ${dit} by id`, async () => { + const response = await agent + .get(`/${dit}s/${dit0.id}`) + .expect(200) + .expect('Content-Type', /^application\/vnd\.api\+json/); + + should(response.body).match({ + data: { + type: `${dit}s`, + id: dit0.id, + attributes: { + title: dit0.title, + detail: dit0.detail + }, + relationships: { + creator: { + data: { type: 'users', id: dit0.creator.username } + } + } + } + }); + }); + + it('[not exist] 404', async () => { + await agent + .get(`/${dit}s/0013310`) + .expect(404) + .expect('Content-Type', /^application\/vnd\.api\+json/); + }); + + }); + + context('invalid', () => { + it('[invalid id] 400', async () => { + await agent + .get(`/${dit}s/invalid-id`) + .expect(400) + .expect('Content-Type', /^application\/vnd\.api\+json/); + }); + }); + }); + + context('not logged', () => { + it('403', async () => { + await agent + .get(`/${dit}s/${dit0.id}`) + .expect(403) + .expect('Content-Type', /^application\/vnd\.api\+json/); + }); + }); + }); + + describe(`PATCH /${dit}s/:id`, () => { + + let dit0, + dit1, + patchBody; + + beforeEach(async () => { + const data = { + users: 3, // how many users to make + verifiedUsers: [0, 1], // which users to make verified + [`${dit}s`]: [[{ }, 0], [{ }, 1]] + }; + // create data in database + dbData = await dbHandle.fill(data); + + loggedUser = dbData[`${dit}s`][0]; + [dit0, dit1] = dbData[`${dit}s`]; + }); + + beforeEach(() => { + patchBody = { + data: { + type: `${dit}s`, + id: dit0.id, + attributes: { + title: 'this is a new title', + detail: 'this is a new detail' + } + } + }; + }); + + context('logged', () => { + + beforeEach(() => { + agent = agentFactory.logged(loggedUser); + }); + + context('valid', () => { + it(`[${dit} exists, creator, title] 200 and update in db`, async () => { + delete patchBody.data.attributes.detail; + + const { title } = patchBody.data.attributes; + const { id, detail } = dit0; + const response = await agent + .patch(`/${dit}s/${id}`) + .send(patchBody) + .expect(200); + + should(response.body).match({ + data: { + type: `${dit}s`, + id, + attributes: { title, detail } + } + }); + + const ditDb = await models.dit.read(`${dit}`, dit0.id); + + should(ditDb).match({ id, title, detail }); + }); + + it(`[${dit} exists, creator, detail] 200 and update in db`, async () => { + delete patchBody.data.attributes.title; + + const { detail } = patchBody.data.attributes; + const { id, title } = dit0; + + const response = await agent + .patch(`/${dit}s/${id}`) + .send(patchBody) + .expect(200); + + should(response.body).match({ + data: { + type: `${dit}s`, + id, + attributes: { title, detail } + } + }); + + const ditDb = await models.dit.read(`${dit}`, dit0.id); + + should(ditDb).match({ id, title, detail }); + }); + + it(`[${dit} exists, creator, title, detail] 200 and update in db`, async () => { + const { title, detail } = patchBody.data.attributes; + const { id } = dit0; + + const response = await agent + .patch(`/${dit}s/${id}`) + .send(patchBody) + .expect(200); + + should(response.body).match({ + data: { + type: `${dit}s`, + id, + attributes: { title, detail } + } + }); + + const ditDb = await models.dit.read(`${dit}`, dit0.id); + should(ditDb).match({ id, title, detail }); + }); + + it(`[${dit} exists, not creator] 403`, async () => { + patchBody.data.id = dit1.id; + + const response = await agent + .patch(`/${dit}s/${dit1.id}`) + .send(patchBody) + .expect(403); + + should(response.body).match({ + errors: [{ + status: 403, + detail: 'only creator can update' + }] + }); + }); + + it(`[${dit} not exist] 404`, async () => { + patchBody.data.id = '00011122'; + + const response = await agent + .patch(`/${dit}s/00011122`) + .send(patchBody) + .expect(404); + + should(response.body).match({ + errors: [{ + status: 404, + detail: `${dit} not found` + }] + }); + }); + }); + + context('invalid', () => { + it(`[invalid ${dit} id] 400`, async () => { + patchBody.data.id = 'invalid-id'; + + await agent + .patch(`/${dit}s/invalid-id`) + .send(patchBody) + .expect(400); + }); + + it('[id in body doesn\'t equal id in params] 400', async () => { + patchBody.data.id = '00011122'; + + await agent + .patch(`/${dit}s/${dit0.id}`) + .send(patchBody) + .expect(400); + }); + + it('[invalid title] 400', async () => { + patchBody.data.attributes.title = ' '; + + await agent + .patch(`/${dit}s/${dit0.id}`) + .send(patchBody) + .expect(400); + }); + + it('[invalid detail] 400', async () => { + patchBody.data.attributes.detail = '.'.repeat(2049); + + await agent + .patch(`/${dit}s/${dit0.id}`) + .send(patchBody) + .expect(400); + }); + + it('[not title nor detail (nothing to update)] 400', async () => { + delete patchBody.data.attributes.title; + delete patchBody.data.attributes.detail; + + await agent + .patch(`/${dit}s/${dit0.id}`) + .send(patchBody) + .expect(400); + }); + + it('[unexpected attribute] 400', async () => { + patchBody.data.attributes.foo = 'bar'; + + await agent + .patch(`/${dit}s/${dit0.id}`) + .send(patchBody) + .expect(400); + }); + }); + }); + + context('not logged', () => { + it('403', async () => { + const response = await agent + .patch(`/${dit}s/${dit0.id}`) + .send(patchBody) + .expect(403); + + // this should fail in authorization controller and not in dit controller + should(response.body).not.match({ + errors: [{ + status: 403, + detail: 'only creator can update' + }] + }); + }); + }); + }); + + describe(`DELETE /${dit}s/:id`, () => { + it('todo'); + }); + }); +} \ No newline at end of file diff --git a/test/handle-database.js b/test/handle-database.js index b0fb873..fe743f7 100644 --- a/test/handle-database.js +++ b/test/handle-database.js @@ -24,6 +24,7 @@ exports.fill = async function (data) { ideas: [], ideaTags: [], ideaComments: [], + challenges: [], reactions: [], votes: [] }; @@ -115,6 +116,20 @@ exports.fill = async function (data) { ideaComment.id = newComment.id; } + for(const challenge of processed.challenges) { + const creator = challenge.creator.username; + const title = challenge.title; + const detail = challenge.detail; + const created = challenge.created; + const outChallenge = await models.dit.create( 'challenge', { title, detail, created, creator }); + if (!outChallenge) { + const e = new Error('challenge could not be saved'); + e.data = challenge; + throw e; + } + challenge.id = outChallenge.id; + } + for(const reaction of processed.reactions) { const creator = reaction.creator.username; const commentId = reaction.comment.id; @@ -310,6 +325,23 @@ function processData(data) { return resp; }); + output.challenges = data.challenges.map(function ([attrs = { }, _creator = 0], i) { + const { title = `challenge title ${i}`, detail = `challenge detail ${i}`, created = Date.now() + 1000 * i } = attrs; + const resp = { + _creator, + get creator() { + return output.users[_creator]; + }, + title, + detail, + created + }; + + resp.creator._ideas.push(i); + + return resp; + }); + // put comments together Object.defineProperty(output, 'comments', { get: function () { return this.ideaComments; } }); From a58f5809e1a72c99fafd4b72ba7ecb966d29cc24 Mon Sep 17 00:00:00 2001 From: Agata-Andrzejewska Date: Tue, 10 Apr 2018 20:44:23 +0200 Subject: [PATCH 2/6] fix tests - missing ditType data parameter --- test/ideas.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/ideas.js b/test/ideas.js index b5620b3..e567763 100644 --- a/test/ideas.js +++ b/test/ideas.js @@ -69,7 +69,8 @@ describe('ideas', () => { type: 'ideas', attributes: { title: 'A testing idea 1', - detail: 'This is a testing idea detail.' + detail: 'This is a testing idea detail.', + ditType: 'idea' } } }); From 0bf94881511cbb0ae6af4d0117d4e571e1762785 Mon Sep 17 00:00:00 2001 From: Agata-Andrzejewska Date: Tue, 10 Apr 2018 20:50:48 +0200 Subject: [PATCH 3/6] fix tests - missing ditType data parameter --- test/ideas.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/ideas.js b/test/ideas.js index e567763..21a8b2c 100644 --- a/test/ideas.js +++ b/test/ideas.js @@ -7,7 +7,7 @@ const agentFactory = require('./agent'), dbHandle = require('./handle-database'), models = require(path.resolve('./models')); -describe('ideas', () => { +describe.only('ideas', () => { let agent, dbData, loggedUser; @@ -28,7 +28,8 @@ describe('ideas', () => { type: 'ideas', attributes: { title: 'A testing idea 1', - detail: 'This is a testing idea detail.' + detail: 'This is a testing idea detail.', + ditType: 'idea' } } }; }); @@ -69,8 +70,7 @@ describe('ideas', () => { type: 'ideas', attributes: { title: 'A testing idea 1', - detail: 'This is a testing idea detail.', - ditType: 'idea' + detail: 'This is a testing idea detail.' } } }); From 1258cb922b8d40d80e36bc534b6d729b06c13e51 Mon Sep 17 00:00:00 2001 From: Agata-Andrzejewska Date: Sun, 15 Apr 2018 12:30:03 +0200 Subject: [PATCH 4/6] write common tests for ideas and challenges lists functionalities and make them work --- collections.js | 13 + controllers/dit-tags.js | 18 +- controllers/dits.js | 255 ++-- controllers/goto/challenges.js | 3 +- controllers/validators/challenges.js | 1 + controllers/validators/schema/challenges.js | 30 +- models/dit-tag/index.js | 202 ++++ models/dit-tag/schema.js | 5 + models/dit/index.js | 257 +++-- models/index.js | 3 +- routes/challenges.js | 5 + serializers/challenge-tags.js | 70 ++ test/dits-list.js | 1152 +++++++++++++++++++ test/dits.js | 6 +- test/handle-database.js | 48 + test/ideas.js | 2 +- 16 files changed, 1881 insertions(+), 189 deletions(-) create mode 100644 models/dit-tag/index.js create mode 100644 models/dit-tag/schema.js create mode 100644 serializers/challenge-tags.js create mode 100644 test/dits-list.js diff --git a/collections.js b/collections.js index 8fce9aa..016bebf 100644 --- a/collections.js +++ b/collections.js @@ -95,6 +95,19 @@ module.exports = { ] }, + challengeTags: { + type: 'edge', + from: ['challenges'], + to: ['tags'], + indexes: [ + { + type: 'hash', + fields: ['_from', '_to'], + unique: true + } + ] + }, + comments: { type: 'document', indexes: [ diff --git a/controllers/dit-tags.js b/controllers/dit-tags.js index 8f49af0..334611d 100644 --- a/controllers/dit-tags.js +++ b/controllers/dit-tags.js @@ -12,15 +12,29 @@ const models = require(path.resolve('./models')), async function post(req, res, next) { try { // gather data from request + const { ditType } = req.body; const { tagname } = req.body.tag; const ditId = req.params.id; const username = req.auth.username; // save new dit-tag to database - const newDitTag = await models.ditTag.create(ditId, tagname, { }, username); + const newDitTag = await models.ditTag.create(ditType, ditId, tagname, { }, username); // serialize response body - const responseBody = serialize.ditTag(newDitTag); + let responseBody; + switch(ditType){ + case 'idea': { + responseBody = serialize.ideaTag(newDitTag); + break; + } + case 'challenge': { + responseBody = serialize.challengeTag(newDitTag); + break; + } + } + + // serialize response body + // const responseBody = serialize.ditTag(newDitTag); // respond return res.status(201).json(responseBody); diff --git a/controllers/dits.js b/controllers/dits.js index 352f545..99e1d5d 100644 --- a/controllers/dits.js +++ b/controllers/dits.js @@ -12,10 +12,10 @@ async function post(req, res, next) { // gather data const { title, detail, ditType } = req.body; const creator = req.auth.username; - // save the idea to database + // save the dit to database const newDit = await models.dit.create(ditType, { title, detail, creator }); - // serialize the idea (JSON API) + // serialize the dit (JSON API) let serializedDit; switch(ditType){ case 'idea': { @@ -36,7 +36,7 @@ async function post(req, res, next) { } /** - * Read idea by id + * Read dit by id */ async function get(req, res, next) { try { @@ -45,7 +45,7 @@ async function get(req, res, next) { const { username } = req.auth; const ditType = req.baseUrl.slice(1,-1); - // read the idea from database + // read the dit from database const dit = await models.dit.read(ditType, id); if (!dit) return res.status(404).json({ }); @@ -54,7 +54,7 @@ async function get(req, res, next) { dit.votes = await models.vote.readVotesTo({ type: ditType+'s', id }); dit.myVote = await models.vote.read({ from: username, to: { type: ditType+'s', id } }); - // serialize the idea (JSON API) + // serialize the dit (JSON API) let serializedDit; switch(ditType){ case 'idea': { @@ -76,8 +76,8 @@ async function get(req, res, next) { } /** - * Update idea's title or detail - * PATCH /ideas/:id + * Update dit's title or detail + * PATCH /dits/:id */ async function patch(req, res, next) { let ditType; @@ -88,10 +88,10 @@ async function patch(req, res, next) { ditType = req.baseUrl.slice(1,-1); const { username } = req.auth; - // update idea in database + // update dit in database const dit = await models.dit.update(ditType, id, { title, detail }, username); - // serialize the idea (JSON API) + // serialize the dit (JSON API) let serializedDit; switch(ditType){ case 'idea': { @@ -127,22 +127,33 @@ async function patch(req, res, next) { } /** - * Get ideas with my tags + * Get dits with my tags */ async function getDitsWithMyTags(req, res, next) { + let ditType; try { // gather data const { username } = req.auth; const { page: { offset = 0, limit = 10 } = { } } = req.query; + ditType = req.baseUrl.slice(1,-1); - // read the ideas from database - const foundIdeas = await models.idea.withMyTags(username, { offset, limit }); - - // serialize - const serializedIdeas = serialize.idea(foundIdeas); + // read the dits from database + const foundDits = await models.dit.withMyTags(ditType, username, { offset, limit }); + // serialize the dit (JSON API) + let serializedDits; + switch(ditType){ + case 'idea': { + serializedDits = serialize.idea(foundDits); + break; + } + case 'challenge': { + serializedDits = serialize.challenge(foundDits); + break; + } + } // respond - return res.status(200).json(serializedIdeas); + return res.status(200).json(serializedDits); } catch (e) { return next(e); @@ -150,44 +161,66 @@ async function getDitsWithMyTags(req, res, next) { } /** - * Get ideas with specified tags + * Get dits with specified tags */ async function getDitsWithTags(req, res, next) { + let ditType; try { // gather data const { page: { offset = 0, limit = 10 } = { } } = req.query; const { withTags: tagnames } = req.query.filter; + ditType = req.baseUrl.slice(1,-1); - // read the ideas from database - const foundIdeas = await models.idea.withTags(tagnames, { offset, limit }); - // serialize - const serializedIdeas = serialize.idea(foundIdeas); + // read the dits from database + const foundDits = await models.dit.withTags(ditType, tagnames, { offset, limit }); + // serialize the dit (JSON API) + let serializedDits; + switch(ditType){ + case 'idea': { + serializedDits = serialize.idea(foundDits); + break; + } + case 'challenge': { + serializedDits = serialize.challenge(foundDits); + break; + } + } // respond - return res.status(200).json(serializedIdeas); - + return res.status(200).json(serializedDits); } catch (e) { return next(e); } } /** - * Get new ideas + * Get new dits */ async function getNewDits(req, res, next) { + let ditType; try { const { page: { offset = 0, limit = 5 } = { } } = req.query; + ditType = req.baseUrl.slice(1,-1); - // read ideas from database - const foundIdeas = await models.idea.findNew({ offset, limit }); - - // serialize - const serializedIdeas = serialize.idea(foundIdeas); + // read dits from database + const foundDits = await models.dit.findNew(ditType, { offset, limit }); + // serialize the dit (JSON API) + let serializedDits; + switch(ditType){ + case 'idea': { + serializedDits = serialize.idea(foundDits); + break; + } + case 'challenge': { + serializedDits = serialize.challenge(foundDits); + break; + } + } // respond - return res.status(200).json(serializedIdeas); + return res.status(200).json(serializedDits); } catch (e) { return next(e); @@ -195,115 +228,199 @@ async function getNewDits(req, res, next) { } /** - * Get random ideas + * Get random dits */ async function getRandomDits(req, res, next) { + let ditType; try { const { page: { limit = 1 } = { } } = req.query; + ditType = req.baseUrl.slice(1,-1); - // read ideas from database - const foundIdeas = await models.idea.random({ limit }); - - // serialize - const serializedIdeas = serialize.idea(foundIdeas); + // read dits from database + const foundDits = await models.dit.random(ditType, { limit }); + // serialize the dit (JSON API) + let serializedDits; + switch(ditType){ + case 'idea': { + serializedDits = serialize.idea(foundDits); + break; + } + case 'challenge': { + serializedDits = serialize.challenge(foundDits); + break; + } + } // respond - return res.status(200).json(serializedIdeas); - + return res.status(200).json(serializedDits); } catch (e) { return next(e); } } /** - * Get ideas with specified creators + * Get dits with specified creators */ async function getDitsWithCreators(req, res, next) { + let ditType; try { // gather data const { page: { offset = 0, limit = 10 } = { } } = req.query; const { creators } = req.query.filter; + ditType = req.baseUrl.slice(1,-1); - // read ideas from database - const foundIdeas = await models.idea.findWithCreators(creators, { offset, limit }); - - // serialize - const serializedIdeas = serialize.idea(foundIdeas); + // read dits from database + const foundDits = await models.dit.findWithCreators(ditType, creators, { offset, limit }); + // serialize the dit (JSON API) + let serializedDits; + switch(ditType){ + case 'idea': { + serializedDits = serialize.idea(foundDits); + break; + } + case 'challenge': { + serializedDits = serialize.challenge(foundDits); + break; + } + } // respond - return res.status(200).json(serializedIdeas); - + return res.status(200).json(serializedDits); } catch (e) { return next(e); } } /** - * Get ideas commented by specified users + * Get dits commented by specified users */ async function getDitsCommentedBy(req, res, next) { + let ditType; try { // gather data const { page: { offset = 0, limit = 10 } = { } } = req.query; const { commentedBy } = req.query.filter; + ditType = req.baseUrl.slice(1,-1); - // read ideas from database - const foundIdeas = await models.idea.findCommentedBy(commentedBy, { offset, limit }); - - // serialize - const serializedIdeas = serialize.idea(foundIdeas); + // read dits from database + const foundDits = await models.dit.findCommentedBy(ditType, commentedBy, { offset, limit }); + // serialize the dit (JSON API) + let serializedDits; + switch(ditType){ + case 'idea': { + serializedDits = serialize.idea(foundDits); + break; + } + case 'challenge': { + serializedDits = serialize.challenge(foundDits); + break; + } + } // respond - return res.status(200).json(serializedIdeas); - + return res.status(200).json(serializedDits); } catch (e) { return next(e); } } /** - * Get highly voted ideas with an optional parameter of minimum votes + * Get highly voted dits with an optional parameter of minimum votes */ async function getDitsHighlyVoted(req, res, next) { + let ditType; try { // gather data const { page: { offset = 0, limit = 5 } = { } } = req.query; const { highlyVoted } = req.query.filter; + ditType = req.baseUrl.slice(1,-1); - // read ideas from database - const foundIdeas = await models.idea.findHighlyVoted(highlyVoted, { offset, limit }); - - // serialize - const serializedIdeas = serialize.idea(foundIdeas); + // read dits from database + const foundDits = await models.dit.findHighlyVoted(ditType, highlyVoted, { offset, limit }); + // serialize the dit (JSON API) + let serializedDits; + switch(ditType){ + case 'idea': { + serializedDits = serialize.idea(foundDits); + break; + } + case 'challenge': { + serializedDits = serialize.challenge(foundDits); + break; + } + } // respond - return res.status(200).json(serializedIdeas); - + return res.status(200).json(serializedDits); } catch (e) { return next(e); } } /** - * Get trending ideas + * Get trending dits */ async function getDitsTrending(req, res, next) { + let ditType; try { // gather data const { page: { offset = 0, limit = 5 } = { } } = req.query; + ditType = req.baseUrl.slice(1,-1); - // read ideas from database - const foundIdeas = await models.idea.findTrending({ offset, limit }); + // read dits from database + const foundDits = await models.dit.findTrending(ditType, { offset, limit }); + + // serialize the dit (JSON API) + let serializedDits; + switch(ditType){ + case 'idea': { + serializedDits = serialize.idea(foundDits); + break; + } + case 'challenge': { + serializedDits = serialize.challenge(foundDits); + break; + } + } + // respond + return res.status(200).json(serializedDits); + } catch (e) { + return next(e); + } +} + +/** + * Get dits with any of specified keywords in title + */ +async function getDitsSearchTitle(req, res, next) { + let ditType; + try { + // gather data + const { page: { offset = 0, limit = 10 } = { } } = req.query; + const { like: keywords } = req.query.filter.title; + ditType = req.baseUrl.slice(1,-1); - // serialize - const serializedIdeas = serialize.idea(foundIdeas); + // read ideas from database + const foundDits = await models.dit.findWithTitleKeywords(ditType, keywords, { offset, limit }); + // serialize the dit (JSON API) + let serializedDits; + switch(ditType){ + case 'idea': { + serializedDits = serialize.idea(foundDits); + break; + } + case 'challenge': { + serializedDits = serialize.challenge(foundDits); + break; + } + } // respond - return res.status(200).json(serializedIdeas); + return res.status(200).json(serializedDits); } catch (e) { return next(e); } } -module.exports = { get, getDitsCommentedBy, getDitsHighlyVoted, getDitsTrending, getDitsWithCreators, getDitsWithMyTags, getDitsWithTags, getNewDits, getRandomDits, patch, post }; +module.exports = { get, getDitsCommentedBy, getDitsHighlyVoted, getDitsSearchTitle, getDitsTrending, getDitsWithCreators, getDitsWithMyTags, getDitsWithTags, getNewDits, getRandomDits, patch, post }; diff --git a/controllers/goto/challenges.js b/controllers/goto/challenges.js index 2dca1d3..33d63ff 100644 --- a/controllers/goto/challenges.js +++ b/controllers/goto/challenges.js @@ -11,6 +11,7 @@ module.exports = { withCreators: route(['query.filter.creators']), commentedBy: route(['query.filter.commentedBy']), highlyVoted: route(['query.filter.highlyVoted']), - trending: route(['query.filter.trending']) + trending: route(['query.filter.trending']), + searchTitle: route(['query.filter.title.like']) }, }; diff --git a/controllers/validators/challenges.js b/controllers/validators/challenges.js index b0e1a3d..def0440 100644 --- a/controllers/validators/challenges.js +++ b/controllers/validators/challenges.js @@ -7,6 +7,7 @@ module.exports = { get: validate('getChallenge'), getChallengesCommentedBy: validate('getChallengesCommentedBy'), getChallengesHighlyVoted: validate('getChallengesHighlyVoted'), + getChallengesSearchTitle: validate('getChallengesSearchTitle'), getChallengesTrending: validate('getChallengesTrending'), getChallengesWithCreators: validate('getChallengesWithCreators'), getChallengesWithMyTags: validate('getChallengesWithMyTags'), diff --git a/controllers/validators/schema/challenges.js b/controllers/validators/schema/challenges.js index ec8ba03..7961111 100644 --- a/controllers/validators/schema/challenges.js +++ b/controllers/validators/schema/challenges.js @@ -1,6 +1,6 @@ 'use strict'; -const { title, detail, ditType, id, page, pageOffset0, random, tagsList, usersList } = require('./paths'); +const { title, detail, ditType, id, keywordsList, page, pageOffset0, random, tagsList, usersList } = require('./paths'); const postChallenges = { properties: { @@ -219,4 +219,30 @@ const getChallengesTrending = { required: ['query'] }; -module.exports = { getChallenge, getChallengesCommentedBy, getChallengesHighlyVoted, getChallengesTrending, getChallengesWithCreators, getChallengesWithMyTags, getChallengesWithTags, getNewChallenges, getRandomChallenges, patchChallenge, postChallenges }; +const getChallengesSearchTitle = { + properties: { + query: { + properties: { + filter: { + properties: { + title: { + properties: { + like: keywordsList + }, + required: ['like'], + additionalProperties: false + } + }, + required: ['title'], + additionalProperties: false + }, + page + }, + required: ['filter'], + additionalProperties: false + }, + }, + required: ['query'] +}; + +module.exports = { getChallenge, getChallengesCommentedBy, getChallengesHighlyVoted, getChallengesSearchTitle, getChallengesTrending, getChallengesWithCreators, getChallengesWithMyTags, getChallengesWithTags, getNewChallenges, getRandomChallenges, patchChallenge, postChallenges }; diff --git a/models/dit-tag/index.js b/models/dit-tag/index.js new file mode 100644 index 0000000..ef71485 --- /dev/null +++ b/models/dit-tag/index.js @@ -0,0 +1,202 @@ +'use strict'; + +const path = require('path'); + +const Model = require(path.resolve('./models/model')), + schema = require('./schema'); + +class DitTag extends Model { + + /** + * Create ditTag in database + */ + static async create(ditType, ditId, tagname, ditTagInput, creatorUsername) { + // generate standard ideaTag + const ditTag = await schema(ditTagInput); + // / STOPPED + const query = ` + // array of dits (1 or 0) + LET ds = (FOR d IN ${ditType}s FILTER d._key == @ditId RETURN d) + // array of dits (1 or 0) + LET ts = (FOR t IN tags FILTER t.tagname == @tagname RETURN t) + // array of users (1 or 0) + LET us = (FOR u IN users FILTER u.username == @creatorUsername RETURN u) + // create the ditTag (if dit, tag and creator exist) + LET ditTag = (FOR d IN ds FOR t IN ts FOR u IN us FILTER u._id == d.creator + INSERT MERGE({ _from: d._id, _to: t._id, creator: u._id }, @ditTag) IN ${ditType}Tags RETURN KEEP(NEW, 'created'))[0] || { } + // if ditTag was not created, default to empty object (to be able to merge later) + // gather needed data + LET creator = MERGE(KEEP(us[0], 'username'), us[0].profile) + LET tag = KEEP(ts[0], 'tagname') + LET dit = MERGE(KEEP(us[0], 'title', 'detail'), { id: us[0]._key }) + // return data + RETURN MERGE(ditTag, { creator, tag, dit })`; + + const params = { ditId, tagname, ditTag, creatorUsername }; + + const cursor = await this.db.query(query, params); + + const response = (await cursor.all())[0]; + + switch (cursor.extra.stats.writesExecuted) { + // ditTag was created + case 1: { + return response; + } + // ditTag was not created + case 0: { + throw generateError(response); + } + } + + function generateError(response) { + let e; + // check that idea, tag and creator exist + const { dit, tag, creator } = response; + + // some of them don't exist, then ditTag was not created + if (!(dit && tag && creator)) { + e = new Error('Not Found'); + e.code = 404; + e.missing = []; + + ['dit', 'tag', 'creator'].forEach((potentialMissing) => { + if (!response[potentialMissing]) e.missing.push(potentialMissing); + }); + } else { + // if all exist, then dit creator !== ditTag creator, not authorized + e = new Error('Not Authorized'); + e.code = 403; + } + + return e; + } + + } + + /** + * Read ideaTag from database + */ + static async read(ideaId, tagname) { + + const query = ` + FOR t IN tags FILTER t.tagname == @tagname + FOR i IN ideas FILTER i._key == @ideaId + FOR it IN ideaTags FILTER it._from == i._id AND it._to == t._id + LET creator = (FOR u IN users FILTER u._id == it.creator + RETURN MERGE(KEEP(u, 'username'), u.profile))[0] + LET ideaTag = KEEP(it, 'created') + LET tag = KEEP(t, 'tagname') + LET idea = MERGE(KEEP(i, 'title', 'detail'), { id: i._key }) + RETURN MERGE(ideaTag, { creator, tag, idea })`; + const params = { ideaId, tagname }; + + const cursor = await this.db.query(query, params); + + return (await cursor.all())[0]; + } + + /** + * Read tags of idea + */ + static async readTagsOfIdea(ideaId) { + + const query = ` + // read idea into array (length 1 or 0) + LET is = (FOR i IN ideas FILTER i._key == @ideaId RETURN i) + // read ideaTags + LET its = (FOR i IN is + FOR it IN ideaTags FILTER it._from == i._id + FOR t IN tags FILTER it._to == t._id + SORT t.tagname + LET ideaTag = KEEP(it, 'created') + LET tag = KEEP(t, 'tagname') + LET idea = MERGE(KEEP(i, 'title', 'detail'), { id: i._key }) + RETURN MERGE(ideaTag, { tag, idea }) + ) + RETURN { ideaTags: its, idea: is[0] }`; + const params = { ideaId }; + + const cursor = await this.db.query(query, params); + + const [{ idea, ideaTags }] = await cursor.all(); + + // when idea not found, error + if (!idea) { + const e = new Error('idea not found'); + e.code = 404; + throw e; + } + + return ideaTags; + } + + /** + * Remove ideaTag from database + */ + static async remove(ideaId, tagname, username) { + const query = ` + // find users (1 or 0) + LET us = (FOR u IN users FILTER u.username == @username RETURN u) + // find ideas (1 or 0) + LET is = (FOR i IN ideas FILTER i._key == @ideaId RETURN i) + // find [ideaTag] between idea and tag specified (1 or 0) + LET its = (FOR i IN is + FOR t IN tags FILTER t.tagname == @tagname + FOR it IN ideaTags FILTER it._from == i._id AND it._to == t._id + RETURN it) + // find and remove [ideaTag] if and only if user is creator of idea + // is user authorized to remove the ideaTag in question? + LET itsdel = (FOR u IN us FOR i IN is FILTER u._id == i.creator + FOR it IN its + REMOVE it IN ideaTags + RETURN it) + // return [ideaTag] between idea and tag + RETURN its`; + + const params = { ideaId, tagname, username }; + + // execute query and gather database response + const cursor = await this.db.query(query, params); + const [matchedIdeaTags] = await cursor.all(); + + // return or error + switch (cursor.extra.stats.writesExecuted) { + // ideaTag was removed: ok + case 1: { + return; + } + // ideaTag was not removed: error + case 0: { + throw generateError(matchedIdeaTags); + } + // unexpected error + default: { + throw new Error('unexpected error'); + } + } + + /** + * When no ideaTag was removed, it can have 2 reasons: + * 1. ideaTag was not found + * 2. ideaTag was found, but the user is not creator of the idea + * therefore is not authorized to do so + */ + function generateError(response) { + let e; + if (response.length === 0) { + // ideaTag was not found + e = new Error('not found'); + e.code = 404; + } else { + // ideaTag was found, but user is not idea's creator + e = new Error('not authorized'); + e.code = 403; + } + + return e; + } + } +} + +module.exports = DitTag; diff --git a/models/dit-tag/schema.js b/models/dit-tag/schema.js new file mode 100644 index 0000000..d963502 --- /dev/null +++ b/models/dit-tag/schema.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = function ({ created = Date.now() }) { + return { created }; +}; diff --git a/models/dit/index.js b/models/dit/index.js index c42eb3c..6547135 100644 --- a/models/dit/index.js +++ b/models/dit/index.js @@ -4,13 +4,13 @@ const _ = require('lodash'), const Model = require(path.resolve('./models/model')), schema = require('./schema'); -class Idea extends Model { +class Dit extends Model { /** - * Create an idea + * Create an dit */ static async create(ditType, { title, detail, created, creator }) { - // create the idea + // create the dit const dit = schema({ title, detail, created }); const ditCollection = ditType + 's'; const query = ` @@ -31,7 +31,7 @@ class Idea extends Model { } /** - * Read the idea by id (_key in db). + * Read the dit by id (_key in db). */ static async read(ditType, id) { const ditCollection = ditType + 's'; @@ -49,7 +49,7 @@ class Idea extends Model { } /** - * Update an idea + * Update an dit */ static async update(ditType, id, newData, username) { const dit = _.pick(newData, ['title', 'detail']); @@ -58,16 +58,16 @@ class Idea extends Model { const query = ` // read [user] LET us = (FOR u IN users FILTER u.username == @username RETURN u) - // read [idea] + // read [dit] LET is = (FOR i IN @@ditCollection FILTER i._key == @id RETURN i) - // update idea if and only if user matches idea creator + // update dit if and only if user matches dit creator LET newis = ( FOR i IN is FOR u IN us FILTER u._id == i.creator UPDATE i WITH @dit IN @@ditCollection LET creator = MERGE(KEEP(u, 'username'), u.profile) RETURN MERGE(KEEP(NEW, 'title', 'detail', 'created'), { id: NEW._key }, { creator }) ) - // return old and new idea (to decide what is the error) + // return old and new dit (to decide what is the error) RETURN [is[0], newis[0]]`; const params = { id, dit, username, '@ditCollection': ditCollection }; const cursor = await this.db.query(query, params); @@ -76,8 +76,8 @@ class Idea extends Model { // if nothing was updated, throw error if (!newDit) { const e = new Error('not updated'); - // if old idea was found, then user doesn't have sufficient writing rights, - // otherwise idea not found + // if old dit was found, then user doesn't have sufficient writing rights, + // otherwise dit not found e.code = (oldDit) ? 403 : 404; throw e; } @@ -86,237 +86,242 @@ class Idea extends Model { } /** - * Read ideas with my tags + * Read dits with my tags */ - static async withMyTags(username, { offset, limit }) { - + static async withMyTags(ditType, username, { offset, limit }) { + const ditTags = ditType + 'Tags'; const query = ` - // gather the ideas related to me + // gather the dits related to me FOR me IN users FILTER me.username == @username FOR t, ut IN 1..1 ANY me OUTBOUND userTag - FOR i IN 1..1 ANY t INBOUND ideaTags + FOR i IN 1..1 ANY t INBOUND @@ditTags LET relevance = ut.relevance LET tg = KEEP(t, 'tagname') SORT relevance DESC // collect found tags together - COLLECT idea=i INTO collected KEEP relevance, tg - LET c = (DOCUMENT(idea.creator)) + COLLECT ${ditType}=i INTO collected KEEP relevance, tg + LET c = (DOCUMENT(${ditType}.creator)) LET creator = MERGE(KEEP(c, 'username'), c.profile) - // sort ideas by sum of relevances of related userTags + // sort dits by sum of relevances of related userTags LET relSum = SUM(collected[*].relevance) SORT relSum DESC // format for output - LET ideaOut = MERGE(KEEP(idea, 'title', 'detail', 'created'), { id: idea._key}, { creator }) + LET ${ditType}Out = MERGE(KEEP(${ditType}, 'title', 'detail', 'created'), { id: ${ditType}._key}, { creator }) LET tagsOut = collected[*].tg // limit LIMIT @offset, @limit // respond - RETURN { idea: ideaOut, tags: tagsOut }`; - const params = { username, offset, limit }; + RETURN { dit: ${ditType}Out, tags: tagsOut }`; + const params = { username, offset, limit, '@ditTags': ditTags }; const cursor = await this.db.query(query, params); const out = await cursor.all(); - // generate idea-tags ids, and add them as attributes to each idea - // and return array of the ideas - return out.map(({ idea, tags }) => { - idea.ideaTags = tags.map(({ tagname }) => ({ - id: `${idea.id}--${tagname}`, - idea, + // generate dit-tags ids, and add them as attributes to each dit + // and return array of the dits + return out.map(({ dit, tags }) => { + dit[`${ditType}Tags`] = tags.map(({ tagname }) => ({ + id: `${dit.id}--${tagname}`, + [`${ditType}`]: dit, tag: { tagname } })); - return idea; + return dit; }); } /** - * Read ideas with tags + * Read dits with tags * @param {string[]} tagnames - list of tagnames to search with * @param {integer} offset - pagination offset * @param {integer} limit - pagination limit - * @returns {Promise} - list of found ideas + * @returns {Promise} - list of found dits */ - static async withTags(tagnames, { offset, limit }) { + static async withTags(ditType, tagnames, { offset, limit }) { + const ditTags = ditType + 'Tags'; const query = ` // find the provided tags FOR t IN tags FILTER t.tagname IN @tagnames SORT t.tagname LET tg = KEEP(t, 'tagname') - // find the related ideas - FOR i IN 1..1 ANY t INBOUND ideaTags - // collect tags of each idea together - COLLECT idea=i INTO collected KEEP tg - // sort ideas by amount of matched tags, and from oldest - SORT LENGTH(collected) DESC, idea.created ASC + // find the related dits + FOR i IN 1..1 ANY t INBOUND @@ditTags + // collect tags of each dit together + COLLECT ${ditType}=i INTO collected KEEP tg + // sort dits by amount of matched tags, and from oldest + SORT LENGTH(collected) DESC, ${ditType}.created ASC // read and format creator - LET c = (DOCUMENT(idea.creator)) + LET c = (DOCUMENT(${ditType}.creator)) LET creator = MERGE(KEEP(c, 'username'), c.profile) // format for output - LET ideaOut = MERGE(KEEP(idea, 'title', 'detail', 'created'), { id: idea._key}, { creator }) + LET ${ditType}Out = MERGE(KEEP(${ditType}, 'title', 'detail', 'created'), { id: ${ditType}._key}, { creator }) LET tagsOut = collected[*].tg // limit LIMIT @offset, @limit // respond - RETURN { idea: ideaOut, tags: tagsOut }`; - const params = { tagnames, offset, limit }; + RETURN { dit: ${ditType}Out, tags: tagsOut }`; + const params = { tagnames, offset, limit, '@ditTags': ditTags }; const cursor = await this.db.query(query, params); const out = await cursor.all(); - // generate idea-tags ids, and add them as attributes to each idea - // and return array of the ideas - return out.map(({ idea, tags }) => { - idea.ideaTags = tags.map(({ tagname }) => ({ - id: `${idea.id}--${tagname}`, - idea, + // generate dit-tags ids, and add them as attributes to each dit + // and return array of the dits + return out.map(({ dit, tags }) => { + dit[`${ditType}Tags`] = tags.map(({ tagname }) => ({ + id: `${dit.id}--${tagname}`, + [`${ditType}`]: dit, tag: { tagname } })); - return idea; + return dit; }); } /** - * Read new ideas + * Read new dits */ - static async findNew({ offset, limit }) { - + static async findNew(ditType, { offset, limit }) { + const ditCollection = ditType + 's'; const query = ` - FOR idea IN ideas + FOR ${ditType} IN @@ditCollection // sort from newest - SORT idea.created DESC + SORT ${ditType}.created DESC LIMIT @offset, @limit // find creator - LET c = (DOCUMENT(idea.creator)) + LET c = (DOCUMENT(${ditType}.creator)) LET creator = MERGE(KEEP(c, 'username'), c.profile) // format for output - LET ideaOut = MERGE(KEEP(idea, 'title', 'detail', 'created'), { id: idea._key}, { creator }) + LET ditOut = MERGE(KEEP(${ditType}, 'title', 'detail', 'created'), { id: ${ditType}._key}, { creator }) // limit // respond - RETURN ideaOut`; - const params = { offset, limit }; + RETURN ditOut`; + const params = { offset, limit, '@ditCollection': ditCollection }; const cursor = await this.db.query(query, params); return await cursor.all(); } /** - * Read random ideas - * @param {number} [limit] - max amount of random ideas to return + * Read random dits + * @param {number} [limit] - max amount of random dits to return */ - static async random({ limit }) { - + static async random(ditType, { limit }) { + const ditCollection = ditType + 's'; const query = ` - FOR idea IN ideas + FOR ${ditType} IN @@ditCollection // sort from newest SORT RAND() LIMIT @limit // find creator - LET c = (DOCUMENT(idea.creator)) + LET c = (DOCUMENT(${ditType}.creator)) LET creator = MERGE(KEEP(c, 'username'), c.profile) // format for output - LET ideaOut = MERGE(KEEP(idea, 'title', 'detail', 'created'), { id: idea._key}, { creator }) + LET ditOut = MERGE(KEEP(${ditType}, 'title', 'detail', 'created'), { id: ${ditType}._key}, { creator }) // limit // respond - RETURN ideaOut`; - const params = { limit }; + RETURN ditOut`; + const params = { limit, '@ditCollection': ditCollection }; const cursor = await this.db.query(query, params); return await cursor.all(); } /** - * Read ideas with specified creators + * Read dits with specified creators * @param {string[]} usernames - list of usernames to search with * @param {integer} offset - pagination offset * @param {integer} limit - pagination limit - * @returns {Promise} - list of found ideas + * @returns {Promise} - list of found dits */ - static async findWithCreators(creators, { offset, limit }) { + static async findWithCreators(ditType, creators, { offset, limit }) { // TODO to be checked for query optimization or unnecessary things + const ditCollection = ditType + 's'; const query = ` LET creators = (FOR u IN users FILTER u.username IN @creators RETURN u) - FOR idea IN ideas FILTER idea.creator IN creators[*]._id + FOR ${ditType} IN @@ditCollection FILTER ${ditType}.creator IN creators[*]._id // find creator - LET c = (DOCUMENT(idea.creator)) + LET c = (DOCUMENT(${ditType}.creator)) // format for output LET creator = MERGE(KEEP(c, 'username'), c.profile) - LET ideaOut = MERGE(KEEP(idea, 'title', 'detail', 'created'), { id: idea._key}, { creator }) + LET ditOut = MERGE(KEEP(${ditType}, 'title', 'detail', 'created'), { id: ${ditType}._key}, { creator }) // sort from newest - SORT idea.created DESC + SORT ${ditType}.created DESC // limit LIMIT @offset, @limit // respond - RETURN ideaOut`; + RETURN ditOut`; - const params = { offset, limit , creators }; + const params = { offset, limit , creators, '@ditCollection': ditCollection }; const cursor = await this.db.query(query, params); return await cursor.all(); } /** - * Read ideas commented by specified users + * Read dits commented by specified users * @param {string[]} usernames - list of usernames to search with * @param {integer} offset - pagination offset * @param {integer} limit - pagination limit - * @returns {Promise} - list of found ideas + * @returns {Promise} - list of found dits */ - static async findCommentedBy(commentedBy, { offset, limit }) { - + static async findCommentedBy(ditType, commentedBy, { offset, limit }) { + const ditCollection = ditType + 's'; const query = ` FOR user IN users FILTER user.username IN @commentedBy FOR comment IN comments FILTER comment.creator == user._id - AND IS_SAME_COLLECTION('ideas', comment.primary) - FOR idea IN ideas - FILTER idea._id == comment.primary - COLLECT i = idea + AND IS_SAME_COLLECTION('${ditType}s', comment.primary) + FOR ${ditType} IN @@ditCollection + FILTER ${ditType}._id == comment.primary + COLLECT i = ${ditType} // sort from newest SORT i.created DESC LIMIT @offset, @limit RETURN i`; - const params = { commentedBy, offset, limit }; + const params = { commentedBy, offset, limit, '@ditCollection': ditCollection }; const cursor = await this.db.query(query, params); return await cursor.all(); } /** - * Read highly voted ideas + * Read highly voted dits * @param {string[]} voteSumBottomLimit - minimal query voteSum * @param {integer} offset - pagination offset * @param {integer} limit - pagination limit - * @returns {Promise} - list of found ideas + * @returns {Promise} - list of found dits */ - static async findHighlyVoted(voteSumBottomLimit, { offset, limit }) { + static async findHighlyVoted(ditType, voteSumBottomLimit, { offset, limit }) { + const ditCollection = ditType + 's'; const query = ` - FOR idea IN ideas - LET ideaVotes = (FOR vote IN votes FILTER idea._id == vote._to RETURN vote) - // get sum of each idea's votes values - LET voteSum = SUM(ideaVotes[*].value) + FOR ${ditType} IN @@ditCollection + LET ${ditType}Votes = (FOR vote IN votes FILTER ${ditType}._id == vote._to RETURN vote) + // get sum of each dit's votes values + LET voteSum = SUM(${ditType}Votes[*].value) // set bottom limit of voteSum FILTER voteSum >= @voteSumBottomLimit // find creator - LET c = (DOCUMENT(idea.creator)) + LET c = (DOCUMENT(${ditType}.creator)) LET creator = MERGE(KEEP(c, 'username'), c.profile) - LET ideaOut = MERGE(KEEP(idea, 'title', 'detail', 'created'), { id: idea._key}, { creator }, { voteSum }) + LET ditOut = MERGE(KEEP(${ditType}, 'title', 'detail', 'created'), { id: ${ditType}._key}, { creator }, { voteSum }) // sort by amount of votes - SORT ideaOut.voteSum DESC, ideaOut.created DESC + SORT ditOut.voteSum DESC, ditOut.created DESC LIMIT @offset, @limit - RETURN ideaOut`; + RETURN ditOut`; - const params = { voteSumBottomLimit, offset, limit }; + const params = { voteSumBottomLimit, offset, limit, '@ditCollection': ditCollection }; const cursor = await this.db.query(query, params); return await cursor.all(); } /** - * Read trending ideas + * Read trending dits * @param {integer} offset - pagination offset * @param {integer} limit - pagination limit - * @returns {Promise} - list of found ideas + * @returns {Promise} - list of found dits */ - static async findTrending({ offset, limit }) { + static async findTrending(ditType, { offset, limit }) { + const ditCollection = ditType + 's'; + const now = Date.now(); const oneWeek = 604800000; // 1000 * 60 * 60 * 24 * 7 const threeWeeks = 1814400000; // 1000 * 60 * 60 * 24 * 21 @@ -325,37 +330,69 @@ class Idea extends Model { const threeWeeksAgo = now - threeWeeks; const threeMonthsAgo = now - threeMonths; - // for each idea we are counting 'rate' + // for each dit we are counting 'rate' // rate is the sum of votes/day in the last three months // votes/day from last week are taken with wage 3 // votes/day from two weeks before last week are taken with wage 2 // votes/day from the rest of days are taken with wage 1 const query = ` - FOR idea IN ideas + FOR ${ditType} IN @@ditCollection FOR vote IN votes - FILTER idea._id == vote._to - // group by idea id - COLLECT id = idea - // get sum of each idea's votes values from last week, last three weeks and last three months + FILTER ${ditType}._id == vote._to + // group by dit id + COLLECT d = ${ditType} + // get sum of each dit's votes values from last week, last three weeks and last three months AGGREGATE rateWeek = SUM((vote.value * TO_NUMBER( @weekAgo <= vote.created))/7), rateThreeWeeks = SUM((vote.value * TO_NUMBER( @threeWeeksAgo <= vote.created && vote.created <= @weekAgo))/14), rateThreeMonths = SUM((vote.value * TO_NUMBER( @threeMonthsAgo <= vote.created && vote.created <= @threeWeeksAgo))/69) // find creator - LET c = (DOCUMENT(id.creator)) + LET c = (DOCUMENT(d.creator)) LET creator = MERGE(KEEP(c, 'username'), c.profile) - LET ideaOut = MERGE(KEEP(id, 'title', 'detail', 'created'), { id: id._key}, { creator }) + LET ditOut = MERGE(KEEP(d, 'title', 'detail', 'created'), { id: d._key}, { creator }) LET rates = 3*rateWeek + 2*rateThreeWeeks + rateThreeMonths FILTER rates > 0 // sort by sum of rates SORT rates DESC LIMIT @offset, @limit - RETURN ideaOut`; + RETURN ditOut`; + + const params = { weekAgo, threeWeeksAgo, threeMonthsAgo, offset, limit, '@ditCollection': ditCollection }; + const cursor = await this.db.query(query, params); + return await cursor.all(); + } + + /** + * Read dits with any of specified keywords in the title + * @param {string[]} keywords - list of keywords to search with + * @param {integer} offset - pagination offset + * @param {integer} limit - pagination limit + * @returns {Promise} - list of found dits + */ + static async findWithTitleKeywords(ditType, keywords, { offset, limit }) { + const ditCollection = ditType + 's'; + const query = ` + FOR ${ditType} IN @@ditCollection + LET search = ( FOR keyword in @keywords + RETURN TO_NUMBER(CONTAINS(${ditType}.title, keyword))) + LET fit = SUM(search) + FILTER fit > 0 + // find creator + LET c = (DOCUMENT(${ditType}.creator)) + // format for output + LET creator = MERGE(KEEP(c, 'username'), c.profile) + LET ditOut = MERGE(KEEP(${ditType}, 'title', 'detail', 'created'), { id: ${ditType}._key}, { creator }, {fit}) + // sort from newest + SORT fit DESC, ditOut.title + // limit + LIMIT @offset, @limit + // respond + RETURN ditOut`; - const params = { weekAgo, threeWeeksAgo, threeMonthsAgo, offset, limit }; + const params = { 'keywords': keywords, offset, limit, '@ditCollection': ditCollection }; const cursor = await this.db.query(query, params); return await cursor.all(); } } -module.exports = Idea; +module.exports = Dit; diff --git a/models/index.js b/models/index.js index 51b0a0a..27746af 100644 --- a/models/index.js +++ b/models/index.js @@ -3,6 +3,7 @@ const comment = require('./comment'), contact = require('./contact'), dit = require('./dit'), + ditTag = require('./dit-tag'), idea = require('./idea'), ideaTag = require('./idea-tag'), message = require('./message'), @@ -23,4 +24,4 @@ const models = { } }; -module.exports = Object.assign(models, { comment, contact, dit, idea, ideaTag, message, model, tag, user, userTag, vote }); +module.exports = Object.assign(models, { comment, contact, dit, ditTag, idea, ideaTag, message, model, tag, user, userTag, vote }); diff --git a/routes/challenges.js b/routes/challenges.js index a11c484..03d7fe5 100644 --- a/routes/challenges.js +++ b/routes/challenges.js @@ -49,6 +49,11 @@ router.route('/') router.route('/') .get(go.get.trending, authorize.onlyLogged, parse, challengeValidators.getChallengesTrending, ditControllers.getDitsTrending); +// get challenges with keywords +router.route('/') + .get(go.get.searchTitle, authorize.onlyLogged, parse, challengeValidators.getChallengesSearchTitle, ditControllers.getDitsSearchTitle); + + router.route('/:id') // read challenge by id .get(authorize.onlyLogged, challengeValidators.get, ditControllers.get) diff --git a/serializers/challenge-tags.js b/serializers/challenge-tags.js new file mode 100644 index 0000000..3ed19fd --- /dev/null +++ b/serializers/challenge-tags.js @@ -0,0 +1,70 @@ +'use strict'; + +const path = require('path'); +const Serializer = require('jsonapi-serializer').Serializer; + +const config = require(path.resolve('./config')); + +/** + * Serializer for challengeTags + */ +const challengeTagsSerializer = new Serializer('challenge-tags', { + attributes: ['challenge', 'tag', 'creator'], + typeForAttribute(attribute) { + if (attribute === 'creator') { + return 'users'; + } + }, + // relationships + challenge: { + ref: 'id', + attributes: ['title', 'detail', 'created'], + includedLinks: { + self: (data, { id }) => `${config.url.all}/challenges/${id}` + } + }, + tag: { + ref: 'tagname', + attributes: ['tagname'], + includedLinks: { + self: (data, { tagname }) => `${config.url.all}/tags/${tagname}` + }, + relationshipLinks: { } + }, + creator: { + ref: 'username', + attributes: ['username', 'givenName', 'familyName', 'description'], + includedLinks: { + self: (data, { username }) => `${config.url.all}/users/${username}` + }, + relationshipLinks: { + related: (data, { username }) => `${config.url.all}/users/${username}` + } + } +}); + +/** + * Given challengeTag, we generate id and add it to the challengeTag + * This method mutates the parameter + */ +function createChallengeTagId(challengeTag) { + const { challenge: { id }, tag: { tagname } } = challengeTag; + challengeTag.id = `${id}--${tagname}`; +} + +/** + * Function to serialize either a userTag or array of userTags + */ +function challengeTag(data) { + // generate ids for challengeTags + if (Array.isArray(data)) { + data.forEach(createChallengeTagId); + } else { + createChallengeTagId(data); + } + + // serialize + return challengeTagsSerializer.serialize(data); +} + +module.exports = { challengeTag }; diff --git a/test/dits-list.js b/test/dits-list.js new file mode 100644 index 0000000..0c0f1cb --- /dev/null +++ b/test/dits-list.js @@ -0,0 +1,1152 @@ +'use strict'; + +const path = require('path'), + should = require('should'), + sinon = require('sinon'); + +const models = require(path.resolve('./models')); + +const agentFactory = require('./agent'), + dbHandle = require('./handle-database'); + +testDitsList('idea'); +testDitsList('challenge'); + +function testDitsList(dit){ + describe(`read lists of ${dit}s`, () => { + + let agent, + dbData; + + // default supertest agent (not logged in) + beforeEach(() => { + agent = agentFactory(); + }); + + // clear database after each test + afterEach(async () => { + await dbHandle.clear(); + }); + + describe(`GET /${dit}s?filter[withMyTags]`, () => { + + let tag1, + dit0, + user0; + + // create and save testing data + beforeEach(async () => { + const data = { + users: 3, + verifiedUsers: [0, 1, 2], + tags: 6, + [`${dit}s`]: Array(7).fill([]), + userTag: [ + [0,0,'',5],[0,1,'',4],[0,2,'',3],[0,4,'',1], + [1,1,'',4],[1,3,'',2], + [2,5,'',2] + ], + [`${dit}Tags`]: [ + [0,0],[0,1],[0,2], + [1,1],[1,2], + [2,1],[2,2],[2,4], + [4,0],[4,1],[4,2],[4,3],[4,4], + [5,2],[5,3], + [6,3] + ] + }; + + dbData = await dbHandle.fill(data); + + [user0] = dbData.users; + [dit0] = dbData[`${dit}s`]; + tag1 = dbData.tags[1]; + }); + + context('logged in', () => { + + beforeEach(() => { + agent = agentFactory.logged(user0); + }); + + context('valid data', () => { + + it(`200 and return array of matched ${dit}s`, async () => { + + // request + const response = await agent + .get(`/${dit}s?filter[withMyTags]`) + .expect(200); + + // we should find 5 dits... + should(response.body).have.property('data').Array().length(5); + + // ...sorted by sum of my tag relevances + should(response.body.data.map(dit => dit.attributes.title)) + .eql([4, 0, 2, 1, 5].map(no => `${dit} title ${no}`)); + + // ditTags should be present as relationships + should(response.body.data[1]).have.propertyByPath('relationships', `${dit}Tags`, 'data').Array().length(3); + should(response.body.data[1].relationships[`${dit}Tags`].data[1]).deepEqual({ + type: `${dit}-tags`, + id: `${dit0.id}--${tag1.tagname}` + }); + + // and dit-tags should be included, too + const includedDitTags = response.body.included.filter(included => included.type === `${dit}-tags`); + should(includedDitTags).Array().length(13); + }); + + it('[pagination] offset and limit the results', async () => { + const response = await agent + .get(`/${dit}s?filter[withMyTags]&page[offset]=1&page[limit]=3`) + .expect(200); + + // we should find 3 dits + should(response.body).have.property('data').Array().length(3); + + // sorted by sum of my tag relevances and started from the 2nd one + should(response.body.data.map(dit => dit.attributes.title)) + .eql([0, 2, 1].map(no => `${dit} title ${no}`)); + }); + }); + + context('invalid data', () => { + + it('[invalid query.filter.withMyTags] 400', async () => { + await agent + .get(`/${dit}s?filter[withMyTags]=1`) + .expect(400); + }); + + it('[invalid pagination] 400', async () => { + await agent + .get(`/${dit}s?filter[withMyTags]&page[offset]=1&page[limit]=21`) + .expect(400); + }); + + it('[unexpected query params] 400', async () => { + await agent + .get(`/${dit}s?filter[withMyTags]&filter[Foo]=bar`) + .expect(400); + }); + }); + }); + + context('not logged in', () => { + it('403', async () => { + await agent + .get(`/${dit}s?filter[withMyTags]`) + .expect(403); + }); + }); + }); + + describe(`GET /${dit}s?filter[withTags]=tag0,tag1,tag2`, () => { + + let tag0, + tag1, + tag3, + tag4, + dit0, + user0; + + // create and save testing data + beforeEach(async () => { + const data = { + users: 3, + verifiedUsers: [0, 1, 2], + tags: 6, + [`${dit}s`]: Array(7).fill([]), + [`${dit}Tags`]: [ + [0,0],[0,1],[0,2], + [1,1],[1,2], + [2,1],[2,2],[2,4], + [4,0],[4,1],[4,2],[4,3],[4,4], + [5,2],[5,3], + [6,3] + ] + }; + + dbData = await dbHandle.fill(data); + + [user0] = dbData.users; + [dit0] = dbData[`${dit}s`]; + [tag0, tag1,, tag3, tag4] = dbData.tags; + }); + + context('logged in', () => { + + beforeEach(() => { + agent = agentFactory.logged(user0); + }); + + context('valid data', () => { + + it(`200 and return array of matched ${dit}s`, async () => { + + // request + const response = await agent + .get(`/${dit}s?filter[withTags]=${tag0.tagname},${tag1.tagname},${tag3.tagname},${tag4.tagname}`) + .expect(200); + + // we should find 6 dits... + should(response.body).have.property('data').Array().length(6); + + // ...sorted by sum of my tag relevances + should(response.body.data.map(dit => dit.attributes.title)) + .eql([4, 0, 2, 1, 5, 6].map(no => `${dit} title ${no}`)); + + // ditTags should be present as relationships + should(response.body.data[1]).have.propertyByPath('relationships', `${dit}Tags`, 'data').Array().length(2); + should(response.body.data[1].relationships[`${dit}Tags`].data[1]).deepEqual({ + type: `${dit}-tags`, + id: `${dit0.id}--${tag1.tagname}` + }); + + // and dit-tags should be included, too + const includedDitTags = response.body.included.filter(included => included.type === `${dit}-tags`); + should(includedDitTags).Array().length(11); + }); + + it('[pagination] offset and limit the results', async () => { + const response = await agent + .get(`/${dit}s?filter[withTags]=${tag0.tagname},${tag1.tagname},${tag3.tagname},${tag4.tagname}&page[offset]=1&page[limit]=3`) + .expect(200); + + // we should find 3 dits + should(response.body).have.property('data').Array().length(3); + + // sorted by sum of my tag relevances and started from the 2nd one + should(response.body.data.map(dit => dit.attributes.title)) + .eql([0, 2, 1].map(no => `${dit} title ${no}`)); + }); + }); + + context('invalid data', () => { + + it('[invalid tagnames in a list] error 400', async () => { + await agent + .get(`/${dit}s?filter[withTags]=invalid--tagname,other-invalid*tagname`) + .expect(400); + }); + + it('[too many tags provided] error 400', async () => { + await agent + .get(`/${dit}s?filter[withTags]=t0,t1,t2,t3,t4,t5,t6,t7,t8,t9,t10`) + .expect(400); + }); + + it('[no tags provided] error 400', async () => { + await agent + .get(`/${dit}s?filter[withTags]=`) + .expect(400); + }); + + it('[invalid pagination] 400', async () => { + await agent + .get(`/${dit}s?filter[withTags]=tag1,tag2,tag3&page[offset]=1&page[limit]=21`) + .expect(400); + }); + + it('[unexpected query params] 400', async () => { + await agent + .get(`/${dit}s?filter[withTags]=tag&filter[Foo]=bar`) + .expect(400); + }); + }); + }); + + context('not logged in', () => { + it('403', async () => { + await agent + .get(`/${dit}s?filter[withTags]=tag0`) + .expect(403); + }); + }); + }); + + describe(`GET /${dit}s?sort=-created (new ${dit}s)`, () => { + + let loggedUser; + + // create and save testing data + beforeEach(async () => { + const data = { + users: 3, + verifiedUsers: [0, 1, 2], + tags: 6, + [`${dit}s`]: Array(11).fill([]) + }; + + dbData = await dbHandle.fill(data); + + loggedUser = dbData.users[0]; + }); + + context('logged in', () => { + + beforeEach(() => { + agent = agentFactory.logged(loggedUser); + }); + + context('valid', () => { + it(`200 and array of new ${dit}s`, async () => { + + // request + const response = await agent + .get(`/${dit}s?sort=-created`) + .expect(200); + + // we should find 5 dits... + should(response.body).have.property('data').Array().length(5); + + // ...sorted from newest to oldest + should(response.body.data.map(dit => dit.attributes.title)) + .eql([10, 9, 8, 7, 6].map(no => `${dit} title ${no}`)); + }); + + it(`[pagination] 200 and array of new ${dit}s, offseted and limited`, async () => { + + // request + const response = await agent + .get(`/${dit}s?sort=-created&page[offset]=3&page[limit]=4`) + .expect(200); + + // we should find 4 dits... + should(response.body).have.property('data').Array().length(4); + + // ...sorted from newest to oldest + should(response.body.data.map(dit => dit.attributes.title)) + .eql([7, 6, 5, 4].map(no => `${dit} title ${no}`)); + }); + }); + + context('invalid', () => { + it('[invalid pagination] 400', async () => { + await agent + .get(`/${dit}s?sort=-created&page[offset]=3&page[limit]=21`) + .expect(400); + }); + + it('[unexpected query params] 400', async () => { + await agent + .get(`/${dit}s?sort=-created&foo=bar`) + .expect(400); + }); + }); + }); + + context('not logged in', () => { + it('403', async () => { + await agent + .get(`/${dit}s?sort=-created`) + .expect(403); + }); + }); + }); + + describe(`GET /${dit}s?filter[random]`, () => { + + let loggedUser; + + // create and save testing data + beforeEach(async () => { + const data = { + users: 3, + verifiedUsers: [0, 1, 2], + [`${dit}s`]: Array(11).fill([]) + }; + + dbData = await dbHandle.fill(data); + + loggedUser = dbData.users[0]; + }); + + context('logged in', () => { + + beforeEach(() => { + agent = agentFactory.logged(loggedUser); + }); + + context('valid', () => { + it(`200 and array of random ${dit}s`, async () => { + + // request + const response = await agent + .get(`/${dit}s?filter[random]`) + .expect(200); + + // we should find 1 dit by default + should(response.body).have.property('data').Array().length(1); + }); + + it(`[pagination] 200 and array of random ${dit}s, limited`, async () => { + + // request + const response = await agent + .get(`/${dit}s?filter[random]&page[offset]=0&page[limit]=4`) + .expect(200); + + // we should find 4 dits... + should(response.body).have.property('data').Array().length(4); + }); + }); + + context('invalid', () => { + it('[invalid pagination] 400', async () => { + await agent + .get(`/${dit}s?filter[random]&page[offset]=3&page[limit]=21`) + .expect(400); + }); + + it('[unexpected query params] 400', async () => { + await agent + .get(`/${dit}s?filter[random]&foo=bar`) + .expect(400); + }); + + it('[random with value] 400', async () => { + await agent + .get(`/${dit}s?filter[random]=bar`) + .expect(400); + }); + }); + }); + + context('not logged in', () => { + it('403', async () => { + await agent + .get(`/${dit}s?filter[random]`) + .expect(403); + }); + }); + }); + + describe(`GET /${dit}s?filter[creators]=user0,user1,user2`, () => { + let user0, + user2, + user3, + user4; + // create and save testing data + beforeEach(async () => { + const data = { + users: 6, + verifiedUsers: [0, 1, 2, 3, 4], + [`${dit}s`]: [[{}, 0], [{}, 0],[{}, 1],[{}, 2],[{}, 2],[{}, 2],[{}, 3]] + }; + + dbData = await dbHandle.fill(data); + + [user0, , user2, user3, user4, ] = dbData.users; + }); + + context('logged in', () => { + + beforeEach(() => { + agent = agentFactory.logged(user0); + }); + + context('valid data', () => { + + it(`[one creator] 200 and return array of matched ${dit}s`, async () => { + + // request + const response = await agent + .get(`/${dit}s?filter[creators]=${user2.username}`) + .expect(200); + + // we should find 2 dits... + should(response.body).have.property('data').Array().length(3); + + // sorted by creation date desc + should(response.body.data.map(dit => dit.attributes.title)) + .eql([5, 4, 3].map(no => `${dit} title ${no}`)); + + }); + + + it(`[two creators] 200 and return array of matched ${dit}s`, async () => { + + // request + const response = await agent + .get(`/${dit}s?filter[creators]=${user2.username},${user3.username}`) + .expect(200); + + // we should find 5 dits... + should(response.body).have.property('data').Array().length(4); + + // sorted by creation date desc + should(response.body.data.map(dit => dit.attributes.title)) + .eql([6, 5, 4, 3].map(no => `${dit} title ${no}`)); + }); + + it(`[creator without ${dit}s] 200 and return array of matched ${dit}s`, async () => { + + // request + const response = await agent + .get(`/${dit}s?filter[creators]=${user4.username}`) + .expect(200); + + // we should find 0 dits... + should(response.body).have.property('data').Array().length(0); + + }); + + it('[pagination] offset and limit the results', async () => { + const response = await agent + .get(`/${dit}s?filter[creators]=${user2.username},${user3.username}&page[offset]=1&page[limit]=3`) + .expect(200); + + // we should find 3 dits + should(response.body).have.property('data').Array().length(3); + + // sorted by creation date desc + should(response.body.data.map(dit => dit.attributes.title)) + .eql([5, 4, 3].map(no => `${dit} title ${no}`)); + }); + + it(`[nonexistent creator] 200 and return array of matched ${dit}s`, async () => { + + // request + const response = await agent + .get(`/${dit}s?filter[creators]=nonexistentcreator`) + .expect(200); + + // we should find 0 dits... + should(response.body).have.property('data').Array().length(0); + + }); + }); + + context('invalid data', () => { + + it('[invalid query.filter.creators] 400', async () => { + await agent + .get(`/${dit}s?filter[creators]=1`) + .expect(400); + }); + + it('[too many users] 400', async () => { + await agent + .get(`/${dit}s?filter[creators]=user1,user2,user3,user4,user5,user6,user7,user8,user9,user190,user11`) + .expect(400); + }); + + it('[invalid pagination] 400', async () => { + await agent + .get(`/${dit}s?filter[creators]=${user2.username},${user3.username}&page[offset]=1&page[limit]=21`) + .expect(400); + }); + + it('[unexpected query params] 400', async () => { + await agent + .get(`/${dit}s?filter[creators]=${user2.username},${user3.username}&additional[param]=3&page[offset]=1&page[limit]=3`) + .expect(400); + }); + }); + }); + + context('not logged in', () => { + it('403', async () => { + await agent + .get(`/${dit}s?filter[creators]=${user2.username}`) + .expect(403); + }); + }); + }); + + describe(`GET /${dit}s?filter[commentedBy]=user0,user1,user2`, () => { + let user0, + user2, + user3, + user4; + // create and save testing data + beforeEach(async () => { + const data = { + users: 6, + verifiedUsers: [0, 1, 2, 3, 4], + [`${dit}s`]: Array(7).fill([]), + [`${dit}Comments`]: [[0, 0],[0, 1], [0,2],[0,2], [0,4], [1,1], [1,2], [2,1], [2,2], [3,4] ] + }; + + dbData = await dbHandle.fill(data); + + [user0, , user2, user3, user4, ] = dbData.users; + }); + + context('logged in', () => { + + beforeEach(() => { + agent = agentFactory.logged(user0); + }); + + context('valid data', () => { + + it(`[${dit}s commented by one user] 200 and return array of matched ${dit}s`, async () => { + + // request + const response = await agent + .get(`/${dit}s?filter[commentedBy]=${user2.username}`) + .expect(200); + + // we should find 3 dit... + should(response.body).have.property('data').Array().length(3); + + // sorted by creation date desc + should(response.body.data.map(dit => dit.attributes.title)) + .eql([2, 1, 0].map(no => `${dit} title ${no}`)); + + }); + + + it(`[${dit}s commented by two users] 200 and return array of matched ${dit}s`, async () => { + + // request + const response = await agent + .get(`/${dit}s?filter[commentedBy]=${user2.username},${user4.username}`) + .expect(200); + + // we should find 4 dits... + should(response.body).have.property('data').Array().length(4); + + // sorted by creation date desc + should(response.body.data.map(dit => dit.attributes.title)) + .eql([3, 2, 1, 0].map(no => `${dit} title ${no}`)); + }); + + it(`[${dit}s commented by user who didn't commented anyting] 200 and return array of matched ${dit}s`, async () => { + + // request + const response = await agent + .get(`/${dit}s?filter[commentedBy]=${user3.username}`) + .expect(200); + + // we should find 0 dits... + should(response.body).have.property('data').Array().length(0); + + }); + + it('[pagination] offset and limit the results', async () => { + const response = await agent + .get(`/${dit}s?filter[commentedBy]=${user2.username},${user4.username}&page[offset]=1&page[limit]=3`) + .expect(200); + + // we should find 3 dits + should(response.body).have.property('data').Array().length(3); + + // sorted by creation date desc + should(response.body.data.map(dit => dit.attributes.title)) + .eql([2, 1, 0].map(no => `${dit} title ${no}`)); + }); + + it(`[nonexistent user who commented] 200 and return array of matched ${dit}s`, async () => { + + // request + const response = await agent + .get(`/${dit}s?filter[commentedBy]=nonexistentuser`) + .expect(200); + + // we should find 0 dits... + should(response.body).have.property('data').Array().length(0); + + }); + }); + + context('invalid data', () => { + + it('[invalid query.filter.commentedBy] 400', async () => { + await agent + .get(`/${dit}s?filter[commentedBy]=1`) + .expect(400); + }); + + it('[too many users] 400', async () => { + await agent + .get(`/${dit}s?filter[commentedBy]=user1,user2,user3,user4,user5,user6,user7,user8,user9,user190,user11`) + .expect(400); + }); + + it('[invalid pagination] 400', async () => { + await agent + .get(`/${dit}s?filter[commentedBy]=${user2.username},${user3.username}&page[offset]=1&page[limit]=21`) + .expect(400); + }); + + it('[unexpected query params] 400', async () => { + await agent + .get(`/${dit}s?filter[commentedBy]=${user2.username},${user3.username}&additional[param]=3&page[offset]=1&page[limit]=3`) + .expect(400); + }); + }); + }); + + context('not logged in', () => { + it('403', async () => { + await agent + .get(`/${dit}s?filter[commentedBy]=${user2.username}`) + .expect(403); + }); + }); + }); + + describe(`GET /${dit}s?filter[highlyVoted]=voteSumBottomLimit`, () => { + let user0; + // create and save testing data + beforeEach(async () => { + const primarys = `${dit}s`; + const data = { + users: 6, + verifiedUsers: [0, 1, 2, 3, 4], + [`${dit}s`]: Array(7).fill([]), + // dits with votes: 3:3, 1:3, 5:1, 2:1, 0:0, 6: -1, 4:-2 + votes: [ + [0, [primarys, 0], -1], + [1, [primarys, 0], 1], + [0, [primarys, 1], 1], + [1, [primarys, 1], 1], + [2, [primarys, 1], 1], + [0, [primarys, 2], -1], + [1, [primarys, 2], 1], + [2, [primarys, 2], 1], + [0, [primarys, 3], 1], + [1, [primarys, 3], 1], + [2, [primarys, 3], 1], + [3, [primarys, 3], 1], + [4, [primarys, 3], -1], + [0, [primarys, 4], -1], + [1, [primarys, 4], -1], + [3, [primarys, 5], 1], + [3, [primarys, 6], -1] + ] + }; + + dbData = await dbHandle.fill(data); + + [user0, , , , , ] = dbData.users; + }); + + context('logged in', () => { + + beforeEach(() => { + agent = agentFactory.logged(user0); + }); + + context('valid data', () => { + + it(`[highly voted ${dit}s] 200 and return array of matched ${dit}s`, async () => { + + // request + const response = await agent + .get(`/${dit}s?filter[highlyVoted]=0`) + .expect(200); + + // without pagination, limit for ideas 5 we should find 5 dits... + should(response.body).have.property('data').Array().length(5); + + // sorted by creation date desc + should(response.body.data.map(dit => dit.attributes.title)) + .eql([3, 1, 5, 2, 0].map(no => `${dit} title ${no}`)); + + }); + + it(`[highly voted ${dit}s with at least 2 votes in plus] 200 and return array of matched ${dit}s`, async () => { + + // request + const response = await agent + .get(`/${dit}s?filter[highlyVoted]=2`) + .expect(200); + + // without pagination, limit for ideas 5 we should find 5 dits... + should(response.body).have.property('data').Array().length(2); + + // sorted by creation date desc + should(response.body.data.map(dit => dit.attributes.title)) + .eql([3, 1].map(no => `${dit} title ${no}`)); + + // shoud value be at least 2 + should(Math.min(...response.body.data.map(dit => dit.meta.voteSum))) + .aboveOrEqual(2); + }); + + + it('[pagination] offset and limit the results', async () => { + const response = await agent + .get(`/${dit}s?filter[highlyVoted]=0&page[offset]=1&page[limit]=3`) + .expect(200); + + // we should find 3 dits + should(response.body).have.property('data').Array().length(3); + + // sorted by creation date desc + should(response.body.data.map(dit => dit.attributes.title)) + .eql([1, 5, 2].map(no => `${dit} title ${no}`)); + }); + + }); + + context('invalid data', () => { + + it('[invalid query.filter.highlyVoted] 400', async () => { + await agent + .get(`/${dit}s?filter[highlyVoted]=string`) + .expect(400); + }); + + it('[invalid query.filter.highlyVoted] 400', async () => { + await agent + .get(`/${dit}s?filter[highlyVoted]`) + .expect(400); + }); + + it('[invalid pagination] 400', async () => { + await agent + .get(`/${dit}s?filter[highlyVoted]=0&page[offset]=1&page[limit]=21`) + .expect(400); + }); + + it('[unexpected query params] 400', async () => { + await agent + .get(`/${dit}s?filter[highlyVoted]=0&additional[param]=3&page[offset]=1&page[limit]=3`) + .expect(400); + }); + }); + }); + + context('not logged in', () => { + it('403', async () => { + await agent + .get(`/${dit}s?filter[highlyVoted]=0`) + .expect(403); + }); + }); + }); + // ///////////////////////////////// TUTAJ + describe(`GET /${dit}s?filter[trending]`, () => { + let user0, + user1, + user2, + user3, + user4, + user5, + user6, + user7, + user8, + dit1, + dit2, + dit3, + dit4, + dit5, + dit6; + const now = Date.now(); + let sandbox; + const threeMonths = 7776000000; + const threeWeeks = 1814400000; + const oneWeek = 604800000; + const twoDays = 172800000; + // create and save testing data + beforeEach(async () => { + sandbox = sinon.sandbox.create(); + const primarys = `${dit}s`; + const data = { + users: 10, + verifiedUsers: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + [`${dit}s`]: Array(11).fill([]), + // dits with votes: 3:3, 1:3, 5:1, 2:1, 0:0, 6: -1, 4:-2 + votes: [ + [0, [primarys, 1], 1], + [0, [primarys, 2], 1], + [0, [primarys, 3], 1], + [1, [primarys, 3], 1], + [2, [primarys, 3], 1], + [0, [primarys, 4], 1], + [1, [primarys, 4], 1], + [2, [primarys, 4], 1], + [3, [primarys, 4], 1], + [4, [primarys, 4], 1], + [0, [primarys, 5], 1], + [1, [primarys, 5], 1], + [2, [primarys, 5], 1], + [3, [primarys, 5], 1], + [4, [primarys, 5], 1], + [5, [primarys, 5], 1], + [6, [primarys, 5], 1], + [0, [primarys, 6], 1], + [1, [primarys, 6], 1] + ] + }; + // post initial data and oldest votes with date three monts ago without two days + sandbox.useFakeTimers(now - threeMonths + twoDays); + dbData = await dbHandle.fill(data); + + [user0, user1, user2, user3, user4, user5, user6, user7, user8 ] = dbData.users; + [ , dit1, dit2, dit3, dit4, dit5, dit6] = dbData[`${dit}s`]; + + // create data to post with time: three weeks ago + const dataThreeWeeksAgo = { + votes: [ + {from: user1.username, to: {type: primarys, id: dit1.id}, value: 1}, + {from: user1.username, to: {type: primarys, id: dit2.id}, value: 1}, + {from: user2.username, to: {type: primarys, id: dit2.id}, value: 1}, + {from: user3.username, to: {type: primarys, id: dit2.id}, value: 1}, + {from: user3.username, to: {type: primarys, id: dit3.id}, value: 1}, + {from: user5.username, to: {type: primarys, id: dit4.id}, value: 1}, + {from: user6.username, to: {type: primarys, id: dit4.id}, value: 1}, + {from: user7.username, to: {type: primarys, id: dit4.id}, value: 1}, + {from: user7.username, to: {type: primarys, id: dit5.id}, value: 1}, + {from: user2.username, to: {type: primarys, id: dit6.id}, value: 1}, + {from: user3.username, to: {type: primarys, id: dit6.id}, value: 1} + ] + }; + // stub time to three weeks ago without two days + sandbox.clock.restore(); + sandbox.useFakeTimers(now - threeWeeks + twoDays); + // add data to database hree weeks ago without two days + for(const i in dataThreeWeeksAgo.votes){ + await models.vote.create(dataThreeWeeksAgo.votes[i]); + } + + const dataOneWeekAgo = { + votes: [ + {from: user2.username, to: {type: primarys, id: dit1.id}, value: 1}, + {from: user3.username, to: {type: primarys, id: dit1.id}, value: 1}, + {from: user4.username, to: {type: primarys, id: dit1.id}, value: 1}, + {from: user5.username, to: {type: primarys, id: dit1.id}, value: 1}, + {from: user6.username, to: {type: primarys, id: dit1.id}, value: 1}, + {from: user7.username, to: {type: primarys, id: dit1.id}, value: 1}, + {from: user8.username, to: {type: primarys, id: dit1.id}, value: 1}, + {from: user4.username, to: {type: primarys, id: dit2.id}, value: 1}, + {from: user5.username, to: {type: primarys, id: dit2.id}, value: 1}, + {from: user6.username, to: {type: primarys, id: dit2.id}, value: 1}, + {from: user7.username, to: {type: primarys, id: dit2.id}, value: 1}, + {from: user8.username, to: {type: primarys, id: dit2.id}, value: 1}, + {from: user4.username, to: {type: primarys, id: dit3.id}, value: 1}, + {from: user5.username, to: {type: primarys, id: dit3.id}, value: 1}, + {from: user6.username, to: {type: primarys, id: dit3.id}, value: 1}, + {from: user7.username, to: {type: primarys, id: dit3.id}, value: 1}, + {from: user8.username, to: {type: primarys, id: dit3.id}, value: 1}, + {from: user8.username, to: {type: primarys, id: dit4.id}, value: 1}, + {from: user8.username, to: {type: primarys, id: dit5.id}, value: 1}, + {from: user4.username, to: {type: primarys, id: dit6.id}, value: 1}, + {from: user5.username, to: {type: primarys, id: dit6.id}, value: 1} + ] + }; + // stub time to one week ago without two days + sandbox.clock.restore(); + sandbox.useFakeTimers( now - oneWeek + twoDays); + for(const i in dataOneWeekAgo.votes){ + await models.vote.create(dataOneWeekAgo.votes[i]); + } + sandbox.clock.restore(); + }); + afterEach(async () => { + sandbox.restore(); + }); + + context('logged in', () => { + + beforeEach(() => { + agent = agentFactory.logged(user0); + }); + + context('valid data', () => { + + it(`[trending] 200 and return array of matched ${dit}s`, async () => { + // request + const response = await agent + .get(`/${dit}s?filter[trending]`) + .expect(200); + // without pagination, limit for dits 5 we should find 5 dits... + should(response.body).have.property('data').Array().length(5); + + // sorted by trending rate + should(response.body.data.map(dit => dit.attributes.title)) + .eql([1, 2, 3, 6, 4].map(no => `${dit} title ${no}`)); + + }); + + it('[trending with pagination] offset and limit the results', async () => { + const response = await agent + .get(`/${dit}s?filter[trending]&page[offset]=1&page[limit]=3`) + .expect(200); + + // we should find 3 dits + should(response.body).have.property('data').Array().length(3); + + // sorted by trending rate + should(response.body.data.map(dit => dit.attributes.title)) + .eql([2, 3, 6].map(no => `${dit} title ${no}`)); + }); + + }); + + context('invalid data', () => { + + it('[trending invalid query.filter.highlyRated] 400', async () => { + await agent + .get(`/${dit}s?filter[trending]=string&page[offset]=1&page[limit]=3`) + .expect(400); + }); + + it('[trending invalid query.filter.highlyRated] 400', async () => { + await agent + .get(`/${dit}s?filter[trending]=1&page[offset]=1&page[limit]=3`) + .expect(400); + }); + + it('[unexpected query params] 400', async () => { + await agent + .get(`/${dit}s?filter[trending]&additional[param]=3&page[offset]=1&page[limit]=3`) + .expect(400); + }); + }); + }); + + context('not logged in', () => { + it('403', async () => { + await agent + .get(`/${dit}s?filter[trending]`) + .expect(403); + }); + }); + }); + + describe(`GET /${dit}s?filter[title][like]=string1,string2,string3`, () => { + let user0; + // create and save testing data + beforeEach(async () => { + const data = { + users: 2, + verifiedUsers: [0], + [`${dit}s`]: [ [{title:`${dit}-title1`}, 0], [{title:`${dit}-title2-keyword1`}, 0], [{title:`${dit}-title3-keyword2`}, 0], [{title:`${dit}-title4-keyword3`}, 0], [{title:`${dit}-title5-keyword2-keyword3`}, 0], [{title:`${dit}-title6-keyword1`}, 0], [{title:`${dit}-title7-keyword1-keyword4`}, 0] ] + }; + + dbData = await dbHandle.fill(data); + + [user0, ] = dbData.users; + }); + + context('logged in', () => { + + beforeEach(() => { + agent = agentFactory.logged(user0); + }); + + context('valid data', () => { + + it(`[find ${dit}s with one word] 200 and return array of matched ${dit}s`, async () => { + + // request + const response = await agent + .get(`/${dit}s?filter[title][like]=keyword1`) + .expect(200); + + // we should find 2 dits... + should(response.body).have.property('data').Array().length(3); + + // sorted by creation date desc + should(response.body.data.map(dit => dit.attributes.title)) + .eql([`${dit}-title2-keyword1`,`${dit}-title6-keyword1`, `${dit}-title7-keyword1-keyword4`]); + + }); + + + it(`[find ${dit}s with two words] 200 and return array of matched ${dit}s`, async () => { + + // request + const response = await agent + .get(`/${dit}s?filter[title][like]=keyword2,keyword3`) + .expect(200); + + // we should find 4 dits... + should(response.body).have.property('data').Array().length(3); + + // sorted by creation date desc + should(response.body.data.map(dit => dit.attributes.title)) + .eql([`${dit}-title5-keyword2-keyword3`, `${dit}-title3-keyword2`, `${dit}-title4-keyword3`]); + }); + + it(`[find ${dit}s with word not present in any] 200 and return array of matched ${dit}s`, async () => { + + // request + const response = await agent + .get(`/${dit}s?filter[title][like]=keyword10`) + .expect(200); + + // we should find 0 dits... + should(response.body).have.property('data').Array().length(0); + + }); + + it('[pagination] offset and limit the results', async () => { + const response = await agent + .get(`/${dit}s?filter[title][like]=keyword1&page[offset]=1&page[limit]=2`) + .expect(200); + + // we should find 3 dits + should(response.body).have.property('data').Array().length(2); + + // sorted by creation date desc + should(response.body.data.map(dit => dit.attributes.title)) + .eql([`${dit}-title6-keyword1`, `${dit}-title7-keyword1-keyword4`]); + }); + + it('should be fine to provide a keyword which includes empty spaces and/or special characters', async () => { + // request + await agent + .get(`/${dit}s?filter[title][like]=keyword , aa,1-i`) + .expect(200); + }); + + }); + + context('invalid data', () => { + + it('[too many keywords] 400', async () => { + await agent + .get(`/${dit}s?filter[title][like]=keyword1,keyword2,keyword3,keyword4,keyword5,keyword6,keyword7,keyword8,keyword9,keyword10,keyword11`) + .expect(400); + }); + + it('[empty keywords] 400', async () => { + await agent + .get(`/${dit}s?filter[title][like]=keyword1,`) + .expect(400); + }); + + it('[too long keywords] 400', async () => { + await agent + .get(`/${dit}s?filter[title][like]=keyword1,${'a'.repeat(257)}`) + .expect(400); + }); + + it('[keywords spaces only] 400', async () => { + await agent + .get(`/${dit}s?filter[title][like]= ,keyword2`) + .expect(400); + }); + + it('[invalid pagination] 400', async () => { + await agent + .get(`/${dit}s?filter[title][like]=keyword1&page[offset]=1&page[limit]=21`) + .expect(400); + }); + + it('[unexpected query params] 400', async () => { + await agent + .get(`/${dit}s?filter[title][like]=keyword1&additional[param]=3&page[offset]=1&page[limit]=3`) + .expect(400); + }); + }); + }); + + context('not logged in', () => { + it('403', async () => { + await agent + .get(`/${dit}s?filter[title][like]=keyword1`) + .expect(403); + }); + }); + }); + }); +} \ No newline at end of file diff --git a/test/dits.js b/test/dits.js index b241177..589cb25 100644 --- a/test/dits.js +++ b/test/dits.js @@ -12,14 +12,14 @@ const agentFactory = require('./agent'), Those are: ideas, challenges */ -testDitsCommonFunctionalities('idea'); -testDitsCommonFunctionalities('challenge'); +testDits('idea'); +testDits('challenge'); /* Function takes type of a dit as an argument and runs all of the test */ -function testDitsCommonFunctionalities(dit){ +function testDits(dit){ describe('dits', () => { let agent, dbData, diff --git a/test/handle-database.js b/test/handle-database.js index fe743f7..928dc0d 100644 --- a/test/handle-database.js +++ b/test/handle-database.js @@ -25,6 +25,8 @@ exports.fill = async function (data) { ideaTags: [], ideaComments: [], challenges: [], + challengeTags: [], + challengeComments: [], reactions: [], votes: [] }; @@ -130,6 +132,26 @@ exports.fill = async function (data) { challenge.id = outChallenge.id; } + for(const challengeTag of processed.challengeTags) { + const creator = challengeTag.creator.username; + const challengeId = challengeTag.challenge.id; + const tagname = challengeTag.tag.tagname; + + await models.ditTag.create('challenge', challengeId, tagname, { }, creator); + } + + for(const challengeComment of processed.challengeComments) { + const creator = challengeComment.creator.username; + const challengeId = challengeComment.challenge.id; + const { content, created } = challengeComment; + const primary = { type: 'challenges', id: challengeId }; + + const newComment = await models.comment.create({ primary, creator, content, created }); + + // save the comment's id + challengeComment.id = newComment.id; + } + for(const reaction of processed.reactions) { const creator = reaction.creator.username; const commentId = reaction.comment.id; @@ -342,6 +364,32 @@ function processData(data) { return resp; }); + output.challengeTags = data.challengeTags.map(function ([_challenge, _tag]) { + const resp = { + _challenge, + _tag, + get challenge() { return output.challenges[this._challenge]; }, + get tag() { return output.tags[this._tag]; }, + get creator() { return this.challenge.creator; } + }; + + return resp; + }); + + output.challengeComments = data.challengeComments.map(([_challenge, _creator, attrs = { }], i) => { + const { content = `challenge comment ${i}`, created = Date.now() + 1000 * i } = attrs; + const resp = { + _creator, + _challenge, + get creator() { return output.users[this._creator]; }, + get challenge() { return output.challenges[this._challenge]; }, + content, + created + }; + + return resp; + }); + // put comments together Object.defineProperty(output, 'comments', { get: function () { return this.ideaComments; } }); diff --git a/test/ideas.js b/test/ideas.js index 21a8b2c..c7f68e4 100644 --- a/test/ideas.js +++ b/test/ideas.js @@ -7,7 +7,7 @@ const agentFactory = require('./agent'), dbHandle = require('./handle-database'), models = require(path.resolve('./models')); -describe.only('ideas', () => { +describe('ideas', () => { let agent, dbData, loggedUser; From ea9dc63432512c9952a9a8219a6ade0302a2b5e1 Mon Sep 17 00:00:00 2001 From: Agata-Andrzejewska Date: Tue, 17 Apr 2018 01:27:29 +0200 Subject: [PATCH 5/6] add comments tests for challenges --- app.js | 2 ++ test/comments.js | 38 +++++++++++++++++++------------------- test/handle-database.js | 2 +- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/app.js b/app.js index cd9f786..c5d1a79 100644 --- a/app.js +++ b/app.js @@ -79,6 +79,8 @@ app.use('/comments', require('./routes/votes')); // following are route factories // they need to know what is the primary object (i.e. idea, comment, etc.) app.use('/ideas', require('./routes/primary-comments')('idea')); +app.use('/challenges', require('./routes/primary-comments')('challenge')); + app.use('/comments', require('./routes/comments')('')); app.use('/comments', require('./routes/primary-comments')('comment')); app.use('/reactions', require('./routes/comments')('comment')); diff --git a/test/comments.js b/test/comments.js index b5c14f2..97f93db 100644 --- a/test/comments.js +++ b/test/comments.js @@ -9,6 +9,7 @@ const agentFactory = require('./agent'), models = require(path.resolve('./models')); commentTestsFactory('idea'); +commentTestsFactory('challenge'); commentTestsFactory('comment'); /** @@ -26,7 +27,6 @@ function commentTestsFactory(primary, only=false) { const ds = (only) ? describe.only : describe; - ds(`${comments} of ${primary}`, () => { // declare some variables @@ -71,13 +71,13 @@ function commentTestsFactory(primary, only=false) { const data = { users: 3, verifiedUsers: [0, 1], - ideas: Array(1).fill([]), - ideaComments: [[0, 0]] + ['ideas']: Array(1).fill([]), + ['ideaComments']: [[0, 0]] }; dbData = await dbHandle.fill(data); - existentPrimary = dbData.ideaComments[0]; + existentPrimary = dbData['ideaComments'][0]; loggedUser = dbData.users[0]; }); } else { @@ -86,7 +86,7 @@ function commentTestsFactory(primary, only=false) { const data = { users: 3, verifiedUsers: [0, 1], - ideas: Array(1).fill([]) + [`${primarys}`]: Array(1).fill([]) }; dbData = await dbHandle.fill(data); @@ -256,8 +256,8 @@ function commentTestsFactory(primary, only=false) { const data = { users: 4, verifiedUsers: [0, 1, 2, 3], - ideas: Array(3).fill([]), - ideaComments: [ + [`${primarys}`]: Array(3).fill([]), + [`${primary}Comments`]: [ [0, 0], [0, 1], [0, 1], [0, 2], [0, 1], [0, 1], [0, 0], [0, 3], [1, 0], [1, 0], [1, 2], [2, 0], [2, 1], [2, 1], [2, 2], [2, 1], [2, 1], [2, 0], [2, 3], @@ -268,7 +268,7 @@ function commentTestsFactory(primary, only=false) { dbData = await dbHandle.fill(data); - [primary0,, primary2] = dbData.ideas; + [primary0,, primary2] = dbData[`${primarys}`]; loggedUser = dbData.users[0]; }); @@ -427,8 +427,8 @@ function commentTestsFactory(primary, only=false) { const data = { users: 2, verifiedUsers: [0, 1], - ideas: Array(1).fill([]), - ideaComments: [[0, 0]], + ['ideas']: Array(1).fill([]), + ['ideaComments']: [[0, 0]], reactions: [[0, 0], [0, 1]] }; @@ -444,17 +444,17 @@ function commentTestsFactory(primary, only=false) { const data = { users: 2, verifiedUsers: [0, 1], - ideas: Array(1).fill([]), - ideaComments: [ + [`${primarys}`]: Array(1).fill([]), + [`${primary}Comments`]: [ [0, 0], [0, 1], ] }; dbData = await dbHandle.fill(data); - primary0 = dbData.ideas[0]; + primary0 = dbData[`${primarys}`][0]; [user0] = dbData.users; - [comment00, comment01] = dbData.ideaComments; + [comment00, comment01] = dbData[`${primary}Comments`]; }); } @@ -625,8 +625,8 @@ function commentTestsFactory(primary, only=false) { const data = { users: 2, verifiedUsers: [0, 1], - ideas: Array(1).fill([]), - ideaComments: [[0, 0]], + ['ideas']: Array(1).fill([]), + ['ideaComments']: [[0, 0]], reactions: [[0, 0], [0, 1]] }; @@ -641,8 +641,8 @@ function commentTestsFactory(primary, only=false) { const data = { users: 2, verifiedUsers: [0, 1], - ideas: Array(1).fill([]), - ideaComments: [ + [`${primarys}`]: Array(1).fill([]), + [`${primary}Comments`]: [ [0, 0], [0, 1], ], reactions: [[0, 0], [0, 1], [1, 0]] @@ -651,7 +651,7 @@ function commentTestsFactory(primary, only=false) { dbData = await dbHandle.fill(data); [user0] = dbData.users; - [comment00, comment01] = dbData.ideaComments; + [comment00, comment01] = dbData[`${primary}Comments`]; }); } diff --git a/test/handle-database.js b/test/handle-database.js index 928dc0d..dde1a96 100644 --- a/test/handle-database.js +++ b/test/handle-database.js @@ -391,7 +391,7 @@ function processData(data) { }); // put comments together - Object.defineProperty(output, 'comments', { get: function () { return this.ideaComments; } }); + Object.defineProperty(output, 'comments', { get: function () { return this.ideaComments.length === 0 ? this.challengeComments : this.ideaComments; } }); output.reactions = data.reactions.map(([_comment, _creator, attrs = { }], i) => { const { content = `reaction content ${i}`, created = Date.now() + 1000 * i } = attrs; From c8b2146b7ebe2afb2e73c54366d625606c9e2bfb Mon Sep 17 00:00:00 2001 From: Agata-Andrzejewska Date: Tue, 17 Apr 2018 23:29:46 +0200 Subject: [PATCH 6/6] create tests for dit-tags and make them pass --- app.js | 1 + controllers/dit-tags.js | 34 ++- controllers/dits.js | 4 +- controllers/validators/schema/index.js | 3 +- models/dit-tag/index.js | 150 ++++++----- serializers/index.js | 3 +- test/dit-tags.js | 339 +++++++++++++++++++++++++ test/votes.js | 1 + 8 files changed, 453 insertions(+), 82 deletions(-) create mode 100644 test/dit-tags.js diff --git a/app.js b/app.js index c5d1a79..e73f1cb 100644 --- a/app.js +++ b/app.js @@ -75,6 +75,7 @@ app.use('/challenges', require('./routes/challenges')); // vote for ideas, ... app.use('/ideas', require('./routes/votes')); app.use('/comments', require('./routes/votes')); +app.use('/challenges', require('./routes/votes')); // following are route factories // they need to know what is the primary object (i.e. idea, comment, etc.) diff --git a/controllers/dit-tags.js b/controllers/dit-tags.js index 334611d..d2325bf 100644 --- a/controllers/dit-tags.js +++ b/controllers/dit-tags.js @@ -10,16 +10,16 @@ const models = require(path.resolve('./models')), * Adds a tag to a dit */ async function post(req, res, next) { + let ditType; try { // gather data from request - const { ditType } = req.body; const { tagname } = req.body.tag; const ditId = req.params.id; const username = req.auth.username; + ditType = req.baseUrl.slice(1,-1); // save new dit-tag to database const newDitTag = await models.ditTag.create(ditType, ditId, tagname, { }, username); - // serialize response body let responseBody; switch(ditType){ @@ -33,13 +33,9 @@ async function post(req, res, next) { } } - // serialize response body - // const responseBody = serialize.ditTag(newDitTag); - // respond return res.status(201).json(responseBody); } catch (e) { - // handle errors switch (e.code) { // duplicate dit-tag @@ -54,7 +50,7 @@ async function post(req, res, next) { // dit creator is not me case 403: { return res.status(403).json({ errors: [ - { status: 403, detail: 'not logged in as dit creator' } + { status: 403, detail: `not logged in as ${ditType} creator` } ]}); } // unexpected error @@ -71,15 +67,27 @@ async function post(req, res, next) { * GET /dits/:id/tags */ async function get(req, res, next) { + let ditType; try { // read dit id const { id } = req.params; + ditType = req.baseUrl.slice(1, -1); // read ditTags from database - const ditTags = await models.ditTag.readTagsOfDit(id); + const newDitTags = await models.ditTag.readTagsOfDit(ditType, id); // serialize response body - const responseBody = serialize.ditTag(ditTags); + let responseBody; + switch(ditType){ + case 'idea': { + responseBody = serialize.ideaTag(newDitTags); + break; + } + case 'challenge': { + responseBody = serialize.challengeTag(newDitTags); + break; + } + } // respond return res.status(200).json(responseBody); @@ -88,7 +96,7 @@ async function get(req, res, next) { if (e.code === 404) { return res.status(404).json({ errors: [{ status: 404, - detail: '${ditType} not found' + detail: `${ditType} not found` }] }); } @@ -102,11 +110,13 @@ async function get(req, res, next) { * DELETE /dits/:id/tags/:tagname */ async function del(req, res, next) { + let ditType; try { const { id, tagname } = req.params; const { username } = req.auth; + ditType = req.baseUrl.slice(1, -1); - await models.ditTag.remove(id, tagname, username); + await models.ditTag.remove(ditType, id, tagname, username); return res.status(204).end(); } catch (e) { @@ -116,7 +126,7 @@ async function del(req, res, next) { } case 403: { return res.status(403).json({ errors: [ - { status: 403, detail: 'not logged in as ${ditType} creator' } + { status: 403, detail: `not logged in as ${ditType} creator` } ] }); } default: { diff --git a/controllers/dits.js b/controllers/dits.js index 99e1d5d..ededf84 100644 --- a/controllers/dits.js +++ b/controllers/dits.js @@ -10,8 +10,10 @@ const path = require('path'), async function post(req, res, next) { try { // gather data - const { title, detail, ditType } = req.body; + const { title, detail } = req.body; const creator = req.auth.username; + const ditType = req.baseUrl.slice(1,-1); + // save the dit to database const newDit = await models.dit.create(ditType, { title, detail, creator }); diff --git a/controllers/validators/schema/index.js b/controllers/validators/schema/index.js index 13208cc..d00150a 100644 --- a/controllers/validators/schema/index.js +++ b/controllers/validators/schema/index.js @@ -4,6 +4,7 @@ const account = require('./account'), authenticate = require('./authenticate'), avatar = require('./avatar'), challenges = require('./challenges'), + challengeTags = require('./challenge-tags'), comments = require('./comments'), contacts = require('./contacts'), definitions = require('./definitions'), @@ -17,5 +18,5 @@ const account = require('./account'), votes = require('./votes'); -module.exports = Object.assign({ definitions }, account, authenticate, avatar, challenges, +module.exports = Object.assign({ definitions }, account, authenticate, avatar, challenges, challengeTags, comments, contacts, ideas, ideaTags, messages, params, tags, users, userTags, votes); diff --git a/models/dit-tag/index.js b/models/dit-tag/index.js index ef71485..ef17989 100644 --- a/models/dit-tag/index.js +++ b/models/dit-tag/index.js @@ -4,14 +4,18 @@ const path = require('path'); const Model = require(path.resolve('./models/model')), schema = require('./schema'); +const ditsDictionary = { challenge: 'challenge', idea: 'idea' }; -class DitTag extends Model { +class DitTag extends Model { /** * Create ditTag in database */ static async create(ditType, ditId, tagname, ditTagInput, creatorUsername) { - // generate standard ideaTag + // allow just particular strings for a ditType + ditType = ditsDictionary[ditType]; + + // generate standard ditTag const ditTag = await schema(ditTagInput); // / STOPPED const query = ` @@ -22,15 +26,15 @@ class DitTag extends Model { // array of users (1 or 0) LET us = (FOR u IN users FILTER u.username == @creatorUsername RETURN u) // create the ditTag (if dit, tag and creator exist) - LET ditTag = (FOR d IN ds FOR t IN ts FOR u IN us FILTER u._id == d.creator + LET ${ditType}Tag = (FOR d IN ds FOR t IN ts FOR u IN us FILTER u._id == d.creator INSERT MERGE({ _from: d._id, _to: t._id, creator: u._id }, @ditTag) IN ${ditType}Tags RETURN KEEP(NEW, 'created'))[0] || { } // if ditTag was not created, default to empty object (to be able to merge later) // gather needed data LET creator = MERGE(KEEP(us[0], 'username'), us[0].profile) LET tag = KEEP(ts[0], 'tagname') - LET dit = MERGE(KEEP(us[0], 'title', 'detail'), { id: us[0]._key }) + LET ${ditType} = MERGE(KEEP(ds[0], 'title', 'detail'), { id: ds[0]._key }) // return data - RETURN MERGE(ditTag, { creator, tag, dit })`; + RETURN MERGE(${ditType}Tag, { creator, tag, ${ditType} })`; const params = { ditId, tagname, ditTag, creatorUsername }; @@ -51,17 +55,17 @@ class DitTag extends Model { function generateError(response) { let e; - // check that idea, tag and creator exist - const { dit, tag, creator } = response; - + // check that dit, tag and creator exist // some of them don't exist, then ditTag was not created - if (!(dit && tag && creator)) { + if (!(response[`${ditType}`] && response['tag'] && response['creator'])) { e = new Error('Not Found'); e.code = 404; e.missing = []; - ['dit', 'tag', 'creator'].forEach((potentialMissing) => { - if (!response[potentialMissing]) e.missing.push(potentialMissing); + [`${ditType}`, 'tag', 'creator'].forEach((potentialMissing) => { + if (!response[potentialMissing]){ + e.missing.push(potentialMissing); + } }); } else { // if all exist, then dit creator !== ditTag creator, not authorized @@ -75,100 +79,112 @@ class DitTag extends Model { } /** - * Read ideaTag from database + * Read ditTag from database */ - static async read(ideaId, tagname) { + static async read(ditType, ditId, tagname) { + const ditCollection = ditType + 's'; + const ditTags = ditType + 'Tags'; + // allow just particular strings for a ditType + ditType = ditsDictionary[ditType]; const query = ` FOR t IN tags FILTER t.tagname == @tagname - FOR i IN ideas FILTER i._key == @ideaId - FOR it IN ideaTags FILTER it._from == i._id AND it._to == t._id - LET creator = (FOR u IN users FILTER u._id == it.creator + FOR d IN @@ditCollection FILTER d._key == @ditId + FOR dt IN @@ditTags FILTER dt._from == d._id AND dt._to == t._id + LET creator = (FOR u IN users FILTER u._id == dt.creator RETURN MERGE(KEEP(u, 'username'), u.profile))[0] - LET ideaTag = KEEP(it, 'created') + LET ${ditType}Tag = KEEP(dt, 'created') LET tag = KEEP(t, 'tagname') - LET idea = MERGE(KEEP(i, 'title', 'detail'), { id: i._key }) - RETURN MERGE(ideaTag, { creator, tag, idea })`; - const params = { ideaId, tagname }; - + LET ${ditType} = MERGE(KEEP(d, 'title', 'detail'), { id: d._key }) + RETURN MERGE(${ditType}Tag, { creator, tag, ${ditType} })`; + const params = { ditId, tagname, '@ditCollection': ditCollection, '@ditTags': ditTags }; const cursor = await this.db.query(query, params); return (await cursor.all())[0]; } /** - * Read tags of idea + * Read tags of dit */ - static async readTagsOfIdea(ideaId) { + static async readTagsOfDit(ditType, ditId) { + const ditCollection = ditType + 's'; + const ditTag = ditType + 'Tags'; + // allow just particular strings for a ditType + ditType = ditsDictionary[ditType]; const query = ` - // read idea into array (length 1 or 0) - LET is = (FOR i IN ideas FILTER i._key == @ideaId RETURN i) - // read ideaTags - LET its = (FOR i IN is - FOR it IN ideaTags FILTER it._from == i._id - FOR t IN tags FILTER it._to == t._id + // read dit into array (length 1 or 0) + LET ds = (FOR d IN @@ditCollection FILTER d._key == @ditId RETURN d) + // read ditTags + LET dts = (FOR d IN ds + FOR dt IN @@ditTag FILTER dt._from == d._id + FOR t IN tags FILTER dt._to == t._id SORT t.tagname - LET ideaTag = KEEP(it, 'created') + LET ${ditType}Tag = KEEP(dt, 'created') LET tag = KEEP(t, 'tagname') - LET idea = MERGE(KEEP(i, 'title', 'detail'), { id: i._key }) - RETURN MERGE(ideaTag, { tag, idea }) + LET ${ditType} = MERGE(KEEP(d, 'title', 'detail'), { id: d._key }) + RETURN MERGE(${ditType}Tag, { tag, ${ditType} }) ) - RETURN { ideaTags: its, idea: is[0] }`; - const params = { ideaId }; + RETURN { ${ditType}Tags: dts, ${ditType}: ds[0] }`; + const params = { ditId, '@ditCollection': ditCollection, '@ditTag':ditTag }; const cursor = await this.db.query(query, params); - const [{ idea, ideaTags }] = await cursor.all(); - - // when idea not found, error - if (!idea) { - const e = new Error('idea not found'); + // const [{ dit, ditTags }] = await cursor.all(); + const ditTagsData = await cursor.all(); + // when dit not found, error + if (!ditTagsData[0][`${ditType}`]) { + const e = new Error(`${ditType} not found`); e.code = 404; throw e; } - return ideaTags; + return ditTagsData[0][`${ditType}Tags`]; } /** - * Remove ideaTag from database + * Remove ditTag from database */ - static async remove(ideaId, tagname, username) { + static async remove(ditType, ditId, tagname, username) { + const ditCollection = ditType + 's'; + const ditTags = ditType + 'Tags'; + // allow just particular strings for a ditType + ditType = ditsDictionary[ditType]; + const query = ` // find users (1 or 0) LET us = (FOR u IN users FILTER u.username == @username RETURN u) - // find ideas (1 or 0) - LET is = (FOR i IN ideas FILTER i._key == @ideaId RETURN i) - // find [ideaTag] between idea and tag specified (1 or 0) - LET its = (FOR i IN is + // find dits (1 or 0) + LET ds = (FOR i IN @@ditCollection FILTER i._key == @ditId RETURN i) + // find [ditTag] between dit and tag specified (1 or 0) + LET dts = (FOR i IN ds FOR t IN tags FILTER t.tagname == @tagname - FOR it IN ideaTags FILTER it._from == i._id AND it._to == t._id - RETURN it) - // find and remove [ideaTag] if and only if user is creator of idea - // is user authorized to remove the ideaTag in question? - LET itsdel = (FOR u IN us FOR i IN is FILTER u._id == i.creator - FOR it IN its - REMOVE it IN ideaTags - RETURN it) - // return [ideaTag] between idea and tag - RETURN its`; - - const params = { ideaId, tagname, username }; + FOR dt IN @@ditTags FILTER dt._from == i._id AND dt._to == t._id + RETURN dt) + // find and remove [ditTag] if and only if user is creator of dit + // is user authorized to remove the ditTag in question? + LET dtsdel = (FOR u IN us FOR d IN ds FILTER u._id == d.creator + FOR dt IN dts + REMOVE dt IN @@ditTags + RETURN dt) + // return [ditTag] between dit and tag + RETURN dts`; + + const params = { ditId, tagname, username, '@ditTags': ditTags, '@ditCollection': ditCollection}; // execute query and gather database response const cursor = await this.db.query(query, params); - const [matchedIdeaTags] = await cursor.all(); + const [matchedDitTags] = await cursor.all(); // return or error switch (cursor.extra.stats.writesExecuted) { - // ideaTag was removed: ok + // ditTag was removed: ok case 1: { return; } - // ideaTag was not removed: error + // ditTag was not removed: error case 0: { - throw generateError(matchedIdeaTags); + throw generateError(matchedDitTags); } // unexpected error default: { @@ -177,19 +193,19 @@ class DitTag extends Model { } /** - * When no ideaTag was removed, it can have 2 reasons: - * 1. ideaTag was not found - * 2. ideaTag was found, but the user is not creator of the idea + * When no ditTag was removed, it can have 2 reasons: + * 1. ditTag was not found + * 2. ditTag was found, but the user is not creator of the dit * therefore is not authorized to do so */ function generateError(response) { let e; if (response.length === 0) { - // ideaTag was not found + // ditTag was not found e = new Error('not found'); e.code = 404; } else { - // ideaTag was found, but user is not idea's creator + // ditTag was found, but user is not dit's creator e = new Error('not authorized'); e.code = 403; } diff --git a/serializers/index.js b/serializers/index.js index 57b117f..5a17220 100644 --- a/serializers/index.js +++ b/serializers/index.js @@ -3,6 +3,7 @@ const Deserializer = require('jsonapi-serializer').Deserializer; const challenges = require('./challenges'), + challengeTags = require('./challenge-tags'), comments = require('./comments'), contacts = require('./contacts'), ideas = require('./ideas'), @@ -45,6 +46,6 @@ function deserialize(req, res, next) { } module.exports = { - serialize: Object.assign({ }, challenges, comments, contacts, ideas, ideaTags, messages, tags, users, votes), + serialize: Object.assign({ }, challenges, challengeTags, comments, contacts, ideas, ideaTags, messages, tags, users, votes), deserialize }; diff --git a/test/dit-tags.js b/test/dit-tags.js new file mode 100644 index 0000000..ecf0946 --- /dev/null +++ b/test/dit-tags.js @@ -0,0 +1,339 @@ +'use strict'; + +const path = require('path'), + should = require('should'); + +const dbHandle = require('./handle-database'); +const agentFactory = require('./agent'); +const models = require(path.resolve('./models')); + +testDitsTags('idea'); +testDitsTags('challenge'); + +function testDitsTags(dit){ + describe(`tags of ${dit}`, () => { + + let agent, + dbData, + existentDit, + loggedUser, + otherUser, + tag0, + tag1; + + beforeEach(() => { + agent = agentFactory(); + }); + + beforeEach(async () => { + const data = { + users: 3, // how many users to make + verifiedUsers: [0, 1], // which users to make verified + tags: 5, + [`${dit}s`]: [ + [{ }, 0], + [{ }, 1] + ], + // ditTag [0, 0] shouldn't be created here; is created in tests for POST + [`${dit}Tags`]: [[0, 1], [0, 2], [0, 3], [0, 4], [1, 0], [1, 4]] + }; + // create data in database + dbData = await dbHandle.fill(data); + + [loggedUser, otherUser] = dbData.users; + [existentDit] = dbData[`${dit}s`]; + [tag0, tag1] = dbData.tags; + }); + + afterEach(async () => { + await dbHandle.clear(); + }); + + describe(`POST /${dit}s/:id/tags`, () => { + let postBody; + + beforeEach(() => { + postBody = { data: { + type: `${dit}-tags`, + relationships: { + tag: { data: { type: 'tags', id: tag0.tagname } } + } + } }; + }); + + context(`logged as ${dit} creator`, () => { + + beforeEach(() => { + agent = agentFactory.logged(loggedUser); + }); + + context('valid data', () => { + it(`[${dit} and tag exist and ${dit}Tag doesn't] 201`, async () => { + const response = await agent + .post(`/${dit}s/${existentDit.id}/tags`) + .send(postBody) + .expect(201); + const ditTagDb = await models.ditTag.read(dit, existentDit.id, tag0.tagname); + should(ditTagDb).match({ + [`${dit}`]: { id: existentDit.id }, + tag: { tagname: tag0.tagname }, + creator: { username: loggedUser.username } + }); + should(response.body).match({ + data: { + type: `${dit}-tags`, + id: `${existentDit.id}--${tag0.tagname}`, + relationships: { + [`${dit}`]: { data: { type: `${dit}s`, id: existentDit.id } }, + tag: { data: { type: 'tags', id: tag0.tagname } }, + creator: { data: { type: 'users', id: loggedUser.username } } + } + } + }); + }); + + it(`[duplicate ${dit}Tag] 409`, async () => { + // first it's ok + await agent + .post(`/${dit}s/${existentDit.id}/tags`) + .send(postBody) + .expect(201); + + // duplicate request should error + await agent + .post(`/${dit}s/${existentDit.id}/tags`) + .send(postBody) + .expect(409); + }); + + it(`[${dit} doesn't exist] 404`, async () => { + const response = await agent + .post(`/${dit}s/00000000/tags`) + .send(postBody) + .expect(404); + + should(response.body).deepEqual({ + errors: [{ + status: 404, + detail: `${dit} not found` + }] + }); + }); + + it('[tag doesn\'t exist] 404', async () => { + // set nonexistent tag in body + postBody.data.relationships.tag.data.id = 'nonexistent-tag'; + + const response = await agent + .post(`/${dit}s/${existentDit.id}/tags`) + .send(postBody) + .expect(404); + + should(response.body).deepEqual({ + errors: [{ + status: 404, + detail: 'tag not found' + }] + }); + }); + + }); + + context('invalid data', () => { + it('[invalid id] 400', async () => { + await agent + .post(`/${dit}s/invalid-id/tags`) + .send(postBody) + .expect(400); + }); + + it('[invalid tagname] 400', async () => { + // invalidate tagname + postBody.data.relationships.tag.data.id = 'invalidTagname'; + + await agent + .post(`/${dit}s/${existentDit.id}/tags`) + .send(postBody) + .expect(400); + }); + + it('[missing tagname] 400', async () => { + // invalidate tagname + delete postBody.data.relationships.tag; + + await agent + .post(`/${dit}s/${existentDit.id}/tags`) + .send(postBody) + .expect(400); + }); + + it('[additional properties in body] 400', async () => { + // add some attributes (or relationships) + postBody.data.attributes = { foo: 'bar' }; + + await agent + .post(`/${dit}s/${existentDit.id}/tags`) + .send(postBody) + .expect(400); + }); + }); + + }); + + context(`logged, not ${dit} creator`, () => { + beforeEach(() => { + agent = agentFactory.logged(otherUser); + }); + + it('403', async () => { + const response = await agent + .post(`/${dit}s/${existentDit.id}/tags`) + .send(postBody) + .expect(403); + + should(response.body).deepEqual({ + errors: [{ status: 403, detail: `not logged in as ${dit} creator` }] + }); + }); + }); + + context('not logged', () => { + it('403', async () => { + await agent + .post(`/${dit}s/${existentDit.id}/tags`) + .send(postBody) + .expect(403); + }); + }); + }); + + describe(`GET /${dit}s/:id/tags`, () => { + context('logged', () => { + + beforeEach(() => { + agent = agentFactory.logged(); + }); + + context('valid data', () => { + it(`[${dit} exists] 200 and list of ${dit}-tags`, async () => { + const response = await agent + .get(`/${dit}s/${existentDit.id}/tags`) + .expect(200); + + const responseData = response.body.data; + + should(responseData).Array().length(4); + }); + + it(`[${dit} doesn't exist] 404`, async () => { + const response = await agent + .get(`/${dit}s/00000001/tags`) + .expect(404); + + should(response.body).match({ errors: [{ + status: 404, + detail: `${dit} not found` + }] }); + }); + }); + + context('invalid data', () => { + it('[invalid id] 400', async () => { + await agent + .get(`/${dit}s/invalidId/tags`) + .expect(400); + }); + }); + + }); + + context('not logged', () => { + it('403', async () => { + await agent + .get(`/${dit}s/${existentDit.id}/tags`) + .expect(403); + }); + }); + + }); + + describe(`DELETE /${dit}s/:id/tags/:tagname`, () => { + + context(`logged as ${dit} creator`, () => { + + beforeEach(() => { + agent = agentFactory.logged(loggedUser); + }); + + context('valid data', () => { + it(`[${dit}-tag exists] 204`, async () => { + const ditTag = await models.ditTag.read(dit, existentDit.id, tag1.tagname); + + // first ditTag exists + should(ditTag).Object(); + + await agent + .delete(`/${dit}s/${existentDit.id}/tags/${tag1.tagname}`) + .expect(204); + + const ditTagAfter = await models.ditTag.read(dit, existentDit.id, tag1.tagname); + // the ditTag doesn't exist + should(ditTagAfter).be.undefined(); + + }); + + it(`[${dit}-tag doesn't exist] 404`, async () => { + await agent + .delete(`/${dit}s/${existentDit.id}/tags/${tag0.tagname}`) + .expect(404); + }); + }); + + context('invalid data', () => { + it('[invalid id] 400', async () => { + await agent + .delete(`/${dit}s/invalid-id/tags/${tag1.tagname}`) + .expect(400); + }); + + it('[invalid tagname] 400', async () => { + await agent + .delete(`/${dit}s/${existentDit.id}/tags/invalid--tagname`) + .expect(400); + }); + }); + + }); + + context(`logged, not ${dit} creator`, () => { + + beforeEach(() => { + agent = agentFactory.logged(otherUser); + }); + + it('403', async () => { + const response = await agent + .delete(`/${dit}s/${existentDit.id}/tags/${tag1.tagname}`) + .expect(403); + + should(response.body).deepEqual({ + errors: [{ status: 403, detail: `not logged in as ${dit} creator` }] + }); + }); + }); + + context('not logged', () => { + it('403', async () => { + const response = await agent + .delete(`/${dit}s/${existentDit.id}/tags/${tag1.tagname}`) + .expect(403); + + should(response.body).not.deepEqual({ + errors: [{ status: 403, detail: `not logged in as ${dit} creator` }] + }); + }); + }); + + }); + }); +} \ No newline at end of file diff --git a/test/votes.js b/test/votes.js index 83c7d7d..87cd916 100644 --- a/test/votes.js +++ b/test/votes.js @@ -9,6 +9,7 @@ const agentFactory = require('./agent'), voteTestFactory('idea'); voteTestFactory('comment'); +voteTestFactory('challenge'); /** * We can test votes to different objects.