diff --git a/test/api/v3/integration/groups/GET-groups.test.js b/test/api/v3/integration/groups/GET-groups.test.js index 8e1e9dfbfc..756a315006 100644 --- a/test/api/v3/integration/groups/GET-groups.test.js +++ b/test/api/v3/integration/groups/GET-groups.test.js @@ -7,6 +7,7 @@ import { import { TAVERN_ID, } from '../../../../../website/server/models/group'; +import apiMessages from '../../../../../website/server/libs/apiMessages'; describe('GET /groups', () => { let user; @@ -14,6 +15,7 @@ describe('GET /groups', () => { const NUMBER_OF_PUBLIC_GUILDS_USER_IS_MEMBER = 1; const NUMBER_OF_USERS_PRIVATE_GUILDS = 1; const NUMBER_OF_GROUPS_USER_CAN_VIEW = 5; + const GUILD_PER_PAGE = 30; before(async () => { await resetHabiticaDB(); @@ -98,6 +100,60 @@ describe('GET /groups', () => { .to.eventually.have.a.lengthOf(NUMBER_OF_PUBLIC_GUILDS); }); + describe('public guilds pagination', () => { + it('req.query.paginate must be a boolean string', async () => { + await expect(user.get('/groups?paginate=aString&type=publicGuilds')) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Invalid request parameters.', + }); + }); + + it('req.query.paginate can only be true when req.query.type includes publicGuilds', async () => { + await expect(user.get('/groups?paginate=true&type=notPublicGuilds')) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: apiMessages('guildsOnlyPaginate'), + }); + }); + + it('req.query.page can\'t be negative', async () => { + await expect(user.get('/groups?paginate=true&page=-1&type=publicGuilds')) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Invalid request parameters.', + }); + }); + + it('returns 30 guilds per page ordered by number of members', async () => { + await user.update({balance: 9000}); + let groups = await Promise.all(_.times(60, (i) => { + return generateGroup(user, { + name: `public guild ${i} - is member`, + type: 'guild', + privacy: 'public', + }); + })); + + // update group number 32 and not the first to make sure sorting works + await groups[32].update({name: 'guild with most members', memberCount: 199}); + await groups[33].update({name: 'guild with less members', memberCount: -100}); + + let page0 = await expect(user.get('/groups?type=publicGuilds&paginate=true')) + .to.eventually.have.a.lengthOf(GUILD_PER_PAGE); + expect(page0[0].name).to.equal('guild with most members'); + + await expect(user.get('/groups?type=publicGuilds&paginate=true&page=1')) + .to.eventually.have.a.lengthOf(GUILD_PER_PAGE); + let page2 = await expect(user.get('/groups?type=publicGuilds&paginate=true&page=2')) + .to.eventually.have.a.lengthOf(1 + 2); // 1 created now, 2 by other tests + expect(page2[2].name).to.equal('guild with less members'); + }); + }); + it('returns all the user\'s guilds when guilds passed in as query', async () => { await expect(user.get('/groups?type=guilds')) .to.eventually.have.a.lengthOf(NUMBER_OF_PUBLIC_GUILDS_USER_IS_MEMBER + NUMBER_OF_USERS_PRIVATE_GUILDS); diff --git a/test/api/v3/unit/libs/apiMessages.js b/test/api/v3/unit/libs/apiMessages.js new file mode 100644 index 0000000000..09ff86f5ba --- /dev/null +++ b/test/api/v3/unit/libs/apiMessages.js @@ -0,0 +1,31 @@ +import apiMessages from '../../../../../website/server/libs/apiMessages'; + +describe('API Messages', () => { + const message = 'Only public guilds support pagination.'; + it('returns an API message', () => { + expect(apiMessages('guildsOnlyPaginate')).to.equal(message); + }); + + it('throws if the API message does not exist', () => { + expect(() => apiMessages('iDoNotExist')).to.throw; + }); + + it('clones the passed variables', () => { + let vars = {a: 1}; + sandbox.stub(_, 'clone').returns({}); + apiMessages('guildsOnlyPaginate', vars); + expect(_.clone).to.have.been.called.once; + expect(_.clone).to.have.been.calledWith(vars); + }); + + it('pass the message through _.template', () => { + let vars = {a: 1}; + let stub = sinon.stub().returns('string'); + sandbox.stub(_, 'template').returns(stub); + apiMessages('guildsOnlyPaginate', vars); + expect(_.template).to.have.been.called.once; + expect(_.template).to.have.been.calledWith(message); + expect(stub).to.have.been.called.once; + expect(stub).to.have.been.calledWith(vars); + }); +}); diff --git a/test/content/translaotr.js b/test/content/translaotr.js index 609973f692..fee9d37672 100644 --- a/test/content/translaotr.js +++ b/test/content/translaotr.js @@ -4,7 +4,7 @@ import translator from '../../website/common/script/content/translation'; describe('Translator', () => { it('returns error message if string is not properly formatted', () => { let improperlyFormattedString = translator('petName', {attr: 0})(); - expect(improperlyFormattedString).to.eql(STRING_ERROR_MSG); + expect(improperlyFormattedString).to.match(STRING_ERROR_MSG); }); it('returns an error message if string does not exist', () => { diff --git a/test/helpers/content.helper.js b/test/helpers/content.helper.js index 8eacadcb46..0470f04114 100644 --- a/test/helpers/content.helper.js +++ b/test/helpers/content.helper.js @@ -2,7 +2,7 @@ require('./globals.helper'); import i18n from '../../website/common/script/i18n'; i18n.translations = require('../../website/server/libs/i18n').translations; -export const STRING_ERROR_MSG = 'Error processing the string. Please see Help > Report a Bug.'; +export const STRING_ERROR_MSG = /^Error processing the string ".*". Please see Help > Report a Bug.$/; export const STRING_DOES_NOT_EXIST_MSG = /^String '.*' not found.$/; export function expectValidTranslationString (attribute) { diff --git a/test/helpers/mongo.js b/test/helpers/mongo.js index e0a1d639ab..94ce48adc9 100644 --- a/test/helpers/mongo.js +++ b/test/helpers/mongo.js @@ -74,6 +74,7 @@ export async function resetHabiticaDB () { name: 'HabitRPG', type: 'guild', privacy: 'public', + memberCount: 0, }, (insertErr2) => { if (insertErr2) return reject(insertErr2); diff --git a/website/common/script/i18n.js b/website/common/script/i18n.js index 17dee9a333..3b8331e892 100644 --- a/website/common/script/i18n.js +++ b/website/common/script/i18n.js @@ -41,7 +41,7 @@ function t (stringName) { try { return template(string)(clonedVars); } catch (_error) { - return 'Error processing the string. Please see Help > Report a Bug.'; + return `Error processing the string "${stringName}". Please see Help > Report a Bug.`; } } else { let stringNotFound; @@ -57,7 +57,7 @@ function t (stringName) { string: stringName, }); } catch (_error) { - return 'Error processing the string. Please see Help > Report a Bug.'; + return 'Error processing the string "stringNotFound". Please see Help > Report a Bug.'; } } } diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js index efeef635a9..1a0d6445a1 100644 --- a/website/server/controllers/api-v3/groups.js +++ b/website/server/controllers/api-v3/groups.js @@ -25,7 +25,7 @@ import payments from '../../libs/payments'; import stripePayments from '../../libs/stripePayments'; import amzLib from '../../libs/amazonPayments'; import shared from '../../../common'; - +import apiMessages from '../../libs/apiMessages'; /** * @apiDefine GroupBodyInvalid @@ -252,6 +252,8 @@ api.createGroupPlan = { * @apiGroup Group * * @apiParam {String} type The type of groups to retrieve. Must be a query string representing a list of values like 'tavern,party'. Possible values are party, guilds, privateGuilds, publicGuilds, tavern + * @apiParam {String="true","false"} [paginate] Public guilds support pagination. When true guilds are returned in groups of 30 + * @apiParam {Number} [page] When pagination is enabled for public guilds this parameter can be used to specify the page number (the initial page is number 0 and not required) * * @apiParamExample {json} Private Guilds, Tavern: * { @@ -259,6 +261,9 @@ api.createGroupPlan = { * } * * @apiError (400) {BadRequest} groupTypesRequired Group types are required + * @apiError (400) {BadRequest} guildsPaginateBooleanString Paginate query parameter must be a boolean (true or false) + * @apiError (400) {BadRequest} guildsPageInteger Page query parameter must be a positive integer + * @apiError (400) {BadRequest} guildsOnlyPaginate Only public guilds support pagination * * @apiSuccess {Object[]} data An array of the requested groups (See /website/server/models/group.js) * @@ -276,15 +281,27 @@ api.getGroups = { let user = res.locals.user; req.checkQuery('type', res.t('groupTypesRequired')).notEmpty(); + // pagination options, can only be used with public guilds + req.checkQuery('paginate').optional().isIn(['true', 'false'], apiMessages('guildsPaginateBooleanString')); + req.checkQuery('page').optional().isInt({min: 0}, apiMessages('guildsPageInteger')); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; let types = req.query.type.split(','); + + let paginate = req.query.paginate === 'true' ? true : false; + if (paginate && !_.includes(types, 'publicGuilds')) { + throw new BadRequest(apiMessages('guildsOnlyPaginate')); + } + let groupFields = basicGroupFields.concat(' description memberCount balance'); let sort = '-memberCount'; - let results = await Group.getGroups({user, types, groupFields, sort}); + let results = await Group.getGroups({ + user, types, groupFields, sort, + paginate, page: req.query.page, + }); res.respond(200, results); }, }; diff --git a/website/server/libs/apiMessages.js b/website/server/libs/apiMessages.js new file mode 100644 index 0000000000..7770c87dba --- /dev/null +++ b/website/server/libs/apiMessages.js @@ -0,0 +1,21 @@ +// A map of messages used by the API that don't need to be translated and +// so are not placed into /common/locales + +import _ from 'lodash'; + +// When this file grows, it can be split into multiple ones. +const messages = { + guildsOnlyPaginate: 'Only public guilds support pagination.', + guildsPaginateBooleanString: 'req.query.paginate must be a boolean string.', + guildsPageInteger: 'req.query.page must be an integer greater than or equal to 0.', +}; + +export default function (msgKey, vars = {}) { + let message = messages[msgKey]; + if (!message) throw new Error(`Error processing the API message "${msgKey}".`); + + let clonedVars = vars ? _.clone(vars) : {}; + + // TODO cache the result of template() ? More memory usage, faster output + return _.template(message)(clonedVars); +} \ No newline at end of file diff --git a/website/server/models/group.js b/website/server/models/group.js index 19efe3065c..1a755b2d7d 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -204,8 +204,14 @@ schema.statics.getGroup = async function getGroup (options = {}) { export const VALID_QUERY_TYPES = ['party', 'guilds', 'privateGuilds', 'publicGuilds', 'tavern']; +const GUILDS_PER_PAGE = 30; // number of guilds to return per page when using pagination + schema.statics.getGroups = async function getGroups (options = {}) { - let {user, types, groupFields = basicFields, sort = '-memberCount', populateLeader = false} = options; + let { + user, types, groupFields = basicFields, + sort = '-memberCount', populateLeader = false, + paginate = false, page = 0, // optional pagination for public guilds + } = options; let queries = []; // Throw error if an invalid type is supplied @@ -247,6 +253,7 @@ schema.statics.getGroups = async function getGroups (options = {}) { privacy: 'public', }).select(groupFields); if (populateLeader === true) publicGuildsQuery.populate('leader', nameFields); + if (paginate === true) publicGuildsQuery.limit(GUILDS_PER_PAGE).skip(page * GUILDS_PER_PAGE); publicGuildsQuery.sort(sort).lean().exec(); queries.push(publicGuildsQuery); break;