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
+
+
+
+
+
+
+
+
+ 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,
+ },
+ });
+}