api: add pagination for guilds

start adding apiMessages

add apiMessages lib with tests

use apiMessage and fix tests

fix content tests

guilds pagination: add api docs

guilds pagination: improve api docs
This commit is contained in:
Matteo Pagliazzi
2017-03-10 14:14:42 +01:00
parent 767763fbf6
commit 939712ad1f
9 changed files with 140 additions and 7 deletions

View File

@@ -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);

View File

@@ -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);
});
});

View File

@@ -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', () => {

View File

@@ -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) {

View File

@@ -74,6 +74,7 @@ export async function resetHabiticaDB () {
name: 'HabitRPG',
type: 'guild',
privacy: 'public',
memberCount: 0,
}, (insertErr2) => {
if (insertErr2) return reject(insertErr2);

View File

@@ -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.';
}
}
}

View File

@@ -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 <a href="https://github.com/HabitRPG/habitica/blob/develop/website/server/models/group.js" target="_blank">/website/server/models/group.js</a>)
*
@@ -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);
},
};

View File

@@ -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);
}

View File

@@ -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;