diff --git a/test/api/unit/libs/content.test.js b/test/api/unit/libs/content.test.js index 368d8fe9fd..36a626151f 100644 --- a/test/api/unit/libs/content.test.js +++ b/test/api/unit/libs/content.test.js @@ -1,5 +1,9 @@ +import fs from 'fs'; import * as contentLib from '../../../../website/server/libs/content'; import content from '../../../../website/common/script/content'; +import { + generateRes, +} from '../../../helpers/api-unit.helper'; describe('contentLib', () => { describe('CONTENT_CACHE_PATH', () => { @@ -13,5 +17,88 @@ describe('contentLib', () => { contentLib.getLocalizedContentResponse(); expect(typeof content.backgrounds.backgrounds062014.beach.text).to.equal('function'); }); + + it('removes keys from the content data', () => { + const response = contentLib.localizeContentData(content, 'en', { backgroundsFlat: true, dropHatchingPotions: true }); + expect(response.backgroundsFlat).to.not.exist; + expect(response.backgrounds).to.exist; + expect(response.dropHatchingPotions).to.not.exist; + expect(response.hatchingPotions).to.exist; + }); + + it('removes nested keys from the content data', () => { + const response = contentLib.localizeContentData(content, 'en', { gear: { tree: true } }); + expect(response.gear.tree).to.not.exist; + expect(response.gear.flat).to.exist; + }); + }); + + it('generates a hash for a filter', () => { + const hash = contentLib.hashForFilter('backgroundsFlat,gear.flat'); + expect(hash).to.equal('-1791877526'); + }); + + it('serves content', () => { + const resSpy = generateRes(); + contentLib.serveContent(resSpy, 'en', '', false); + expect(resSpy.send).to.have.been.calledOnce; + }); + + it('serves filtered content', () => { + const resSpy = generateRes(); + contentLib.serveContent(resSpy, 'en', 'backgroundsFlat,gear.flat', false); + expect(resSpy.send).to.have.been.calledOnce; + }); + + describe('caches content', async () => { + let resSpy; + beforeEach(() => { + resSpy = generateRes(); + fs.rmdirSync(contentLib.CONTENT_CACHE_PATH, { recursive: true }); + fs.mkdirSync(contentLib.CONTENT_CACHE_PATH); + }); + + it('does not cache requests in development mode', async () => { + contentLib.serveContent(resSpy, 'en', '', false); + expect(fs.existsSync(`${contentLib.CONTENT_CACHE_PATH}en.json`)).to.be.false; + }); + + it('caches unfiltered requests', async () => { + expect(fs.existsSync(`${contentLib.CONTENT_CACHE_PATH}en.json`)).to.be.false; + contentLib.serveContent(resSpy, 'en', '', true); + expect(fs.existsSync(`${contentLib.CONTENT_CACHE_PATH}en.json`)).to.be.true; + }); + + it('serves cached requests', async () => { + fs.writeFileSync( + `${contentLib.CONTENT_CACHE_PATH}en.json`, + '{"success": true, "data": {"all": {}}}', + 'utf8', + ); + contentLib.serveContent(resSpy, 'en', '', true); + expect(resSpy.sendFile).to.have.been.calledOnce; + expect(resSpy.sendFile).to.have.been.calledWith(`${contentLib.CONTENT_CACHE_PATH}en.json`); + }); + + it('caches filtered requests', async () => { + const filter = 'backgroundsFlat,gear.flat'; + const hash = contentLib.hashForFilter(filter); + expect(fs.existsSync(`${contentLib.CONTENT_CACHE_PATH}en${hash}.json`)).to.be.false; + contentLib.serveContent(resSpy, 'en', filter, true); + expect(fs.existsSync(`${contentLib.CONTENT_CACHE_PATH}en${hash}.json`)).to.be.true; + }); + + it('serves filtered cached requests', async () => { + const filter = 'backgroundsFlat,gear.flat'; + const hash = contentLib.hashForFilter(filter); + fs.writeFileSync( + `${contentLib.CONTENT_CACHE_PATH}en${hash}.json`, + '{"success": true, "data": {}}', + 'utf8', + ); + contentLib.serveContent(resSpy, 'en', filter, true); + expect(resSpy.sendFile).to.have.been.calledOnce; + expect(resSpy.sendFile).to.have.been.calledWith(`${contentLib.CONTENT_CACHE_PATH}en${hash}.json`); + }); }); }); diff --git a/test/api/v3/integration/content/GET-content.test.js b/test/api/v3/integration/content/GET-content.test.js index a241058be5..0dbd2779e4 100644 --- a/test/api/v3/integration/content/GET-content.test.js +++ b/test/api/v3/integration/content/GET-content.test.js @@ -22,4 +22,38 @@ describe('GET /content', () => { expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach'); expect(res.backgrounds.backgrounds062014.beach.text).to.equal(t('backgroundBeachText')); }); + + it('does not filter content for regular requests', async () => { + const res = await requester().get('/content'); + expect(res).to.have.nested.property('backgrounds.backgrounds062014'); + expect(res).to.have.nested.property('gear.tree'); + }); + + 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'); + }); }); diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index db867eb391..32eb04da31 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -40,6 +40,7 @@ export function generateRes (options = {}) { redirect: sandbox.stub(), render: sandbox.stub(), send: sandbox.stub(), + sendFile: sandbox.stub(), sendStatus: sandbox.stub().returnsThis(), set: sandbox.stub(), status: sandbox.stub().returnsThis(), diff --git a/website/server/controllers/api-v3/content.js b/website/server/controllers/api-v3/content.js index 33bb64328e..5c3a3c5fd4 100644 --- a/website/server/controllers/api-v3/content.js +++ b/website/server/controllers/api-v3/content.js @@ -1,34 +1,16 @@ import nconf from 'nconf'; -import fs from 'fs'; import { langCodes } from '../../libs/i18n'; -import { CONTENT_CACHE_PATH, getLocalizedContentResponse } from '../../libs/content'; +import { serveContent } from '../../libs/content'; const IS_PROD = nconf.get('IS_PROD'); const api = {}; -const CACHED_HASHES = [ - -]; - const MOBILE_FILTER = `achievements,questSeriesAchievements,animalColorAchievements,animalSetAchievements,stableAchievements, mystery,bundles,loginIncentives,pets,premiumPets,specialPets,questPets,wackyPets,mounts,premiumMounts,specialMounts,questMounts, events,dropEggs,questEggs,dropHatchingPotions,premiumHatchingPotions,wackyHatchingPotions,backgroundsFlat,questsByLevel,gear.tree, tasksByCategory,userDefaults,timeTravelStable,gearTypes,cardTypes`; -function hashForFilter (filter) { - let hash = 0; - let i; let - chr; - if (filter.length === 0) return ''; - for (i = 0; i < filter.length; i++) { // eslint-disable-line - chr = filter.charCodeAt(i); - hash = ((hash << 5) - hash) + chr; // eslint-disable-line - hash |= 0; // eslint-disable-line - } - return String(hash); -} - /** * @api {get} /api/v3/content Get all available content objects * @apiDescription Does not require authentication. @@ -92,52 +74,13 @@ api.getContent = { // apply defaults for mobile clients if (filter === '') { if (req.headers['x-client'] === 'habitica-android') { - filter = `${MOBILE_FILTER},appearance.background`; + filter = `${MOBILE_FILTER},appearances.background`; } else if (req.headers['x-client'] === 'habitica-ios') { filter = `${MOBILE_FILTER},backgrounds`; } } - // Build usable filter object - const filterObj = {}; - filter.split(',').forEach(item => { - if (item.includes('.')) { - const [key, subkey] = item.split('.'); - if (!filterObj[key]) { - filterObj[key] = {}; - } - filterObj[key][subkey.trim()] = true; - } else { - filterObj[item.trim()] = true; - } - }); - - if (IS_PROD) { - const filterHash = language + hashForFilter(filter); - if (CACHED_HASHES.includes(filterHash)) { - // Content is already cached, so just send it. - res.sendFile(`${CONTENT_CACHE_PATH}${filterHash}.json`); - } else { - // Content is not cached, so cache it and send it. - res.set({ - 'Content-Type': 'application/json', - }); - const jsonResString = getLocalizedContentResponse(language, filterObj); - fs.writeFileSync( - `${CONTENT_CACHE_PATH}${filterHash}.json`, - jsonResString, - 'utf8', - ); - CACHED_HASHES.push(filterHash); - res.status(200).send(jsonResString); - } - } else { - res.set({ - 'Content-Type': 'application/json', - }); - const jsonResString = getLocalizedContentResponse(language, filterObj); - res.status(200).send(jsonResString); - } + serveContent(res, language, filter, IS_PROD); }, }; diff --git a/website/server/libs/content.js b/website/server/libs/content.js index 378f7a09a6..94321cdf72 100644 --- a/website/server/libs/content.js +++ b/website/server/libs/content.js @@ -1,10 +1,15 @@ import _ from 'lodash'; import path from 'path'; +import fs from 'fs'; import common from '../../common'; import packageInfo from '../../../package.json'; export const CONTENT_CACHE_PATH = path.join(__dirname, '/../../../content_cache/'); +const CACHED_HASHES = [ + +]; + function walkContent (obj, lang, removedKeys = {}) { _.each(obj, (item, key, source) => { if (key in removedKeys && removedKeys[key] === true) { @@ -33,3 +38,59 @@ export function getLocalizedContentResponse (langCode, removedKeys = {}) { const localizedContent = localizeContentData(common.content, langCode, removedKeys); return `{"success": true, "data": ${JSON.stringify(localizedContent)}, "appVersion": "${packageInfo.version}"}`; } + +export function hashForFilter (filter) { + let hash = 0; + let i; let + chr; + if (filter.length === 0) return ''; + for (i = 0; i < filter.length; i++) { // eslint-disable-line + chr = filter.charCodeAt(i); + hash = ((hash << 5) - hash) + chr; // eslint-disable-line + hash |= 0; // eslint-disable-line + } + return String(hash); +} + +export function serveContent (res, language, filter, isProd) { + // Build usable filter object + const filterObj = {}; + filter.split(',').forEach(item => { + if (item.includes('.')) { + const [key, subkey] = item.split('.'); + if (!filterObj[key]) { + filterObj[key] = {}; + } + filterObj[key][subkey.trim()] = true; + } else { + filterObj[item.trim()] = true; + } + }); + + if (isProd) { + const filterHash = language + hashForFilter(filter); + if (CACHED_HASHES.includes(filterHash)) { + // Content is already cached, so just send it. + res.sendFile(`${CONTENT_CACHE_PATH}${filterHash}.json`); + } else { + // Content is not cached, so cache it and send it. + res.set({ + 'Content-Type': 'application/json', + }); + const jsonResString = getLocalizedContentResponse(language, filterObj); + fs.writeFileSync( + `${CONTENT_CACHE_PATH}${filterHash}.json`, + jsonResString, + 'utf8', + ); + CACHED_HASHES.push(filterHash); + res.status(200).send(jsonResString); + } + } else { + res.set({ + 'Content-Type': 'application/json', + }); + const jsonResString = getLocalizedContentResponse(language, filterObj); + res.status(200).send(jsonResString); + } +}