diff --git a/test/api/unit/libs/xmlMarshaller.test.js b/test/api/unit/libs/xmlMarshaller.test.js new file mode 100644 index 0000000000..73633a4c30 --- /dev/null +++ b/test/api/unit/libs/xmlMarshaller.test.js @@ -0,0 +1,44 @@ +import * as xmlMarshaller from '../../../../website/server/libs/xmlMarshaller'; + +describe('xml marshaller marshalls user data', () => { + const minimumUser = { + pinnedItems: [], + unpinnedItems: [], + inbox: {}, + }; + + function userDataWith (fields) { + return { ...minimumUser, ...fields }; + } + + it('maps the newMessages field to have id as a value in a list.', () => { + const userData = userDataWith({ + newMessages: { + '283171a5-422c-4991-bc78-95b1b5b51629': { + name: 'The Language Hackers', + value: true, + }, + '283171a6-422c-4991-bc78-95b1b5b51629': { + name: 'The Bug Hackers', + value: false, + }, + }, + }); + + const xml = xmlMarshaller.marshallUserData(userData); + + expect(xml).to.equal(` + + + 283171a5-422c-4991-bc78-95b1b5b51629 + The Language Hackers + true + + + 283171a6-422c-4991-bc78-95b1b5b51629 + The Bug Hackers + false + +`); + }); +}); diff --git a/test/api/unit/top-level/dataexport.test.js b/test/api/unit/top-level/dataexport.test.js deleted file mode 100644 index 3f6892ee9d..0000000000 --- a/test/api/unit/top-level/dataexport.test.js +++ /dev/null @@ -1,79 +0,0 @@ -import dataexport from '../../../../website/server/controllers/top-level/dataexport'; - -import * as Tasks from '../../../../website/server/models/task'; -import * as inboxLib from '../../../../website/server/libs/inbox'; - -describe('xml export', async () => { - let exported; - - const user = { - toJSON () { - return { - newMessages: { - '283171a5-422c-4991-bc78-95b1b5b51629': { - name: 'The Language Hackers', - value: true, - }, - '283171a6-422c-4991-bc78-95b1b5b51629': { - name: 'The Bug Hackers', - value: false, - }, - }, - inbox: {}, - pinnedItems: [], - unpinnedItems: [], - }; - }, - }; - - const response = { - locals: { user }, - set () {}, - status: () => ({ - send: data => { - exported = data; - }, - }), - }; - - beforeEach(() => { - const tasks = [{ - toJSON: () => ({ a: 'b', type: 'c' }), - }]; - const messages = [{ flags: { content: 'message' } }]; - - sinon.stub(Tasks.Task, 'find').returns({ exec: async () => tasks }); - sinon.stub(inboxLib, 'getUserInbox').resolves(messages); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('maps the newMessages field to have id as a value in a list.', async () => { - await dataexport.exportUserDataXml.handler({}, response); - expect(exported).to.equal(` - - 283171a5-422c-4991-bc78-95b1b5b51629 - The Language Hackers - true - - - 283171a6-422c-4991-bc78-95b1b5b51629 - The Bug Hackers - false - - - - content - - - - - b - c - - -`); - }); -}); diff --git a/test/api/v3/integration/GET-dataexport.test.js b/test/api/v3/integration/GET-dataexport.test.js new file mode 100644 index 0000000000..f8e8b0ccc1 --- /dev/null +++ b/test/api/v3/integration/GET-dataexport.test.js @@ -0,0 +1,424 @@ +import moment from 'moment'; + +import { generateUser, requester } from '../../../helpers/api-integration/v3'; +import { Task } from '../../../../website/server/models/task'; + +describe('GET /export/userdata.xml', () => { + let user; + + before(async () => { + user = await generateUser(); + }); + + it('returns the xml for a minimum viable user', async () => { + const xml = await requester(user).get('/export/userdata.xml'); + + const userId = user.id; + const userName = user.auth.local.username; + const dateTime = moment(user.auth.timestamps.created).toDate(); + const taskId = (await Task.findOne({ userId }, 'id').exec())._id; + + expect(xml).to.equal(` + + + ${userName} + ${userName} + ${userName}@example.com + + + ${dateTime} + ${dateTime} + ${dateTime} + + + + + + + + false + false + false + false + + 0 + 0 + + + + + + false + 0 + + + + + true + + + + 0 + 0 + 0 + 0 + + 1 + 0 + 0 + + + + + -2 + -2 + -2 + -2 + -2 + -2 + -2 + -2 + -2 + -2 + -2 + -2 + + + + false + false + false + false + false + false + false + false + false + false + false + false + false + false + false + + + false + false + false + false + false + false + false + + + false + false + false + false + false + true + false + false + 0 + 0 + false + 0 + false + true + false + false + false + false + true + + ${dateTime} + + + + + + armor_base_0 + head_base_0 + shield_base_0 + + + armor_base_0 + head_base_0 + shield_base_0 + + + true + true + true + true + true + true + true + true + true + true + true + true + true + true + true + true + true + true + true + true + true + + + + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + + + 0 + ${dateTime} + + + + + + + + 1 + + + + + + + + + 0 + 0 + 0 + + + false + + level + ascending + + + + red + 3 + 1 + 0 + 0 + 1 + + + false + true + true + true + true + true + true + true + true + true + true + true + true + true + true + + + false + true + true + true + true + true + true + true + true + true + true + true + true + true + + + false + false + false + false + + + false + false + + 0 + slim + false + 915533 + blue + 0 + rosstavoTheme + none + flat + true + MM/dd/yyyy + false + true + false + false + false + false + false + false + true + en + + violet + + + ${userName} + + + + 0 + 0 + 0 + 0 + 0 + false + false + false + false + false + + + 0 + 0 + 0 + 0 + + 50 + 10 + 0 + 0 + 1 + warrior + 0 + 0 + 0 + 0 + 0 + + + 0 + false + + + ${user.tasksOrder.todos[0]} + + <_v>1 + 0 + 0 + 0 + <_id>${userId} + ${user.apiToken} + ${dateTime} + + ${user.tags[0].id} + Work + + + ${user.tags[1].id} + Exercise + + + ${user.tags[2].id} + Health + Wellness + + + ${user.tags[3].id} + School + + + ${user.tags[4].id} + Teams + + + ${user.tags[5].id} + Chores + + + ${user.tags[6].id} + Creativity + + + + gear.flat.weapon_warrior_0 + marketGear + + + gear.flat.armor_warrior_1 + marketGear + + + gear.flat.shield_warrior_1 + marketGear + + + gear.flat.head_warrior_1 + marketGear + + + potion + potion + + + armoire + armoire + + ${userId} + <_tmp>undefined + + + + + + false + false + false + + singleCompletion + + false + false + todo + You can either complete this To Do, edit it, or remove it. + 0 + 1 + int + false + ${dateTime} + ${dateTime} + <_id>${taskId} + Join Habitica (Check me off!) + ${userId} + ${taskId} + + +`); + }); +}); diff --git a/website/server/controllers/top-level/dataexport.js b/website/server/controllers/top-level/dataexport.js index f1bc60ea28..fd9a10c8bf 100644 --- a/website/server/controllers/top-level/dataexport.js +++ b/website/server/controllers/top-level/dataexport.js @@ -1,14 +1,12 @@ import _ from 'lodash'; import moment from 'moment'; -import * as js2xml from 'js2xmlparser'; // import Pageres from 'pageres'; // import nconf from 'nconf'; // import got from 'got'; import md from 'habitica-markdown'; import csvStringify from '../../libs/csvStringify'; -import { - NotFound, -} from '../../libs/errors'; +import { marshallUserData } from '../../libs/xmlMarshaller'; +import { NotFound } from '../../libs/errors'; import * as Tasks from '../../models/task'; import * as inboxLib from '../../libs/inbox'; // import { model as User } from '../../models/user'; @@ -85,7 +83,7 @@ api.exportUserHistory = { // Convert user to json and attach tasks divided by type and inbox messages // at user.tasks[`${taskType}s`] (user.tasks.{dailys/habits/...}) -async function _getUserDataForExport (user, xmlMode = false) { +async function _getUserDataForExport (user) { const userData = user.toJSON(); userData.tasks = {}; @@ -108,30 +106,6 @@ async function _getUserDataForExport (user, xmlMode = false) { userData.tasks[`${taskType}s`] = tasksPerType; }); - if (xmlMode) { - // object maps cant be parsed - userData.inbox.messages = _(userData.inbox.messages) - .map(m => { - m.flags = Object.keys(m.flags); - - return m; - }) - .value(); - - userData.newMessages = _.map(userData.newMessages, (msg, id) => ({ id, ...msg })); - - // _id gets parsed as a bytearray => which gets cast to a chararray => "weird chars" - userData.unpinnedItems = userData.unpinnedItems.map(i => ({ - path: i.path, - type: i.type, - })); - - userData.pinnedItems = userData.pinnedItems.map(i => ({ - path: i.path, - type: i.type, - })); - } - return userData; } @@ -172,13 +146,8 @@ api.exportUserDataXml = { url: '/export/userdata.xml', middlewares: [authWithSession], async handler (req, res) { - const userData = await _getUserDataForExport(res.locals.user, true); - const xmlData = js2xml.parse('user', userData, { - cdataInvalidChars: true, - declaration: { - include: false, - }, - }); + const userData = await _getUserDataForExport(res.locals.user); + const xmlData = marshallUserData(userData); res.set({ 'Content-Type': 'text/xml', diff --git a/website/server/libs/xmlMarshaller.js b/website/server/libs/xmlMarshaller.js new file mode 100644 index 0000000000..5e7bb96095 --- /dev/null +++ b/website/server/libs/xmlMarshaller.js @@ -0,0 +1,32 @@ +import _ from 'lodash'; +import * as js2xml from 'js2xmlparser'; + +export function marshallUserData (userData) { + // object maps can't be marshalled to XML + userData.inbox.messages = _(userData.inbox.messages) + .map(m => { + m.flags = Object.keys(m.flags); + return m; + }) + .value(); + + userData.newMessages = _.map(userData.newMessages, (msg, id) => ({ id, ...msg })); + + // _id gets parsed as a bytearray => which gets cast to a chararray => "weird chars" + userData.unpinnedItems = userData.unpinnedItems.map(i => ({ + path: i.path, + type: i.type, + })); + + userData.pinnedItems = userData.pinnedItems.map(i => ({ + path: i.path, + type: i.type, + })); + + return js2xml.parse('user', userData, { + cdataInvalidChars: true, + declaration: { + include: false, + }, + }); +}