diff --git a/gulp/gulp-content.js b/gulp/gulp-content.js index dc1b32c435..c36bfeb125 100644 --- a/gulp/gulp-content.js +++ b/gulp/gulp-content.js @@ -6,7 +6,7 @@ gulp.task('content:cache', done => { // Requiring at runtime because these files access `common` // code which in production works only if transpiled so after // gulp build:babel:common has run - const { CONTENT_CACHE_PATH, getLocalizedContent } = require('../website/server/libs/content'); // eslint-disable-line global-require + const { CONTENT_CACHE_PATH, getLocalizedContentResponse } = require('../website/server/libs/content'); // eslint-disable-line global-require const { langCodes } = require('../website/server/libs/i18n'); // eslint-disable-line global-require try { @@ -23,7 +23,7 @@ gulp.task('content:cache', done => { langCodes.forEach(langCode => { fs.writeFileSync( `${CONTENT_CACHE_PATH}${langCode}.json`, - getLocalizedContent(langCode), + getLocalizedContentResponse(langCode), 'utf8', ); }); diff --git a/test/api/unit/libs/content.test.js b/test/api/unit/libs/content.test.js index bd037ef4fb..368d8fe9fd 100644 --- a/test/api/unit/libs/content.test.js +++ b/test/api/unit/libs/content.test.js @@ -8,9 +8,9 @@ describe('contentLib', () => { }); }); - describe('getLocalizedContent', () => { + describe('getLocalizedContentResponse', () => { it('clones, not modify, the original content data', () => { - contentLib.getLocalizedContent(); + contentLib.getLocalizedContentResponse(); expect(typeof content.backgrounds.backgrounds062014.beach.text).to.equal('function'); }); }); diff --git a/test/api/unit/libs/localizeContentData.js b/test/api/unit/libs/localizeContentData.js new file mode 100644 index 0000000000..be43098b08 --- /dev/null +++ b/test/api/unit/libs/localizeContentData.js @@ -0,0 +1,22 @@ +import faq from '../../../../website/common/script/content/faq'; +import common from '../../../../website/common'; +import { localizeContentData } from '../../../../website/server/libs/content'; + +const { i18n } = common; + +describe('localizeContentData', () => { + it('Should take a an object with localization identifiers and ' + + 'return an object with actual translations in English', () => { + const faqInEnglish = localizeContentData(faq, 'en'); + + expect(faqInEnglish).to.have.property('stillNeedHelp'); + expect(faqInEnglish.stillNeedHelp.ios).to.equal(i18n.t('iosFaqStillNeedHelp', 'en')); + }); + it('Should take an object with localization identifiers and ' + + 'return an object with actual translations in German', () => { + const faqInEnglish = localizeContentData(faq, 'de'); + + expect(faqInEnglish).to.have.property('stillNeedHelp'); + expect(faqInEnglish.stillNeedHelp.ios).to.equal(i18n.t('iosFaqStillNeedHelp', 'de')); + }); +}); diff --git a/test/api/v4/faq/GET-faq.test.js b/test/api/v4/faq/GET-faq.test.js new file mode 100644 index 0000000000..5c7ab31ce6 --- /dev/null +++ b/test/api/v4/faq/GET-faq.test.js @@ -0,0 +1,65 @@ +import { + requester, + translate, +} from '../../../helpers/api-integration/v4'; +import i18n from '../../../../website/common/script/i18n'; + +describe('GET /faq', () => { + describe('language parameter', () => { + it('returns faq (and does not require authentication)', async () => { + const res = await requester().get('/faq'); + + expect(res).to.have.property('stillNeedHelp'); + expect(res.stillNeedHelp.ios).to.equal(translate('iosFaqStillNeedHelp')); + expect(res).to.have.property('questions'); + expect(res.questions[0].question).to.equal(translate('faqQuestion0')); + }); + + it('returns faq not in English', async () => { + const res = await requester().get('/faq?language=de'); + expect(res).to.have.nested.property('stillNeedHelp.ios'); + expect(res.stillNeedHelp.ios).to.equal(i18n.t('iosFaqStillNeedHelp', 'de')); + }); + + it('falls back to English if the desired language is not found', async () => { + const res = await requester().get('/faq?language=wrong'); + expect(res).to.have.nested.property('stillNeedHelp.ios'); + expect(res.stillNeedHelp.ios).to.equal(translate('iosFaqStillNeedHelp')); + }); + }); + + describe('platform parameter', () => { + it('returns faq with answers for ios platform only', async () => { + const res = await requester().get('/faq?platform=ios'); + + expect(res).to.have.property('stillNeedHelp'); + expect(res.stillNeedHelp).to.eql({ ios: translate('iosFaqStillNeedHelp') }); + + expect(res).to.have.property('questions'); + expect(res.questions[0]).to.eql({ + question: translate('faqQuestion0'), + ios: translate('iosFaqAnswer0'), + }); + }); + it('returns an error when invalid platform parameter is specified', async () => { + const request = requester().get('/faq?platform=wrong'); + await expect(request) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: i18n.t('invalidReqParams'), + }); + }); + it('falls back to "web" description if there is no description for specified platform', async () => { + const res = await requester().get('/faq?platform=android'); + expect(res).to.have.property('stillNeedHelp'); + expect(res.stillNeedHelp).to.eql({ web: translate('webFaqStillNeedHelp') }); + + expect(res).to.have.property('questions'); + expect(res.questions[0]).to.eql({ + question: translate('faqQuestion0'), + android: translate('androidFaqAnswer0'), + }); + }); + }); +}); diff --git a/website/common/script/errors/apiErrorMessages.js b/website/common/script/errors/apiErrorMessages.js index e8a3edce8c..77991b3f9b 100644 --- a/website/common/script/errors/apiErrorMessages.js +++ b/website/common/script/errors/apiErrorMessages.js @@ -27,4 +27,6 @@ export default { missingSubKey: 'Missing "req.query.sub"', ipAddressBlocked: 'This IP address has been blocked from accessing Habitica. This may be due to a breach of our Terms of Service or technical issue originating at this IP address. For details or to ask to be unblocked, please email admin@habitica.com or ask your parent or guardian to email them. Include your Habitica @ Username or User Id in the email if you have one.', + + invalidPlatform: 'Invalid platform specified', }; diff --git a/website/server/controllers/api-v3/content.js b/website/server/controllers/api-v3/content.js index 6dbb6137f4..76152c4e40 100644 --- a/website/server/controllers/api-v3/content.js +++ b/website/server/controllers/api-v3/content.js @@ -1,6 +1,6 @@ import nconf from 'nconf'; import { langCodes } from '../../libs/i18n'; -import { CONTENT_CACHE_PATH, getLocalizedContent } from '../../libs/content'; +import { CONTENT_CACHE_PATH, getLocalizedContentResponse } from '../../libs/content'; const IS_PROD = nconf.get('IS_PROD'); @@ -72,7 +72,7 @@ api.getContent = { 'Content-Type': 'application/json', }); - const jsonResString = getLocalizedContent(language); + const jsonResString = getLocalizedContentResponse(language); res.status(200).send(jsonResString); } }, diff --git a/website/server/controllers/api-v4/faq.js b/website/server/controllers/api-v4/faq.js new file mode 100644 index 0000000000..171e72edad --- /dev/null +++ b/website/server/controllers/api-v4/faq.js @@ -0,0 +1,77 @@ +import _ from 'lodash'; +import { query } from 'express-validator/check'; +import { langCodes } from '../../libs/i18n'; +import apiError from '../../libs/apiError'; +import common from '../../../common'; +import { localizeContentData } from '../../libs/content'; + +const { content } = common; +const { faq } = content; + +const api = {}; + +function _deleteProperties (obj, keysToDelete, platform) { + // if there is no description for specified platform, use 'web' description by default + if (obj[platform] === undefined) { + delete obj.ios; + delete obj.android; + return; + } + + keysToDelete.forEach(key => delete obj[key]); +} + +function _deleteOtherPlatformsAnswers (faqObject, platform) { + const faqCopy = _.cloneDeep(faqObject); + const keysToDelete = _.without(['web', 'ios', 'android'], platform); + + _deleteProperties(faqCopy.stillNeedHelp, keysToDelete, platform); + faqCopy.questions.forEach(question => { + _deleteProperties(question, keysToDelete, platform); + }); + + return faqCopy; +} + +/** + * @api {get} /api/v4/faq Get faq in json format + * @apiDescription Does not require authentication. + * @apiName FaqGet + * @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 FAQ in a json format + */ +api.faq = { + method: 'GET', + url: '/faq', + middlewares: [ + query('platform') + .optional() + .isIn(['web', 'android', 'ios']).withMessage(apiError('invalidPlatform')), + ], + async handler (req, res) { + const validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + const proposedLang = req.query.language && req.query.language.toString(); + const language = langCodes.includes(proposedLang) ? proposedLang : 'en'; + + const { platform } = req.query; + + const dataToLocalize = platform ? _deleteOtherPlatformsAnswers(faq, platform) : faq; + res.respond(200, localizeContentData(dataToLocalize, language)); + }, +}; + +export default api; diff --git a/website/server/libs/content.js b/website/server/libs/content.js index 134d87f007..13d4f31b9f 100644 --- a/website/server/libs/content.js +++ b/website/server/libs/content.js @@ -14,8 +14,13 @@ function walkContent (obj, lang) { }); } -export function getLocalizedContent (langCode) { - const contentClone = _.cloneDeep(common.content); - walkContent(contentClone, langCode); - return `{"success": true, "data": ${JSON.stringify(contentClone)}}`; +export function localizeContentData (data, langCode) { + const dataClone = _.cloneDeep(data); + walkContent(dataClone, langCode); + return dataClone; +} + +export function getLocalizedContentResponse (langCode) { + const localizedContent = localizeContentData(common.content, langCode); + return `{"success": true, "data": ${JSON.stringify(localizedContent)}}`; }