diff --git a/test/api/v4/content/GET-content.test.js b/test/api/v4/content/GET-content.test.js new file mode 100644 index 00000000000..1e13064bfb3 --- /dev/null +++ b/test/api/v4/content/GET-content.test.js @@ -0,0 +1,87 @@ +import { + requester, + translate as t, + generateUser, +} from '../../../helpers/api-integration/v4'; +import i18n from '../../../../website/common/script/i18n'; + +describe('GET /content', () => { + it('returns content (and does not require authentication)', async () => { + const res = await requester().get('/content'); + expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach'); + expect(res.backgrounds.backgrounds062014.beach.text).to.equal(t('backgroundBeachText')); + }); + + it('returns content not in English', async () => { + const res = await requester().get('/content?language=de'); + expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach'); + expect(res.backgrounds.backgrounds062014.beach.text).to.equal(i18n.t('backgroundBeachText', 'de')); + }); + + it('falls back to English if the desired language is not found', async () => { + const res = await requester().get('/content?language=wrong'); + expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach'); + expect(res.backgrounds.backgrounds062014.beach.text).to.equal(t('backgroundBeachText')); + }); + + it('filters out starter task content by default', async () => { + const res = await requester().get('/content'); + expect(res).to.have.nested.property('backgrounds.backgrounds062014'); + expect(res).to.have.nested.property('gear.tree'); + expect(res).to.not.have.nested.property('userDefaults'); + expect(res).to.not.have.nested.property('tasksByCategory'); + expect(res).to.not.have.nested.property('userDefaultsMobile'); + }); + + it('filters content automatically for iOS requests', async () => { + const res = await requester(null, { 'x-client': 'habitica-ios' }).get('/content'); + expect(res).to.have.nested.property('appearances.background.beach'); + expect(res).to.not.have.nested.property('backgrounds.backgrounds062014'); + expect(res).to.not.have.property('backgroundsFlat'); + expect(res).to.not.have.nested.property('gear.tree'); + }); + + it('filters content automatically for Android requests', async () => { + const res = await requester(null, { 'x-client': 'habitica-android' }).get('/content'); + expect(res).to.not.have.nested.property('appearances.background.beach'); + expect(res).to.have.nested.property('backgrounds.backgrounds062014'); + expect(res).to.not.have.property('backgroundsFlat'); + expect(res).to.not.have.nested.property('gear.tree'); + }); + + it('filters content if the request specifies a filter', async () => { + const res = await requester().get('/content?filter=backgroundsFlat,gear.flat'); + expect(res).to.not.have.property('backgroundsFlat'); + expect(res).to.have.nested.property('gear.tree'); + expect(res).to.not.have.nested.property('gear.flat'); + }); + + it('filters content if the request contains invalid filters', async () => { + const res = await requester().get('/content?filter=backgroundsFlat,invalid'); + expect(res).to.not.have.property('backgroundsFlat'); + }); + + describe('authenticated user', () => { + let user; + it('returns content in user\'s preferred language when no language parameter is provided', async () => { + user = await generateUser({ 'preferences.language': 'de' }); + const res = await user.get('/content'); + expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach'); + expect(res.backgrounds.backgrounds062014.beach.text).to.equal(i18n.t('backgroundBeachText', 'de')); + }); + + it('respects language parameter over user\'s preferred language', async () => { + user = await generateUser({ 'preferences.language': 'de' }); + const res = await user.get('/content?language=fr'); + expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach'); + expect(res.backgrounds.backgrounds062014.beach.text).to.equal(i18n.t('backgroundBeachText', 'fr')); + }); + + it('falls back to English if user\'s preferred language is invalid', async () => { + user = await generateUser({ 'preferences.language': 'invalid_lang' }); + const res = await user.get('/content'); + expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach'); + expect(res.backgrounds.backgrounds062014.beach.text).to.equal(t('backgroundBeachText')); + }); + }); +}); diff --git a/website/server/controllers/api-v4/content.js b/website/server/controllers/api-v4/content.js new file mode 100644 index 00000000000..ebcf409c672 --- /dev/null +++ b/website/server/controllers/api-v4/content.js @@ -0,0 +1,99 @@ +import nconf from 'nconf'; +import { langCodes } from '../../libs/i18n'; +import { + serveContent, + ANDROID_FILTER, + IOS_FILTER, + V4_FILTER, +} from '../../libs/content'; +import { authWithHeaders } from '../../middlewares/auth'; + +const IS_PROD = nconf.get('IS_PROD'); + +const api = {}; + +/** + * @api {get} /api/v4/content Get all available content objects + * @apiDescription Does not require authentication. + * @apiName ContentGet + * @apiGroup Content + * + * @apiParam (Query) {String="bg","cs","da","de", + * "en","en@pirate","en_GB", + * "es","es_419","fr","he","hu", + * "id","it","ja","nl","pl","pt","pt_BR", + * "ro","ru","sk","sr","sv", + * "uk","zh","zh_TW"} [language=en] Language code used for the items' + * strings. If the authenticated user makes + * the request, the content will return with + * the user's configured language. + * + * @apiSuccess {Object} data Various data about the content of Habitica. The content route + * contains many keys, but the data listed below are the recommended data to use. + * @apiSuccess {Object} data.mystery The mystery sets awarded to paying subscribers. + * @apiSuccess {Object} data.gear The gear that can be equipped. + * @apiSuccess {Object} data.gear.tree Detailed information about the gear, organized by type. + * @apiSuccess {Object} data.gear.flat The full key of each equipment. + * @apiSuccess {Object} data.spells The skills organized by class. Includes cards and visual buffs. + * @apiSuccess {Object} data.potion Data about the health potion. + * @apiSuccess {Object} data.armoire Data about the armoire. + * @apiSuccess {Array} data.classes The available classes. + * @apiSuccess {Object} data.eggs All available eggs. + * @apiSuccess {Object} data.timeTravelStable The animals available + * in the Time Traveler's stable, separated + * into pets and mounts. + * @apiSuccess {Object} data.hatchingPotions All the hatching potions. + * @apiSuccess {Object} data.petInfo All the pets with extra info. + * @apiSuccess {Object} data.mountInfo All the mounts with extra info. + * @apiSuccess {Object} data.food All the food. + * @apiSuccess {Array} data.userCanOwnQuestCategories The types of quests that a user can own. + * @apiSuccess {Object} data.quests Data about the quests. + * @apiSuccess {Object} data.appearances Data about the appearance properties. + * @apiSuccess {Object} data.appearances.hair Data about available hair options. + * @apiSuccess {Object} data.appearances.shirt Data about available shirt options. + * @apiSuccess {Object} data.appearances.size Data about available body size options. + * @apiSuccess {Object} data.appearances.skin Data about available skin options. + * @apiSuccess {Object} data.appearances.chair Data about available chair options. + * @apiSuccess {Object} data.appearances.background Data about available background options. + * @apiSuccess {Object} data.backgrounds Data about the background sets. + * @apiSuccess {Object} data.subscriptionBlocks Data about the various subscriptions blocks. + * + */ +api.getContent = { + method: 'GET', + url: '/content', + noLanguage: true, + middlewares: [authWithHeaders({ optional: true })], + async handler (req, res) { + let language = 'en'; + const proposedLang = req.query.language; + + if (proposedLang && langCodes.includes(proposedLang)) { + language = proposedLang; + } else if (res.locals.user + && res.locals.user.preferences + && res.locals.user.preferences.language + ) { + const userLang = res.locals.user.preferences.language; + if (langCodes.includes(userLang)) { + language = userLang; + } + } + + let filter = req.query.filter || ''; + // apply defaults for mobile clients + if (filter === '') { + if (req.headers['x-client'] === 'habitica-android') { + filter = [V4_FILTER, ANDROID_FILTER].join(','); + } else if (req.headers['x-client'] === 'habitica-ios') { + filter = [V4_FILTER, IOS_FILTER].join(','); + } else { + filter = V4_FILTER; + } + } + + serveContent(res, language, filter, IS_PROD); + }, +}; + +export default api; diff --git a/website/server/libs/content.js b/website/server/libs/content.js index b4f57508ac9..b1351a8f705 100644 --- a/website/server/libs/content.js +++ b/website/server/libs/content.js @@ -17,6 +17,7 @@ const MOBILE_FILTER = ['achievements', 'questSeriesAchievements', 'animalColorAc export const ANDROID_FILTER = [...MOBILE_FILTER, 'appearances.background'].join(','); export const IOS_FILTER = [...MOBILE_FILTER, 'backgrounds'].join(','); +export const V4_FILTER = 'userDefaults,tasksByCategory,userDefaultsMobile'; function getDay (date) { if (date === undefined) { diff --git a/website/server/middlewares/appRoutes.js b/website/server/middlewares/appRoutes.js index 33b3b325694..24400338297 100644 --- a/website/server/middlewares/appRoutes.js +++ b/website/server/middlewares/appRoutes.js @@ -42,6 +42,7 @@ app.use('/api/v3', v3RateLimiter, v3Router); const v4RouterOverrides = [ // 'GET-/status', Example to override the GET /status api call 'POST-/user/auth/local/register', + 'GET-/content', 'GET-/news', 'GET-/user', 'PUT-/user',