fix(dataexport) - 12482 - Extract xml marshalling into library

- Add integration test on dataexport endpoint
- Add library with unit test for xml marshalling
This commit is contained in:
Bart Enkelaar 2020-09-25 08:55:28 +02:00
parent 8b9c76a2b7
commit 6e91326648
5 changed files with 505 additions and 115 deletions

View file

@ -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(`<user>
<inbox/>
<newMessages>
<id>283171a5-422c-4991-bc78-95b1b5b51629</id>
<name>The Language Hackers</name>
<value>true</value>
</newMessages>
<newMessages>
<id>283171a6-422c-4991-bc78-95b1b5b51629</id>
<name>The Bug Hackers</name>
<value>false</value>
</newMessages>
</user>`);
});
});

View file

@ -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(`<user>
<newMessages>
<id>283171a5-422c-4991-bc78-95b1b5b51629</id>
<name>The Language Hackers</name>
<value>true</value>
</newMessages>
<newMessages>
<id>283171a6-422c-4991-bc78-95b1b5b51629</id>
<name>The Bug Hackers</name>
<value>false</value>
</newMessages>
<inbox>
<messages>
<flags>content</flags>
</messages>
</inbox>
<tasks>
<cs>
<a>b</a>
<type>c</type>
</cs>
</tasks>
</user>`);
});
});

View file

@ -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(`<user>
<auth>
<local>
<username>${userName}</username>
<lowerCaseUsername>${userName}</lowerCaseUsername>
<email>${userName}@example.com</email>
</local>
<timestamps>
<created>${dateTime}</created>
<loggedin>${dateTime}</loggedin>
<updated>${dateTime}</updated>
</timestamps>
<facebook/>
<google/>
<apple/>
</auth>
<achievements>
<ultimateGearSets>
<healer>false</healer>
<wizard>false</wizard>
<rogue>false</rogue>
<warrior>false</warrior>
</ultimateGearSets>
<streak>0</streak>
<perfect>0</perfect>
<quests/>
</achievements>
<backer/>
<contributor/>
<purchased>
<ads>false</ads>
<txnCount>0</txnCount>
<skin/>
<hair/>
<shirt/>
<background>
<violet>true</violet>
</background>
<plan>
<consecutive>
<count>0</count>
<offset>0</offset>
<gemCapExtra>0</gemCapExtra>
<trinkets>0</trinkets>
</consecutive>
<quantity>1</quantity>
<extraMonths>0</extraMonths>
<gemsBought>0</gemsBought>
</plan>
</purchased>
<flags>
<tour>
<intro>-2</intro>
<classes>-2</classes>
<stats>-2</stats>
<tavern>-2</tavern>
<party>-2</party>
<guilds>-2</guilds>
<challenges>-2</challenges>
<market>-2</market>
<pets>-2</pets>
<mounts>-2</mounts>
<hall>-2</hall>
<equipment>-2</equipment>
</tour>
<tutorial>
<common>
<habits>false</habits>
<dailies>false</dailies>
<todos>false</todos>
<rewards>false</rewards>
<party>false</party>
<pets>false</pets>
<gems>false</gems>
<skills>false</skills>
<classes>false</classes>
<tavern>false</tavern>
<equipment>false</equipment>
<items>false</items>
<mounts>false</mounts>
<inbox>false</inbox>
<stats>false</stats>
</common>
<ios>
<addTask>false</addTask>
<editTask>false</editTask>
<deleteTask>false</deleteTask>
<filterTask>false</filterTask>
<groupPets>false</groupPets>
<inviteParty>false</inviteParty>
<reorderTask>false</reorderTask>
</ios>
</tutorial>
<customizationsNotification>false</customizationsNotification>
<showTour>false</showTour>
<dropsEnabled>false</dropsEnabled>
<itemsEnabled>false</itemsEnabled>
<newStuff>false</newStuff>
<rewrite>true</rewrite>
<classSelected>false</classSelected>
<rebirthEnabled>false</rebirthEnabled>
<recaptureEmailsPhase>0</recaptureEmailsPhase>
<weeklyRecapEmailsPhase>0</weeklyRecapEmailsPhase>
<communityGuidelinesAccepted>false</communityGuidelinesAccepted>
<cronCount>0</cronCount>
<welcomed>false</welcomed>
<armoireEnabled>true</armoireEnabled>
<armoireOpened>false</armoireOpened>
<armoireEmpty>false</armoireEmpty>
<cardReceived>false</cardReceived>
<warnedLowHealth>false</warnedLowHealth>
<verifiedUsername>true</verifiedUsername>
<levelDrops/>
<lastWeeklyRecap>${dateTime}</lastWeeklyRecap>
</flags>
<history/>
<items>
<gear>
<equipped>
<armor>armor_base_0</armor>
<head>head_base_0</head>
<shield>shield_base_0</shield>
</equipped>
<costume>
<armor>armor_base_0</armor>
<head>head_base_0</head>
<shield>shield_base_0</shield>
</costume>
<owned>
<headAccessory_special_blackHeadband>true</headAccessory_special_blackHeadband>
<headAccessory_special_blueHeadband>true</headAccessory_special_blueHeadband>
<headAccessory_special_greenHeadband>true</headAccessory_special_greenHeadband>
<headAccessory_special_pinkHeadband>true</headAccessory_special_pinkHeadband>
<headAccessory_special_redHeadband>true</headAccessory_special_redHeadband>
<headAccessory_special_whiteHeadband>true</headAccessory_special_whiteHeadband>
<headAccessory_special_yellowHeadband>true</headAccessory_special_yellowHeadband>
<eyewear_special_blackTopFrame>true</eyewear_special_blackTopFrame>
<eyewear_special_blueTopFrame>true</eyewear_special_blueTopFrame>
<eyewear_special_greenTopFrame>true</eyewear_special_greenTopFrame>
<eyewear_special_pinkTopFrame>true</eyewear_special_pinkTopFrame>
<eyewear_special_redTopFrame>true</eyewear_special_redTopFrame>
<eyewear_special_whiteTopFrame>true</eyewear_special_whiteTopFrame>
<eyewear_special_yellowTopFrame>true</eyewear_special_yellowTopFrame>
<eyewear_special_blackHalfMoon>true</eyewear_special_blackHalfMoon>
<eyewear_special_blueHalfMoon>true</eyewear_special_blueHalfMoon>
<eyewear_special_greenHalfMoon>true</eyewear_special_greenHalfMoon>
<eyewear_special_pinkHalfMoon>true</eyewear_special_pinkHalfMoon>
<eyewear_special_redHalfMoon>true</eyewear_special_redHalfMoon>
<eyewear_special_whiteHalfMoon>true</eyewear_special_whiteHalfMoon>
<eyewear_special_yellowHalfMoon>true</eyewear_special_yellowHalfMoon>
</owned>
</gear>
<special>
<snowball>0</snowball>
<spookySparkles>0</spookySparkles>
<shinySeed>0</shinySeed>
<seafoam>0</seafoam>
<valentine>0</valentine>
<nye>0</nye>
<greeting>0</greeting>
<thankyou>0</thankyou>
<birthday>0</birthday>
<congrats>0</congrats>
<getwell>0</getwell>
<goodluck>0</goodluck>
</special>
<lastDrop>
<count>0</count>
<date>${dateTime}</date>
</lastDrop>
<pets/>
<eggs/>
<hatchingPotions/>
<food/>
<mounts/>
<quests>
<dustbunnies>1</dustbunnies>
</quests>
</items>
<invitations>
<party/>
</invitations>
<party>
<quest>
<progress>
<up>0</up>
<down>0</down>
<collectedItems>0</collectedItems>
<collect/>
</progress>
<RSVPNeeded>false</RSVPNeeded>
</quest>
<order>level</order>
<orderAscending>ascending</orderAscending>
</party>
<preferences>
<hair>
<color>red</color>
<base>3</base>
<bangs>1</bangs>
<beard>0</beard>
<mustache>0</mustache>
<flower>1</flower>
</hair>
<emailNotifications>
<unsubscribeFromAll>false</unsubscribeFromAll>
<newPM>true</newPM>
<kickedGroup>true</kickedGroup>
<wonChallenge>true</wonChallenge>
<giftedGems>true</giftedGems>
<giftedSubscription>true</giftedSubscription>
<invitedParty>true</invitedParty>
<invitedGuild>true</invitedGuild>
<questStarted>true</questStarted>
<invitedQuest>true</invitedQuest>
<importantAnnouncements>true</importantAnnouncements>
<weeklyRecaps>true</weeklyRecaps>
<onboarding>true</onboarding>
<majorUpdates>true</majorUpdates>
<subscriptionReminders>true</subscriptionReminders>
</emailNotifications>
<pushNotifications>
<unsubscribeFromAll>false</unsubscribeFromAll>
<newPM>true</newPM>
<wonChallenge>true</wonChallenge>
<giftedGems>true</giftedGems>
<giftedSubscription>true</giftedSubscription>
<invitedParty>true</invitedParty>
<invitedGuild>true</invitedGuild>
<questStarted>true</questStarted>
<invitedQuest>true</invitedQuest>
<majorUpdates>true</majorUpdates>
<mentionParty>true</mentionParty>
<mentionJoinedGuild>true</mentionJoinedGuild>
<mentionUnjoinedGuild>true</mentionUnjoinedGuild>
<partyActivity>true</partyActivity>
</pushNotifications>
<suppressModals>
<levelUp>false</levelUp>
<hatchPet>false</hatchPet>
<raisePet>false</raisePet>
<streak>false</streak>
</suppressModals>
<tasks>
<groupByChallenge>false</groupByChallenge>
<confirmScoreNotes>false</confirmScoreNotes>
</tasks>
<dayStart>0</dayStart>
<size>slim</size>
<hideHeader>false</hideHeader>
<skin>915533</skin>
<shirt>blue</shirt>
<timezoneOffset>0</timezoneOffset>
<sound>rosstavoTheme</sound>
<chair>none</chair>
<allocationMode>flat</allocationMode>
<autoEquip>true</autoEquip>
<dateFormat>MM/dd/yyyy</dateFormat>
<sleep>false</sleep>
<stickyHeader>true</stickyHeader>
<disableClasses>false</disableClasses>
<newTaskEdit>false</newTaskEdit>
<dailyDueDefaultView>false</dailyDueDefaultView>
<advancedCollapsed>false</advancedCollapsed>
<toolbarCollapsed>false</toolbarCollapsed>
<reverseChatOrder>false</reverseChatOrder>
<displayInviteToPartyWhenPartyIs1>true</displayInviteToPartyWhenPartyIs1>
<language>en</language>
<webhooks/>
<background>violet</background>
</preferences>
<profile>
<name>${userName}</name>
</profile>
<stats>
<buffs>
<str>0</str>
<int>0</int>
<per>0</per>
<con>0</con>
<stealth>0</stealth>
<streaks>false</streaks>
<snowball>false</snowball>
<spookySparkles>false</spookySparkles>
<shinySeed>false</shinySeed>
<seafoam>false</seafoam>
</buffs>
<training>
<int>0</int>
<per>0</per>
<str>0</str>
<con>0</con>
</training>
<hp>50</hp>
<mp>10</mp>
<exp>0</exp>
<gp>0</gp>
<lvl>1</lvl>
<class>warrior</class>
<points>0</points>
<str>0</str>
<con>0</con>
<int>0</int>
<per>0</per>
</stats>
<inbox>
<newMessages>0</newMessages>
<optOut>false</optOut>
</inbox>
<tasksOrder>
<todos>${user.tasksOrder.todos[0]}</todos>
</tasksOrder>
<_v>1</_v>
<balance>0</balance>
<loginIncentives>0</loginIncentives>
<invitesSent>0</invitesSent>
<_id>${userId}</_id>
<apiToken>${user.apiToken}</apiToken>
<lastCron>${dateTime}</lastCron>
<tags>
<id>${user.tags[0].id}</id>
<name>Work</name>
</tags>
<tags>
<id>${user.tags[1].id}</id>
<name>Exercise</name>
</tags>
<tags>
<id>${user.tags[2].id}</id>
<name>Health + Wellness</name>
</tags>
<tags>
<id>${user.tags[3].id}</id>
<name>School</name>
</tags>
<tags>
<id>${user.tags[4].id}</id>
<name>Teams</name>
</tags>
<tags>
<id>${user.tags[5].id}</id>
<name>Chores</name>
</tags>
<tags>
<id>${user.tags[6].id}</id>
<name>Creativity</name>
</tags>
<extra/>
<pinnedItems>
<path>gear.flat.weapon_warrior_0</path>
<type>marketGear</type>
</pinnedItems>
<pinnedItems>
<path>gear.flat.armor_warrior_1</path>
<type>marketGear</type>
</pinnedItems>
<pinnedItems>
<path>gear.flat.shield_warrior_1</path>
<type>marketGear</type>
</pinnedItems>
<pinnedItems>
<path>gear.flat.head_warrior_1</path>
<type>marketGear</type>
</pinnedItems>
<pinnedItems>
<path>potion</path>
<type>potion</type>
</pinnedItems>
<pinnedItems>
<path>armoire</path>
<type>armoire</type>
</pinnedItems>
<id>${userId}</id>
<_tmp>undefined</_tmp>
<tasks>
<todos>
<challenge/>
<group>
<approval>
<required>false</required>
<approved>false</approved>
<requested>false</requested>
</approval>
<sharedCompletion>singleCompletion</sharedCompletion>
</group>
<completed>false</completed>
<collapseChecklist>false</collapseChecklist>
<type>todo</type>
<notes>You can either complete this To Do, edit it, or remove it.</notes>
<value>0</value>
<priority>1</priority>
<attribute>int</attribute>
<byHabitica>false</byHabitica>
<createdAt>${dateTime}</createdAt>
<updatedAt>${dateTime}</updatedAt>
<_id>${taskId}</_id>
<text>Join Habitica (Check me off!)</text>
<userId>${userId}</userId>
<id>${taskId}</id>
</todos>
</tasks>
</user>`);
});
});

View file

@ -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',

View file

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