Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions tests/e2e/default/enketo/forms/db-doc-file-upload.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?xml version="1.0"?>
<h:html xmlns="http://www.w3.org/2002/xforms" xmlns:h="http://www.w3.org/1999/xhtml"
xmlns:ev="http://www.w3.org/2001/xml-events" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:jr="http://openrosa.org/javarosa">
<h:head>
<h:title>db-doc-file-upload</h:title>

<model>
<instance>
<db-doc-file-upload id="db-doc-file-upload">
<name/>
<main_photo/>
<sub_doc db-doc="true">
<type>sub_record</type>
<sub_photo/>
</sub_doc>
<sub_doc_ref db-doc-ref="/db-doc-file-upload/sub_doc"/>
<meta>
<instanceID/>
</meta>
</db-doc-file-upload>
</instance>

<bind nodeset="/db-doc-file-upload/name" type="string"/>
<bind nodeset="/db-doc-file-upload/main_photo" type="binary"/>
<bind nodeset="/db-doc-file-upload/sub_doc/sub_photo" type="binary"/>
</model>
</h:head>

<h:body>
<input ref="/db-doc-file-upload/name">
<label>Name</label>
</input>
<upload ref="/db-doc-file-upload/main_photo" mediatype="image/*">
<label>Main photo</label>
</upload>
<upload ref="/db-doc-file-upload/sub_doc/sub_photo" mediatype="image/*">
<label>Sub doc photo</label>
</upload>
</h:body>
</h:html>
50 changes: 50 additions & 0 deletions tests/e2e/default/enketo/forms/db-doc-multi-file-upload.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?xml version="1.0"?>
<h:html xmlns="http://www.w3.org/2002/xforms" xmlns:h="http://www.w3.org/1999/xhtml"
xmlns:ev="http://www.w3.org/2001/xml-events" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:jr="http://openrosa.org/javarosa">
<h:head>
<h:title>db-doc-multi-file-upload</h:title>

<model>
<instance>
<db-doc-multi-file-upload id="db-doc-multi-file-upload">
<name/>
<main_photo/>
<sub_doc_a db-doc="true">
<type>sub_record_a</type>
<photo_a/>
</sub_doc_a>
<sub_doc_a_ref db-doc-ref="/db-doc-multi-file-upload/sub_doc_a"/>
<sub_doc_b db-doc="true">
<type>sub_record_b</type>
<photo_b/>
</sub_doc_b>
<sub_doc_b_ref db-doc-ref="/db-doc-multi-file-upload/sub_doc_b"/>
<meta>
<instanceID/>
</meta>
</db-doc-multi-file-upload>
</instance>

<bind nodeset="/db-doc-multi-file-upload/name" type="string"/>
<bind nodeset="/db-doc-multi-file-upload/main_photo" type="binary"/>
<bind nodeset="/db-doc-multi-file-upload/sub_doc_a/photo_a" type="binary"/>
<bind nodeset="/db-doc-multi-file-upload/sub_doc_b/photo_b" type="binary"/>
</model>
</h:head>

<h:body>
<input ref="/db-doc-multi-file-upload/name">
<label>Name</label>
</input>
<upload ref="/db-doc-multi-file-upload/main_photo" mediatype="image/*">
<label>Main photo</label>
</upload>
<upload ref="/db-doc-multi-file-upload/sub_doc_a/photo_a" mediatype="image/*">
<label>Sub doc A photo</label>
</upload>
<upload ref="/db-doc-multi-file-upload/sub_doc_b/photo_b" mediatype="image/*">
<label>Sub doc B photo</label>
</upload>
</h:body>
</h:html>
40 changes: 40 additions & 0 deletions tests/e2e/default/enketo/forms/db-doc-repeat-file-upload.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?xml version="1.0"?>
<h:html xmlns="http://www.w3.org/2002/xforms" xmlns:h="http://www.w3.org/1999/xhtml"
xmlns:ev="http://www.w3.org/2001/xml-events" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:jr="http://openrosa.org/javarosa">
<h:head>
<h:title>db-doc-repeat-file-upload</h:title>

<model>
<instance>
<db-doc-repeat-file-upload id="db-doc-repeat-file-upload">
<name/>
<repeat_section jr:template="">
<repeat_doc db-doc="true">
<type>repeat_record</type>
<repeat_photo/>
</repeat_doc>
<repeat_doc_ref db-doc-ref="/db-doc-repeat-file-upload/repeat_section/repeat_doc"/>
</repeat_section>
<meta>
<instanceID/>
</meta>
</db-doc-repeat-file-upload>
</instance>

<bind nodeset="/db-doc-repeat-file-upload/name" type="string"/>
<bind nodeset="/db-doc-repeat-file-upload/repeat_section/repeat_doc/repeat_photo" type="binary"/>
</model>
</h:head>

<h:body>
<input ref="/db-doc-repeat-file-upload/name">
<label>Name</label>
</input>
<repeat nodeset="/db-doc-repeat-file-upload/repeat_section">
<upload ref="/db-doc-repeat-file-upload/repeat_section/repeat_doc/repeat_photo" mediatype="image/*">
<label>Repeat photo</label>
</upload>
</repeat>
</h:body>
</h:html>
179 changes: 179 additions & 0 deletions tests/e2e/default/enketo/submit-db-doc-file-upload.wdio-spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
const commonPage = require('@page-objects/default/common/common.wdio.page');
const commonEnketoPage = require('@page-objects/default/enketo/common-enketo.wdio.page');
const reportsPage = require('@page-objects/default/reports/reports.wdio.page');
const utils = require('@utils');
const path = require('path');
const loginPage = require('@page-objects/default/login/login.wdio.page');
const genericForm = require('@page-objects/default/enketo/generic-form.wdio.page');

describe('Submit form with file uploads routed to db-doc sub-documents (#10904)', () => {
const mainImagePath = path.join(__dirname, '/images/photo-for-upload-form.png');
const subImagePath = path.join(__dirname, '../../../../webapp/src/img/layers.png');
const subImagePath2 = path.join(__dirname, '../../../../webapp/src/img/icon.png');

before(async () => {
await utils.saveDocIfNotExists(commonPage.createFormDoc(`${__dirname}/forms/db-doc-file-upload`));
await utils.saveDocIfNotExists(commonPage.createFormDoc(`${__dirname}/forms/db-doc-multi-file-upload`));
await utils.saveDocIfNotExists(commonPage.createFormDoc(`${__dirname}/forms/db-doc-repeat-file-upload`));
await loginPage.cookieLogin();
await commonPage.hideSnackbar();
});

it('should route file attachments to the correct owner doc', async () => {
await commonPage.goToReports();
await commonPage.openFastActionReport('db-doc-file-upload', false);

await commonEnketoPage.setInputValue('Name', 'Test Sub Doc Upload');
await commonEnketoPage.addFileInputValue('Main photo', mainImagePath);
await commonEnketoPage.addFileInputValue('Sub doc photo', subImagePath);

await genericForm.submitForm();
await commonPage.waitForPageLoaded();

const reportId = await reportsPage.getCurrentReportId();
const report = await utils.getDoc(reportId);

expect(report.fields.name).to.equal('Test Sub Doc Upload');

// The main doc should have the main_photo attachment (xpath-based name for binary widget)
const mainAttachments = Object.keys(report._attachments || {});
const mainPhotoAttachment = mainAttachments.find(name => name.match(/^user-file-photo-for-upload-form.*\.png$/));
expect(mainPhotoAttachment, 'Main photo should be attached to the report doc').to.exist;

// The sub-doc ID is stored via db-doc-ref
const subDocId = report.fields.sub_doc_ref;
expect(subDocId, 'Sub-doc reference should be populated by db-doc-ref').to.exist;

// Fetch the sub-doc and verify it has the sub_photo attachment
const subDoc = await utils.getDoc(subDocId);
expect(subDoc, 'Sub-doc should exist in the database').to.exist;
expect(subDoc.type).to.equal('sub_record');

const subAttachments = Object.keys(subDoc._attachments || {});
const subPhotoAttachment = subAttachments.find(name => name.match(/^user-file-layers.*\.png$/));
expect(subPhotoAttachment, 'Sub-doc photo should be attached to the sub-doc').to.exist;
expect(subDoc._attachments[subPhotoAttachment].content_type).to.equal('image/png');

// The sub-doc file should NOT be on the main doc
const mainHasSubFile = mainAttachments.find(name => name.match(/layers/));
expect(mainHasSubFile, 'Sub-doc file should not appear on the main doc').to.not.exist;
});

it('should route file attachments correctly with multiple sub-docs', async () => {
await commonPage.goToReports();
await commonPage.openFastActionReport('db-doc-multi-file-upload', false);

await commonEnketoPage.setInputValue('Name', 'Test Multi Sub Doc');
await commonEnketoPage.addFileInputValue('Main photo', mainImagePath);
await commonEnketoPage.addFileInputValue('Sub doc A photo', subImagePath);
await commonEnketoPage.addFileInputValue('Sub doc B photo', subImagePath2);

await genericForm.submitForm();
await commonPage.waitForPageLoaded();

const reportId = await reportsPage.getCurrentReportId();
const report = await utils.getDoc(reportId);

expect(report.fields.name).to.equal('Test Multi Sub Doc');

const mainAttachments = Object.keys(report._attachments || {});
const mainPhotoAttachment = mainAttachments.find(name => name.match(/^user-file-photo-for-upload-form.*\.png$/));
expect(mainPhotoAttachment, 'Main photo should be attached to the report doc').to.exist;

// Verify sub-doc A
const subDocAId = report.fields.sub_doc_a_ref;
expect(subDocAId, 'Sub-doc A reference should be populated').to.exist;

const subDocA = await utils.getDoc(subDocAId);
expect(subDocA.type).to.equal('sub_record_a');

const subAAttachments = Object.keys(subDocA._attachments || {});
const subAPhoto = subAAttachments.find(name => name.match(/^user-file-layers.*\.png$/));
expect(subAPhoto, 'Sub-doc A photo should be attached to sub-doc A').to.exist;
expect(subDocA._attachments[subAPhoto].content_type).to.equal('image/png');

// Verify sub-doc B
const subDocBId = report.fields.sub_doc_b_ref;
expect(subDocBId, 'Sub-doc B reference should be populated').to.exist;

const subDocB = await utils.getDoc(subDocBId);
expect(subDocB.type).to.equal('sub_record_b');

const subBAttachments = Object.keys(subDocB._attachments || {});
const subBPhoto = subBAttachments.find(name => name.match(/^user-file-icon.*\.png$/));
expect(subBPhoto, 'Sub-doc B photo should be attached to sub-doc B').to.exist;
expect(subDocB._attachments[subBPhoto].content_type).to.equal('image/png');

// Cross-contamination checks: no sub-doc files on main doc
const mainHasSubAFile = mainAttachments.find(name => name.match(/layers/));
expect(mainHasSubAFile, 'Sub-doc A file should not appear on the main doc').to.not.exist;
const mainHasSubBFile = mainAttachments.find(name => name.match(/icon/));
expect(mainHasSubBFile, 'Sub-doc B file should not appear on the main doc').to.not.exist;

// No cross-contamination between sub-docs
const subAHasSubBFile = subAAttachments.find(name => name.match(/icon/));
expect(subAHasSubBFile, 'Sub-doc B file should not appear on sub-doc A').to.not.exist;
const subBHasSubAFile = subBAttachments.find(name => name.match(/layers/));
expect(subBHasSubAFile, 'Sub-doc A file should not appear on sub-doc B').to.not.exist;
});

it('should route file attachments to sub-docs inside repeat groups', async () => {
await commonPage.goToReports();
await commonPage.openFastActionReport('db-doc-repeat-file-upload', false);

await commonEnketoPage.setInputValue('Name', 'Test Repeat Sub Doc');

// First repeat instance is already present; upload a file to it
await commonEnketoPage.addFileInputValue('Repeat photo', mainImagePath, { repeatIndex: 0 });

// Add a second repeat and upload a different file
await commonEnketoPage.addRepeatSection();
await commonEnketoPage.addFileInputValue('Repeat photo', subImagePath, { repeatIndex: 1 });

await genericForm.submitForm();
await commonPage.waitForPageLoaded();

const reportId = await reportsPage.getCurrentReportId();
const report = await utils.getDoc(reportId);

expect(report.fields.name).to.equal('Test Repeat Sub Doc');

// There should be two repeat sections, each with a db-doc-ref
const repeatSections = report.fields.repeat_section;
expect(repeatSections).to.be.an('array');
expect(repeatSections.length).to.equal(2);

// Verify first repeat sub-doc
const repeatDoc1Id = repeatSections[0].repeat_doc_ref;
expect(repeatDoc1Id, 'First repeat doc reference should be populated').to.exist;

const repeatDoc1 = await utils.getDoc(repeatDoc1Id);
expect(repeatDoc1.type).to.equal('repeat_record');

const repeat1Attachments = Object.keys(repeatDoc1._attachments || {});
const repeat1Photo = repeat1Attachments.find(name => name.match(/^user-file-photo-for-upload-form.*\.png$/));
expect(repeat1Photo, 'First repeat doc should have its photo attached').to.exist;

// Verify second repeat sub-doc
const repeatDoc2Id = repeatSections[1].repeat_doc_ref;
expect(repeatDoc2Id, 'Second repeat doc reference should be populated').to.exist;

const repeatDoc2 = await utils.getDoc(repeatDoc2Id);
expect(repeatDoc2.type).to.equal('repeat_record');

const repeat2Attachments = Object.keys(repeatDoc2._attachments || {});
const repeat2Photo = repeat2Attachments.find(name => name.match(/^user-file-layers.*\.png$/));
expect(repeat2Photo, 'Second repeat doc should have its photo attached').to.exist;

// No file attachments should be on the main report doc
const mainAttachments = Object.keys(report._attachments || {});
const mainUserFiles = mainAttachments.filter(name => name.startsWith('user-file-'));
expect(mainUserFiles, 'No user files should be on the main doc').to.be.empty;

// No cross-contamination between repeat sub-docs
const repeat1HasRepeat2File = repeat1Attachments.find(name => name.match(/layers/));
expect(repeat1HasRepeat2File, 'Repeat 2 file should not appear on repeat 1 doc').to.not.exist;
const repeat2HasRepeat1File = repeat2Attachments.find(name => name.match(/photo-for-upload-form/));
expect(repeat2HasRepeat1File, 'Repeat 1 file should not appear on repeat 2 doc').to.not.exist;
});
});
56 changes: 51 additions & 5 deletions webapp/src/ts/services/enketo.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,22 @@ export class EnketoService {
return form;
}

private findFileNodeByFilename($record, filename: string) {
// After upload, Enketo's Nodeset.setVal rewrites file-widget nodes from
// type="binary" to type="file" (#10903 §6.5). Inline-binary blobs from
// draw/signature widgets keep type="binary" and are handled separately.
let match = null;
$record
.find('[type=file]')
.each((_idx, element) => {
if ($(element).text() === filename) {
match = element;
return false; // break
}
});
return match;
}

private xmlToDocs(doc, formXml, xmlVersion, record) {
const recordDoc = $.parseXML(record);
const $record = $($(recordDoc).children()[0]);
Expand Down Expand Up @@ -481,26 +497,56 @@ export class EnketoService {
}
doc.hidden_fields = this.enketoTranslationService.getHiddenFieldList(record, dbDocTags);

// Build a lookup map: sub-doc _couchId -> prepared doc object
const subDocById = new Map<string, any>();
docsToStore.forEach(subDoc => subDocById.set(subDoc._id, subDoc));

// Resolve the owner document for a given XML element by walking
// up to the nearest [db-doc="true"] ancestor. Falls back to the
// main report doc when the element is not inside a sub-doc.
const resolveOwnerDoc = (element) => {
let node = element.parentNode;
while (node && node !== recordDoc) {
if (node._couchId && subDocById.has(node._couchId)) {
return subDocById.get(node._couchId);
}
node = node.parentNode;
}
return doc;
};

// Route FileManager files to the correct owner doc.
// For each file, find the [type=file] node whose text matches
// the filename, then resolve the owner from its position in the
// XML tree.
FileManager
.getCurrentFiles()
.forEach(file => this.attachmentService.add(doc, `user-file-${file.name}`, file, file.type, false));
.forEach(file => {
const ownerDoc = resolveOwnerDoc(
this.findFileNodeByFilename($record, file.name) ?? $record[0]
);
this.attachmentService.add(ownerDoc, `user-file-${file.name}`, file, file.type, false);
});

// The legacy filename scheme is xpath-based (rooted at doc.form) because
// inline-binary widgets (draw/signature) do not store the filename as a
// question value, so xpath is the only stable mapping from answer to
// attachment. The xpath naming is unchanged when routing to a sub-doc;
// only the target doc differs.
const attachLegacyFile = (elem, file, type, alreadyEncoded) => {
const ownerDoc = resolveOwnerDoc(elem);
const xpath = Xpath.getElementXPath(elem);
// replace instance root element node name with form internal ID
const filename = 'user-file' +
(xpath.startsWith('/' + doc.form) ? xpath : xpath.replace(/^\/[^/]+/, '/' + doc.form));
this.attachmentService.add(doc, filename, file, type, alreadyEncoded);
this.attachmentService.add(ownerDoc, filename, file, type, alreadyEncoded);
};

$record
.find('[type=binary]')
.each((idx, element) => {
const file = $(element).text();
if (file) {
// Attach binary file with legacy-style filename because the actual filename is not stored as the question
// value in the form model (and so there is currently no way to map the answer in a saved report to the
// associated file attachment).
attachLegacyFile(element, file, 'image/png', true);
}
});
Expand Down
Loading
Loading