Add tests for content filtering

This commit is contained in:
Phillip Thelen
2024-01-26 14:58:21 +01:00
committed by Sabe Jones
parent 39252c7828
commit ec0275e6f6
5 changed files with 186 additions and 60 deletions

View File

@@ -1,5 +1,9 @@
import fs from 'fs';
import * as contentLib from '../../../../website/server/libs/content'; import * as contentLib from '../../../../website/server/libs/content';
import content from '../../../../website/common/script/content'; import content from '../../../../website/common/script/content';
import {
generateRes,
} from '../../../helpers/api-unit.helper';
describe('contentLib', () => { describe('contentLib', () => {
describe('CONTENT_CACHE_PATH', () => { describe('CONTENT_CACHE_PATH', () => {
@@ -13,5 +17,88 @@ describe('contentLib', () => {
contentLib.getLocalizedContentResponse(); contentLib.getLocalizedContentResponse();
expect(typeof content.backgrounds.backgrounds062014.beach.text).to.equal('function'); 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`);
});
}); });
}); });

View File

@@ -22,4 +22,38 @@ describe('GET /content', () => {
expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach'); expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach');
expect(res.backgrounds.backgrounds062014.beach.text).to.equal(t('backgroundBeachText')); 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');
});
}); });

View File

@@ -40,6 +40,7 @@ export function generateRes (options = {}) {
redirect: sandbox.stub(), redirect: sandbox.stub(),
render: sandbox.stub(), render: sandbox.stub(),
send: sandbox.stub(), send: sandbox.stub(),
sendFile: sandbox.stub(),
sendStatus: sandbox.stub().returnsThis(), sendStatus: sandbox.stub().returnsThis(),
set: sandbox.stub(), set: sandbox.stub(),
status: sandbox.stub().returnsThis(), status: sandbox.stub().returnsThis(),

View File

@@ -1,34 +1,16 @@
import nconf from 'nconf'; import nconf from 'nconf';
import fs from 'fs';
import { langCodes } from '../../libs/i18n'; 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 IS_PROD = nconf.get('IS_PROD');
const api = {}; const api = {};
const CACHED_HASHES = [
];
const MOBILE_FILTER = `achievements,questSeriesAchievements,animalColorAchievements,animalSetAchievements,stableAchievements, const MOBILE_FILTER = `achievements,questSeriesAchievements,animalColorAchievements,animalSetAchievements,stableAchievements,
mystery,bundles,loginIncentives,pets,premiumPets,specialPets,questPets,wackyPets,mounts,premiumMounts,specialMounts,questMounts, mystery,bundles,loginIncentives,pets,premiumPets,specialPets,questPets,wackyPets,mounts,premiumMounts,specialMounts,questMounts,
events,dropEggs,questEggs,dropHatchingPotions,premiumHatchingPotions,wackyHatchingPotions,backgroundsFlat,questsByLevel,gear.tree, events,dropEggs,questEggs,dropHatchingPotions,premiumHatchingPotions,wackyHatchingPotions,backgroundsFlat,questsByLevel,gear.tree,
tasksByCategory,userDefaults,timeTravelStable,gearTypes,cardTypes`; 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 * @api {get} /api/v3/content Get all available content objects
* @apiDescription Does not require authentication. * @apiDescription Does not require authentication.
@@ -92,52 +74,13 @@ api.getContent = {
// apply defaults for mobile clients // apply defaults for mobile clients
if (filter === '') { if (filter === '') {
if (req.headers['x-client'] === 'habitica-android') { 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') { } else if (req.headers['x-client'] === 'habitica-ios') {
filter = `${MOBILE_FILTER},backgrounds`; filter = `${MOBILE_FILTER},backgrounds`;
} }
} }
// Build usable filter object serveContent(res, language, filter, IS_PROD);
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);
}
}, },
}; };

View File

@@ -1,10 +1,15 @@
import _ from 'lodash'; import _ from 'lodash';
import path from 'path'; import path from 'path';
import fs from 'fs';
import common from '../../common'; import common from '../../common';
import packageInfo from '../../../package.json'; import packageInfo from '../../../package.json';
export const CONTENT_CACHE_PATH = path.join(__dirname, '/../../../content_cache/'); export const CONTENT_CACHE_PATH = path.join(__dirname, '/../../../content_cache/');
const CACHED_HASHES = [
];
function walkContent (obj, lang, removedKeys = {}) { function walkContent (obj, lang, removedKeys = {}) {
_.each(obj, (item, key, source) => { _.each(obj, (item, key, source) => {
if (key in removedKeys && removedKeys[key] === true) { if (key in removedKeys && removedKeys[key] === true) {
@@ -33,3 +38,59 @@ export function getLocalizedContentResponse (langCode, removedKeys = {}) {
const localizedContent = localizeContentData(common.content, langCode, removedKeys); const localizedContent = localizeContentData(common.content, langCode, removedKeys);
return `{"success": true, "data": ${JSON.stringify(localizedContent)}, "appVersion": "${packageInfo.version}"}`; 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);
}
}