diff --git a/.gitignore b/.gitignore index 463f068ee7f..3fd5618f336 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ config/*/forms/contact/*.xlsx tests/integration/results/ allure* tests/config-temp +tests/utils/config-temp .eslintcache doc-conflicts/ cht-docker-compose.log diff --git a/tests/e2e/default/contacts/forms/family-with-attachments-create.xml b/tests/e2e/default/contacts/forms/family-with-attachments-create.xml new file mode 100644 index 00000000000..3cfb669033a --- /dev/null +++ b/tests/e2e/default/contacts/forms/family-with-attachments-create.xml @@ -0,0 +1,112 @@ + + + + New Family With Attachments + + + + + Family Name + + + Family Photo + + + Primary Contact Name + + + Primary Contact Photo + + + Children + + + Child Name + + + Child Photo + + + + + + + PARENT + contact + family_with_attachments + + + iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII= + NEW + + + PARENT + person + + + iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII= + + + + PARENT + person + + + iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII= + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/default/contacts/forms/family-with-attachments-edit.xml b/tests/e2e/default/contacts/forms/family-with-attachments-edit.xml new file mode 100644 index 00000000000..64a00bf14c4 --- /dev/null +++ b/tests/e2e/default/contacts/forms/family-with-attachments-edit.xml @@ -0,0 +1,82 @@ + + + + Edit Family With Attachments + + + + + Family Name + + + Family Photo + + + Children + + + Child Name + + + Child Photo + + + + + + + PARENT + contact + family_with_attachments + + + + + + + PARENT + person + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/default/contacts/sub-contact-attachments.wdio-spec.js b/tests/e2e/default/contacts/sub-contact-attachments.wdio-spec.js new file mode 100644 index 00000000000..815d300f36a --- /dev/null +++ b/tests/e2e/default/contacts/sub-contact-attachments.wdio-spec.js @@ -0,0 +1,248 @@ +const path = require('path'); +const fs = require('fs'); +const utils = require('@utils'); +const placeFactory = require('@factories/cht/contacts/place'); +const userFactory = require('@factories/cht/users/users'); +const loginPage = require('@page-objects/default/login/login.wdio.page'); +const commonPage = require('@page-objects/default/common/common.wdio.page'); +const commonEnketoPage = require('@page-objects/default/enketo/common-enketo.wdio.page'); +const genericForm = require('@page-objects/default/enketo/generic-form.wdio.page'); +const contactPage = require('@page-objects/default/contacts/contacts.wdio.page'); +const { CONTACT_TYPES } = require('@medic/constants'); + +describe('Sub-contact attachment routing', () => { + const familyPhotoPath = path.join(__dirname, '../enketo/images/photo-for-upload-form.png'); + const primaryContactPhotoPath = path.join(__dirname, '../../../../webapp/src/img/layers.png'); + const childPhotoPath = path.join(__dirname, '../../../../webapp/src/img/icon.png'); + + const places = placeFactory.generateHierarchy(); + const healthCenter = places.get(CONTACT_TYPES.HEALTH_CENTER); + + const onlineUser = userFactory.build({ + place: healthCenter._id, + roles: ['program_officer'] + }); + + const familyType = { + id: 'family_with_attachments', + name_key: 'contact.type.family_with_attachments', + group_key: 'contact.type.family_with_attachments.plural', + create_key: 'contact.type.family_with_attachments.new', + edit_key: 'contact.type.family_with_attachments.edit', + primary_contact_key: '', + parents: [CONTACT_TYPES.HEALTH_CENTER, CONTACT_TYPES.CLINIC, 'district_hospital'], + icon: 'medic-clinic', + create_form: 'form:contact:family_with_attachments:create', + edit_form: 'form:contact:family_with_attachments:edit', + person: false + }; + + const translations = { + 'contact.type.family_with_attachments': 'Family With Attachments', + 'contact.type.family_with_attachments.plural': 'Families With Attachments', + 'contact.type.family_with_attachments.new': 'New Family With Attachments', + 'contact.type.family_with_attachments.edit': 'Edit Family With Attachments' + }; + + const createFormXml = fs.readFileSync( + path.join(__dirname, 'forms/family-with-attachments-create.xml'), + 'utf8' + ); + + const editFormXml = fs.readFileSync( + path.join(__dirname, 'forms/family-with-attachments-edit.xml'), + 'utf8' + ); + + const createFormDoc = { + _id: 'form:contact:family_with_attachments:create', + internalId: 'contact:family_with_attachments:create', + title: 'New Family With Attachments', + type: 'form', + _attachments: { + xml: { + content_type: 'application/octet-stream', + data: Buffer.from(createFormXml).toString('base64'), + } + } + }; + + const editFormDoc = { + _id: 'form:contact:family_with_attachments:edit', + internalId: 'contact:family_with_attachments:edit', + title: 'Edit Family With Attachments', + type: 'form', + _attachments: { + xml: { + content_type: 'application/octet-stream', + data: Buffer.from(editFormXml).toString('base64'), + } + } + }; + + const fetchFamilyAndChildren = async (familyId) => { + const family = await utils.getDoc(familyId); + const [familyDoc, primaryContact] = await utils.getDocs([familyId, family.contact._id]); + const allRows = await utils.db.allDocs({ include_docs: true }); + const repeatChildren = allRows.rows + .map(row => row.doc) + .filter(doc => doc?.parent?._id === familyId && doc._id !== primaryContact._id); + return { family: familyDoc, primaryContact, repeatChildren }; + }; + + const submitFamilyForm = async ({ familyName, primaryContactName, repeatChildren }) => { + await commonEnketoPage.setInputValue('Family Name', familyName); + await commonEnketoPage.addFileInputValue('Family Photo', familyPhotoPath); + + await commonEnketoPage.setInputValue('Primary Contact Name', primaryContactName); + await commonEnketoPage.addFileInputValue('Primary Contact Photo', primaryContactPhotoPath); + + for (let i = 0; i < repeatChildren.length; i++) { + await commonEnketoPage.addRepeatSection(); + await commonEnketoPage.setInputValue('Child Name', repeatChildren[i].name); + await commonEnketoPage.addFileInputValue('Child Photo', repeatChildren[i].photoPath, { repeatIndex: i }); + } + + await genericForm.submitForm(); + await commonPage.waitForPageLoaded(); + await contactPage.waitForContactLoaded(); + }; + + before(async () => { + await utils.saveDocs([...places.values()]); + await utils.createUsers([onlineUser]); + await utils.addTranslations('en', translations); + + const settings = await utils.getSettings(); + settings.contact_types.push(familyType); + await utils.updateSettings({ contact_types: settings.contact_types }, { ignoreReload: true }); + + await utils.saveDocs([createFormDoc, editFormDoc]); + + await loginPage.login(onlineUser); + }); + + after(async () => { + await utils.deleteDocs([createFormDoc._id, editFormDoc._id]); + await utils.revertDb([/^form:/], true); + }); + + afterEach(async () => { + await commonPage.goToPeople(); + await commonPage.waitForPageLoaded(); + }); + + it('should route uploads to main, sibling, and repeat docs', async () => { + await commonPage.goToPeople(healthCenter._id); + await commonPage.clickFastActionFAB({ actionId: familyType.id }); + + await submitFamilyForm({ + familyName: 'Routing Family', + primaryContactName: 'Amina', + repeatChildren: [{ name: 'Kid Alpha', photoPath: childPhotoPath }], + }); + + const familyId = await contactPage.getCurrentContactId(); + expect(familyId).to.exist; + + const { family, primaryContact, repeatChildren } = await fetchFamilyAndChildren(familyId); + + expect(family.name).to.equal('Routing Family'); + expect(family._attachments).to.exist; + const familyAttachmentNames = Object.keys(family._attachments); + // Two attachments per owner doc: the file-widget upload and the inline- + // binary `badge` from the form's instance default. The badge field value is + // its attachment name minus the `user-file-` prefix. + expect(familyAttachmentNames).to.have.lengthOf(2); + const familyBadge = familyAttachmentNames.find(n => n.startsWith('user-file-') && n.endsWith('/badge')); + expect(familyBadge, 'family badge attachment').to.exist; + expect(familyAttachmentNames.find(n => /^user-file-photo-for-upload-form.*\.png$/.test(n))).to.exist; + expect(family.badge).to.equal(familyBadge.replace('user-file-', '')); + + expect(primaryContact.name).to.equal('Amina'); + expect(primaryContact._attachments).to.exist; + const primaryAttachmentNames = Object.keys(primaryContact._attachments); + expect(primaryAttachmentNames).to.have.lengthOf(2); + const primaryBadge = primaryAttachmentNames.find(n => n.startsWith('user-file-') && n.endsWith('/badge')); + expect(primaryBadge, 'primary contact badge attachment').to.exist; + expect(primaryAttachmentNames.find(n => /^user-file-layers.*\.png$/.test(n))).to.exist; + expect(primaryContact.badge).to.equal(primaryBadge.replace('user-file-', '')); + + expect(repeatChildren).to.have.lengthOf(1); + const [child] = repeatChildren; + expect(child.name).to.equal('Kid Alpha'); + expect(child._attachments).to.exist; + const childAttachmentNames = Object.keys(child._attachments); + expect(childAttachmentNames).to.have.lengthOf(2); + const childBadge = childAttachmentNames.find(n => n.startsWith('user-file-') && n.endsWith('/badge')); + expect(childBadge, 'child badge attachment').to.exist; + expect(childAttachmentNames.find(n => /^user-file-icon.*\.png$/.test(n))).to.exist; + expect(child.badge).to.equal(childBadge.replace('user-file-', '')); + + const allAttachmentNames = [ + ...familyAttachmentNames, + ...primaryAttachmentNames, + ...childAttachmentNames, + ]; + expect(new Set(allAttachmentNames).size).to.equal(6); + }); + + it('should keep saved attachments intact when adding a new repeat child on edit', async () => { + await commonPage.goToPeople(healthCenter._id); + await commonPage.clickFastActionFAB({ actionId: familyType.id }); + + await submitFamilyForm({ + familyName: 'Edit Family', + primaryContactName: 'Bilal', + repeatChildren: [{ name: 'Kid One', photoPath: childPhotoPath }], + }); + + const familyId = await contactPage.getCurrentContactId(); + const before = await fetchFamilyAndChildren(familyId); + + const snapshotAttachments = (doc) => Object.fromEntries( + Object.entries(doc._attachments || {}).map(([key, value]) => [key, value.length]) + ); + const beforeFamily = snapshotAttachments(before.family); + const beforePrimary = snapshotAttachments(before.primaryContact); + expect(before.repeatChildren).to.have.lengthOf(1); + const beforeKidOne = snapshotAttachments(before.repeatChildren[0]); + + await commonPage.accessEditOption(); + await commonPage.waitForPageLoaded(); + + await commonEnketoPage.addRepeatSection(); + await commonEnketoPage.setInputValue('Child Name', 'Kid Two'); + await commonEnketoPage.addFileInputValue('Child Photo', familyPhotoPath, { repeatIndex: 0 }); + + await genericForm.submitForm(); + await commonPage.waitForPageLoaded(); + await contactPage.waitForContactLoaded(); + + const after = await fetchFamilyAndChildren(familyId); + + expect(snapshotAttachments(after.family)).to.deep.equal(beforeFamily); + expect(snapshotAttachments(after.primaryContact)).to.deep.equal(beforePrimary); + + // The edit form has no , so no save-time attach happens for it and + // the original badge attachment + field value survive the edit unchanged. + expect(after.family.badge).to.equal(before.family.badge); + expect(after.primaryContact.badge).to.equal(before.primaryContact.badge); + + const kidOneAfter = after.repeatChildren.find(c => c.name === 'Kid One'); + const kidTwoAfter = after.repeatChildren.find(c => c.name === 'Kid Two'); + expect(kidOneAfter, 'Kid One should still exist').to.exist; + expect(kidTwoAfter, 'Kid Two should be created').to.exist; + + expect(snapshotAttachments(kidOneAfter)).to.deep.equal(beforeKidOne); + const kidOneBefore = before.repeatChildren.find(c => c.name === 'Kid One'); + expect(kidOneAfter.badge).to.equal(kidOneBefore.badge); + + // Kid Two is created on edit — the edit form has no badge default for + // children, so the new repeat child has no badge attachment. + expect(kidTwoAfter._attachments).to.exist; + const kidTwoAttachmentNames = Object.keys(kidTwoAfter._attachments); + expect(kidTwoAttachmentNames).to.have.lengthOf(1); + expect(kidTwoAttachmentNames[0]).to.match(/^user-file-photo-for-upload-form.*\.png$/); + }); +}); diff --git a/tests/e2e/default/enketo/forms/db-doc-file-upload.xml b/tests/e2e/default/enketo/forms/db-doc-file-upload.xml new file mode 100644 index 00000000000..99cdb0e021d --- /dev/null +++ b/tests/e2e/default/enketo/forms/db-doc-file-upload.xml @@ -0,0 +1,41 @@ + + + + db-doc-file-upload + + + + + + + + sub_record + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/default/enketo/forms/db-doc-multi-file-upload.xml b/tests/e2e/default/enketo/forms/db-doc-multi-file-upload.xml new file mode 100644 index 00000000000..231549f827d --- /dev/null +++ b/tests/e2e/default/enketo/forms/db-doc-multi-file-upload.xml @@ -0,0 +1,50 @@ + + + + db-doc-multi-file-upload + + + + + + + + sub_record_a + + + + + sub_record_b + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/default/enketo/forms/db-doc-repeat-file-upload.xml b/tests/e2e/default/enketo/forms/db-doc-repeat-file-upload.xml new file mode 100644 index 00000000000..90479783342 --- /dev/null +++ b/tests/e2e/default/enketo/forms/db-doc-repeat-file-upload.xml @@ -0,0 +1,40 @@ + + + + db-doc-repeat-file-upload + + + + + + + + repeat_record + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/default/enketo/submit-db-doc-file-upload.wdio-spec.js b/tests/e2e/default/enketo/submit-db-doc-file-upload.wdio-spec.js new file mode 100644 index 00000000000..b43d9b5f5d3 --- /dev/null +++ b/tests/e2e/default/enketo/submit-db-doc-file-upload.wdio-spec.js @@ -0,0 +1,180 @@ +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'); + + // The repeat starts with zero instances, so add the first intance before uploading + await commonEnketoPage.addRepeatSection(); + 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; + }); +}); diff --git a/webapp/src/ts/services/contact-save.service.ts b/webapp/src/ts/services/contact-save.service.ts index 8d72dabd892..1bd0d37de91 100644 --- a/webapp/src/ts/services/contact-save.service.ts +++ b/webapp/src/ts/services/contact-save.service.ts @@ -7,16 +7,26 @@ import { EnketoTranslationService } from '@mm-services/enketo-translation.servic import { ExtractLineageService } from '@mm-services/extract-lineage.service'; import { AttachmentService } from '@mm-services/attachment.service'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; +import { USER_FILE_PREFIX } from '@mm-services/enketo.service'; import { Contact, Qualifier } from '@medic/cht-datasource'; import { Xpath } from '@mm-providers/xpath-element-path.provider'; import FileManager from '../../js/enketo/file-manager'; +interface ContactOwnerContext { + root: Element; + preparedDocs: Record[]; + mainDoc: Record; + submittedRepeatsLen: number; +} + +type AttachmentNamesByDoc = Map, Set>; +type FileNameMapByDoc = Map, Map>; + @Injectable({ providedIn: 'root' }) export class ContactSaveService { private readonly CONTACT_FIELD_NAMES = [ 'parent', 'contact' ]; - private readonly USER_FILE_ATTACHMENT_PREFIX = 'user-file-'; private readonly getContactFromDatasource: ReturnType; constructor( @@ -55,7 +65,7 @@ export class ContactSaveService { // Process attachments for all documents const preparedDocs = [ doc ].concat(repeated, siblings); // NB: order matters: #4200 - this.processAllAttachments(preparedDocs, xmlStr); + this.processAllAttachments(preparedDocs, submitted, xmlStr); return { docId: doc._id, @@ -173,51 +183,241 @@ export class ContactSaveService { }); } - private processAllAttachments(preparedDocs: Record[], xmlStr: string) { - const mainDoc = preparedDocs[0]; - const newAttachmentNames = new Set(); - const fileNameMap = new Map(); // Map of original file name -> sanitized file name + private processAllAttachments( + preparedDocs: Record[], + submitted: { doc; siblings?; repeats? }, + xmlStr: string, + ) { + const ctx: ContactOwnerContext = { + root: $.parseXML(xmlStr).documentElement, + preparedDocs, + mainDoc: preparedDocs[0], + submittedRepeatsLen: submitted?.repeats?.child_data?.length ?? 0 + }; + + const newAttachmentNamesByDoc: AttachmentNamesByDoc = new Map(); + const fileNameMapByDoc: FileNameMapByDoc = new Map(); + + const fileManagerNames = this.attachUploadedFiles(ctx, newAttachmentNamesByDoc, fileNameMapByDoc); + this.attachInlineBinaryFields(ctx, fileManagerNames, newAttachmentNamesByDoc); + this.finalizeDocs(preparedDocs, fileNameMapByDoc, newAttachmentNamesByDoc); + } + + private trackNewAttachment(map: AttachmentNamesByDoc, doc: Record, name: string) { + const set = map.get(doc) ?? new Set(); + set.add(name); + map.set(doc, set); + } + + private trackFileName(map: FileNameMapByDoc, doc: Record, original: string, sanitized: string) { + const names = map.get(doc) ?? new Map(); + names.set(original, sanitized); + map.set(doc, names); + } - // Attach files from FileManager (uploaded via file widgets) + /** Attaches FileManager uploads, routed per sub-doc. Returns the original + * filenames so the binary pass can skip upload widgets that also carry + * `type="binary"` markup. */ + private attachUploadedFiles( + ctx: ContactOwnerContext, + newAttachmentNamesByDoc: AttachmentNamesByDoc, + fileNameMapByDoc: FileNameMapByDoc, + ): Set { + const fileManagerNames = new Set(); FileManager .getCurrentFiles() .forEach(file => { + const ownerDoc = this.findContactOwnerForFilename(file.name, ctx); const sanitizedFileName = this.sanitizeFileName(file.name); - const attachmentName = `${this.USER_FILE_ATTACHMENT_PREFIX}${sanitizedFileName}`; - newAttachmentNames.add(attachmentName); - this.attachmentService.add(mainDoc, attachmentName, file, file.type, false); + const attachmentName = `${USER_FILE_PREFIX}${sanitizedFileName}`; - // Track the mapping for field value sanitization - fileNameMap.set(file.name, sanitizedFileName); + this.attachmentService.add(ownerDoc, attachmentName, file, file.type, false); + this.trackNewAttachment(newAttachmentNamesByDoc, ownerDoc, attachmentName); + this.trackFileName(fileNameMapByDoc, ownerDoc, file.name, sanitizedFileName); + fileManagerNames.add(file.name); }); + return fileManagerNames; + } - // Process binary fields from XML - if (xmlStr) { - const $record = $($.parseXML(xmlStr)); - const formId = $record.find(':first').attr('id'); - - $record - .find('[type=binary]') - .each((_idx, element) => { - const content = $(element).text(); - if (content) { - const xpath = Xpath.getElementXPath(element); - // Replace instance root element node name with form internal ID - const filename = this.USER_FILE_ATTACHMENT_PREFIX.slice(0, -1) + - (xpath.startsWith('/' + formId) ? xpath : xpath.replace(/^\/[^/]+/, '/' + formId)); - newAttachmentNames.add(filename); - this.attachmentService.add(mainDoc, filename, content, 'image/png', true); - } - }); + private attachInlineBinaryFields( + ctx: ContactOwnerContext, + fileManagerNames: Set, + newAttachmentNamesByDoc: AttachmentNamesByDoc, + ) { + $(ctx.root) + .find('[type=binary]') + .each((_idx, element: Element) => { + this.attachOneBinaryField(element, ctx, fileManagerNames, newAttachmentNamesByDoc); + }); + } + + /** Attaches the blob to the owning doc and mirrors the bare reference into the + * doc's field value, so the renderer resolves the image via + * `USER_FILE_PREFIX + value`. */ + private attachOneBinaryField( + element: Element, + ctx: ContactOwnerContext, + fileManagerNames: Set, + newAttachmentNamesByDoc: AttachmentNamesByDoc, + ) { + const content = $(element).text(); + // Skip: empty; a value that already looks like an attachment reference + // (re-saves stay idempotent); or a file-upload widget tagged type="binary" + // whose blob the FileManager pass already attached. + if (!content || this.enketoTranslationService.isAttachmentRef(content) || fileManagerNames.has(content)) { + return; + } + + const ownerDoc = this.resolveContactOwnerDoc(element, ctx); + const reference = this.computeBinaryReference(element, ctx); + const attachmentName = `${USER_FILE_PREFIX}${reference}`; + + this.attachmentService.add(ownerDoc, attachmentName, content, 'image/png', true); + this.trackNewAttachment(newAttachmentNamesByDoc, ownerDoc, attachmentName); + + // Field path is the element's position relative to its section container + // (the section for main/sibling, the i-th repeat child for a repeat). + const container = this.findFieldContainerElement(element, ctx); + const fieldPath = container && this.computeFieldPath(element, container); + if (fieldPath) { + objectPath.set(ownerDoc, fieldPath, reference); } + } + + /** Bare attachment reference for a binary node: the element's xpath with the + * instance root swapped for the form internal ID and the leading slash + * dropped -> `//`. */ + private computeBinaryReference(element: Element, ctx: ContactOwnerContext): string { + const formId = $(ctx.root).attr('id'); + const xpath = Xpath.getElementXPath(element); + return (xpath.startsWith('/' + formId) ? xpath : xpath.replace(/^\/[^/]+/, '/' + formId)).slice(1); + } - // Sanitize field values in the document to match sanitized attachment names - this.sanitizeFieldValues(mainDoc, fileNameMap); + /** Per-doc field-value sanitization + orphan cleanup. */ + private finalizeDocs( + preparedDocs: Record[], + fileNameMapByDoc: FileNameMapByDoc, + newAttachmentNamesByDoc: AttachmentNamesByDoc, + ) { + for (const doc of preparedDocs) { + const nameMap = fileNameMapByDoc.get(doc) ?? new Map(); + this.sanitizeFieldValues(doc, nameMap); + + const newNames = newAttachmentNamesByDoc.get(doc) ?? new Set(); + const referenced = this.findReferencedAttachments(doc); + const valid = new Set([ ...newNames, ...referenced ]); + this.removeOrphanedAttachments(doc, valid); + } + } - // Remove orphaned user attachments that are no longer referenced - const referencedAttachmentNames = this.findReferencedAttachments(mainDoc); - const validAttachmentNames = new Set([...newAttachmentNames, ...referencedAttachmentNames]); - this.removeOrphanedAttachments(mainDoc, validAttachmentNames); + private findSectionForElement(el: Element, root: Element): Element | null { + let section: Element = el; + while (section.parentNode && section.parentNode !== root) { + section = section.parentNode as Element; + } + return section.parentNode === root ? section : null; + } + + private isMainSection(section: Element, root: Element): boolean { + const ignored = new Set([ 'meta', 'inputs', 'repeat' ]); + const firstSection = (Array.from(root.children) as Element[]) + .find(c => !ignored.has(c.tagName) && c.childElementCount > 0); + return section === firstSection; + } + + private findSiblingDoc( + section: Element, + preparedDocs: Record[], + mainDoc: Record, + ): Record { + const siblingId = mainDoc[section.tagName]?._id; + return preparedDocs.find((d: Record) => d._id === siblingId) ?? mainDoc; + } + + private findRepeatDoc( + section: Element, + el: Element, + preparedDocs: Record[], + submittedRepeatsLen: number, + ): Record | null { + const repeatChildren = Array.from(section.children) as Element[]; + const childIdx = repeatChildren.findIndex(c => c.contains(el)); + if (childIdx >= 0 && childIdx < submittedRepeatsLen) { + return preparedDocs[1 + childIdx]; + } + return null; + } + + /** + * The element whose children map 1:1 to the owner doc's top-level fields: + * the section itself for main/sibling, the i-th `` of `` for + * repeats. Anchors `computeFieldPath`. + */ + private findFieldContainerElement(el: Element, ctx: ContactOwnerContext): Element | null { + const section = this.findSectionForElement(el, ctx.root); + if (!section) { + return null; + } + if (section.tagName === 'repeat') { + const repeatChildren = Array.from(section.children) as Element[]; + return repeatChildren.find(c => c.contains(el)) ?? null; + } + return section; + } + + /** + * Dot-path of the element's position relative to its container, e.g. + * `` → `group.photo`. + */ + private computeFieldPath(el: Element, container: Element): string { + const segments: string[] = []; + let node: Node | null = el; + while (node && node !== container) { + segments.unshift((node as Element).tagName); + node = node.parentNode; + } + return segments.join('.'); + } + + /** + * Resolves the prepared sub-doc that owns an XML element by walking up to its + * section root (a direct child of the form root): + * + * - main section (first non-meta/inputs/repeat element child of root) -> preparedDocs[0] + * - / peer of main section -> sibling doc with matching _id + * - 's i-th -> preparedDocs[1 + i] (repeats precede siblings in concat) + * - anything else (meta, inputs, unknown) -> mainDoc + */ + private resolveContactOwnerDoc(el: Element, ctx: ContactOwnerContext): Record { + const { root, preparedDocs, mainDoc, submittedRepeatsLen } = ctx; + const section = this.findSectionForElement(el, root); + if (!section || this.isMainSection(section, root)) { + return mainDoc; + } + if (this.CONTACT_FIELD_NAMES.includes(section.tagName)) { + return this.findSiblingDoc(section, preparedDocs, mainDoc); + } + if (section.tagName === 'repeat') { + return this.findRepeatDoc(section, el, preparedDocs, submittedRepeatsLen) ?? mainDoc; + } + return mainDoc; + } + + /** + * Resolves the owning prepared doc for a FileManager upload by matching the + * filename against `[type=file]` widget text, falling back to mainDoc when no + * node matches. Filenames are unique within a form session (Enketo's + * timestamp suffix), so the first match is the only match. + */ + private findContactOwnerForFilename(filename: string, ctx: ContactOwnerContext): Record { + const match = $(ctx.root) + .find('[type=file]') + .toArray() + .find(el => $(el).text() === filename); + if (!match) { + return ctx.mainDoc; + } + return this.resolveContactOwnerDoc(match, ctx); } private findReferencedAttachments(doc: Record): Set { @@ -232,7 +432,7 @@ export class ContactSaveService { if (typeof value !== 'string' || !value) { return false; } - const possibleAttachmentName = `${this.USER_FILE_ATTACHMENT_PREFIX}${value}`; + const possibleAttachmentName = `${USER_FILE_PREFIX}${value}`; return existingAttachmentNames.includes(possibleAttachmentName); }; @@ -240,7 +440,7 @@ export class ContactSaveService { referencedPaths.forEach(path => { const value = objectPath.get(doc, path); - referenced.add(`${this.USER_FILE_ATTACHMENT_PREFIX}${value}`); + referenced.add(`${USER_FILE_PREFIX}${value}`); }); return referenced; @@ -252,7 +452,7 @@ export class ContactSaveService { } Object.keys(doc._attachments) - .filter(name => name.startsWith(this.USER_FILE_ATTACHMENT_PREFIX) && !validAttachmentNames.has(name)) + .filter(name => name.startsWith(USER_FILE_PREFIX) && !validAttachmentNames.has(name)) .forEach(name => this.attachmentService.remove(doc, name)); } diff --git a/webapp/src/ts/services/enketo-translation.service.ts b/webapp/src/ts/services/enketo-translation.service.ts index 1b45cbbca40..7c67af682fd 100644 --- a/webapp/src/ts/services/enketo-translation.service.ts +++ b/webapp/src/ts/services/enketo-translation.service.ts @@ -35,16 +35,12 @@ export class EnketoTranslationService { path = path || ''; const result = {}; this.withElements(data).forEach((n:any) => { - const typeAttribute = n.attributes.getNamedItem('type'); const updatedPath = path + '/' + n.nodeName; let value; const hasChildren = this.withElements(n.childNodes).length > 0; if (hasChildren) { value = this.nodesToJs(n.childNodes, repeatPaths, updatedPath); - } else if (typeAttribute && typeAttribute.value === 'binary') { - // this is attached to the doc instead of inlined - value = ''; } else { value = n.textContent; } @@ -91,7 +87,56 @@ export class EnketoTranslationService { return found; } - return elem.children(name); + // Match by node name in JS rather than passing `name` to the jQuery selector + // data keys can be `_attachments` names containing ':' / '/' (form-id-derived), + // which jQuery would reject as an invalid selector. + return elem.children().filter((_idx, child) => child.nodeName === name); + } + + /** + * True for an attachment-reference value: the position-derived name stored for + * an inline-binary field after its blob is attached, e.g. + * `//` (slash-delimited XML names, no `user-file-` + * prefix). Inline base64 never matches: it carries `+`/`=` and isn't a path. + */ + isAttachmentRef(value): boolean { + // `:` appears in contact form ids (e.g. `contact:person:create`); base64 + // never contains `:` so allowing it here doesn't widen the base64 overlap. + return typeof value === 'string' && /^[A-Za-z_][\w.:-]*(?:\/[A-Za-z_][\w.:-]*)+$/.test(value); + } + + // For [type=binary] nodes the form's instance default / calculate / itext + // base64 is the source of truth. Skip the bind when the saved value is empty + // (keep the form default) or is an attachment reference (the real data lives + // in the attachment). Genuine inline base64 still binds. + private shouldSkipBinaryBind(elem, data): boolean { + const typeAttr = elem.attr ? elem.attr('type') : elem[0]?.getAttribute?.('type'); + if (typeAttr !== 'binary') { + return false; + } + return [ null, undefined, '' ].includes(data) || this.isAttachmentRef(data); + } + + private bindArrayToXml(elem, data) { + const parent = elem.parent(); + elem.remove(); + + data.forEach((dataEntry) => { + const clone = elem.clone(); + this.bindJsonToXml(clone, dataEntry); + parent.append(clone); + }); + } + + private bindObjectToXml(elem, data, childMatcher?) { + if (!elem.children().length) { + this.bindJsonToXml(elem, data._id); + } + + Object.keys(data).forEach((key) => { + const current = this.findCurrentElement(elem, key, childMatcher); + this.bindJsonToXml(current, data[key]); + }); } bindJsonToXml(elem, data, childMatcher?) { @@ -100,32 +145,21 @@ export class EnketoTranslationService { elem.removeAttr('jr:template'); elem.removeAttr('template'); + if (this.shouldSkipBinaryBind(elem, data)) { + return; + } + if (data === null || typeof data !== 'object') { elem.text(data); return; } if (Array.isArray(data)) { - const parent = elem.parent(); - elem.remove(); - - data.forEach((dataEntry) => { - const clone = elem.clone(); - this.bindJsonToXml(clone, dataEntry); - parent.append(clone); - }); + this.bindArrayToXml(elem, data); return; } - if (!elem.children().length) { - this.bindJsonToXml(elem, data._id); - } - - Object.keys(data).forEach((key) => { - const value = data[key]; - const current = this.findCurrentElement(elem, key, childMatcher); - this.bindJsonToXml(current, value); - }); + this.bindObjectToXml(elem, data, childMatcher); } getHiddenFieldList (model, dbDocFields:Array) { diff --git a/webapp/src/ts/services/enketo.service.ts b/webapp/src/ts/services/enketo.service.ts index 2a219d00e0f..be1cfa1ce1b 100644 --- a/webapp/src/ts/services/enketo.service.ts +++ b/webapp/src/ts/services/enketo.service.ts @@ -18,6 +18,14 @@ import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { Qualifier, Report } from '@medic/cht-datasource'; import { DOC_TYPES } from '@medic/constants'; +/** + * Prefix for every CouchDB attachment created from a form media field, so a + * field's value is uniformly resolved as `USER_FILE_PREFIX + value`: + * - file-widget uploads -> `user-file-` + * - inline-binary fields -> `user-file-//` + */ +export const USER_FILE_PREFIX = 'user-file-'; + /** * Service for interacting with Enketo forms. This code is intended for displaying forms in the CHT as well as being * reused by code outside the CHT (e.g. cht-conf-test-harness). All logic that is proper to Enketo functionality should @@ -362,6 +370,13 @@ export class EnketoService { return form; } + private findFileNodeByFilename($record, filename: string) { + return $record + .find('[type=file]') + .toArray() + .find((element) => $(element).text() === filename) ?? $record[0]; + } + private xmlToDocs(doc, formXml, xmlVersion, record) { const recordDoc = $.parseXML(record); const $record = $($(recordDoc).children()[0]); @@ -465,15 +480,17 @@ export class EnketoService { $element.text(refId); }); - const docsToStore = $record - .find('[db-doc=true]') - .map((idx, element) => { - const docToStore: any = this.enketoTranslationService.reportRecordToJs(getOuterHTML(element)); - docToStore._id = getId(Xpath.getElementXPath(element)); - docToStore.reported_date = Date.now(); - return docToStore; - }) - .get(); + // Sub-docs are parsed twice: first to populate fields (so the binary loop + // can read each sub-doc's `type` and route attachments to the right doc), + // then again after the loop rewrites node text to the attachment reference + // so the final field values use those references, not the inline base64. + const subDocElements = $record.find('[db-doc=true]').toArray(); + const docsToStore = subDocElements.map((element: any) => { + const docToStore: any = this.enketoTranslationService.reportRecordToJs(getOuterHTML(element)); + docToStore._id = getId(Xpath.getElementXPath(element)); + docToStore.reported_date = Date.now(); + return docToStore; + }); doc._id = getId('/*'); if (xmlVersion) { @@ -481,30 +498,78 @@ 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(); + 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 each FileManager file to its owner doc: match the filename against a + // [type=file] node, then resolve the owner from its position in the tree. + // Inline [type=binary] fields are handled by the loop below. 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_PREFIX}${file.name}`, file, file.type, false); + }); - const attachLegacyFile = (elem, file, type, alreadyEncoded) => { + // Attaches an inline-binary blob and returns its bare reference (attachment + // name minus USER_FILE_PREFIX), so node text / parsed fields resolve via the + // same `USER_FILE_PREFIX + value` rule as file-widget uploads. + const attachInlineBinary = (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); + // Sub-docs carry their own `.form`, so each sub-doc's binaries are + // namespaced by it; the main report doc falls back to `doc.form`. + const formId = ownerDoc.form || doc.form; + // replace instance root element node name with form internal ID, drop + // the leading slash -> `//` + const reference = (xpath.startsWith('/' + formId) ? xpath : xpath.replace(/^\/[^/]+/, '/' + formId)) + .slice(1); + this.attachmentService.add(ownerDoc, `${USER_FILE_PREFIX}${reference}`, file, type, alreadyEncoded); + return reference; }; $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); + if (!file) { + return; } + // Skip values that already look like an attachment reference, so a + // re-save can't double-attach the reference string as base64 data. + if (this.enketoTranslationService.isAttachmentRef(file)) { + return; + } + const reference = attachInlineBinary(element, file, 'image/png', true); + // Rewrite node text to the bare reference so the re-parse below puts the + // reference in doc.fields instead of the inline base64. + $(element).text(reference); }); + // Re-parse sub-docs from the now-rewritten XML and merge in the + // reference-name field values. + subDocElements.forEach((element: any, idx: number) => { + const parsed = this.enketoTranslationService.reportRecordToJs(getOuterHTML(element)); + Object.assign(docsToStore[idx], parsed); + }); + record = getOuterHTML($record[0]); // remove old style content attachment diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts index f07f0040fd4..7229c7531cb 100644 --- a/webapp/src/ts/services/form.service.ts +++ b/webapp/src/ts/services/form.service.ts @@ -404,6 +404,7 @@ export class FormService { const docs = await this.contactSaveService.save(form, docId, typeFields, xmlVersion); const preparedDocs = await this.applyTransitions(docs); + await this.validateAttachments(preparedDocs.preparedDocs); const primaryDoc = preparedDocs.preparedDocs.find(doc => doc.type === type); diff --git a/webapp/src/ts/services/format-data-record.service.ts b/webapp/src/ts/services/format-data-record.service.ts index 84641f6e660..dbae897ba21 100644 --- a/webapp/src/ts/services/format-data-record.service.ts +++ b/webapp/src/ts/services/format-data-record.service.ts @@ -461,6 +461,11 @@ export class FormatDataRecordService { return undefined; } const isImagePath = filePath => doc._attachments[filePath]?.content_type?.startsWith('image/'); + // A media field's value is its attachment name minus the `user-file-` + // prefix, for both file-widget uploads and inline-binary fields. Inline- + // binary values carry the form/sub-doc prefix (`//`), + // which is what makes sub-doc rendering work without rebuilding it from the + // label. const filePath = 'user-file-' + value; if (isImagePath(filePath)) { return filePath; diff --git a/webapp/tests/karma/js/enketo/file-manager.spec.ts b/webapp/tests/karma/js/enketo/file-manager.spec.ts index 4755f072e78..d18c9fec707 100644 --- a/webapp/tests/karma/js/enketo/file-manager.spec.ts +++ b/webapp/tests/karma/js/enketo/file-manager.spec.ts @@ -28,6 +28,43 @@ describe('file-manager', () => { expect(fileManager.getMaxSizeReadable()).to.equal(enketoConstants.maxAttachmentSizeReadable); }); + describe('getCurrentFiles', () => { + let form; + + const addFileInput = (filename, postfix) => { + const input = document.createElement('input'); + input.type = 'file'; + if (postfix !== undefined) { + input.dataset.filenamePostfix = postfix; + } + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(new File([ 'content' ], filename, { type: 'image/png' })); + input.files = dataTransfer.files; + form.appendChild(input); + return input; + }; + + beforeEach(() => { + form = document.createElement('form'); + form.className = 'or'; + document.body.appendChild(form); + }); + + afterEach(() => form.remove()); + + it('uniquifies the same file uploaded to two inputs via the timestamp postfix', () => { + addFileInput('photo.png', '-10_30_01'); + addFileInput('photo.png', '-10_30_02'); + + const files = fileManager.getCurrentFiles(); + + expect(files.length).to.equal(2); + expect(files[0].name).to.equal('photo-10_30_01.png'); + expect(files[1].name).to.equal('photo-10_30_02.png'); + expect(files[0].name).to.not.equal(files[1].name); + }); + }); + describe('getObjectUrl', () => { const fakeUrl = 'fake-url'; let originalCHTCore; diff --git a/webapp/tests/karma/ts/services/contact-save.service.spec.ts b/webapp/tests/karma/ts/services/contact-save.service.spec.ts index ec045da2be4..5112e5142dd 100644 --- a/webapp/tests/karma/ts/services/contact-save.service.spec.ts +++ b/webapp/tests/karma/ts/services/contact-save.service.spec.ts @@ -24,6 +24,8 @@ describe('ContactSave service', () => { beforeEach(() => { enketoTranslationService = { contactRecordToJs: sinon.stub(), + // pure regex helper — delegate to the real implementation + isAttachmentRef: (value) => new EnketoTranslationService().isAttachmentRef(value), }; extractLineageService = { extract: sinon.stub() }; @@ -419,9 +421,9 @@ describe('ContactSave service', () => { 'Jane Smith' + '+254712345679' + 'female' + - '' + + '' + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' + - '' + + '' + '' + ''; @@ -444,7 +446,7 @@ describe('ContactSave service', () => { expect( addCall.args[1], 'Should use XPath-based attachment name for binary field' - ).to.equal('user-file/contact:person:create/person/signature'); + ).to.equal('user-file-contact:person:create/person/badge'); expect( addCall.args[2], 'Should pass the base64 content' @@ -670,7 +672,7 @@ describe('ContactSave service', () => { '+254712345680' + 'female' + 'BASE64_PHOTO_DATA' + - 'BASE64_SIGNATURE_DATA' + + 'BASE64_BADGE_DATA' + '' + ''; @@ -713,19 +715,538 @@ describe('ContactSave service', () => { expect( photoCall.args[1], 'Photo binary field XPath-based name' - ).to.equal('user-file/contact:person:create/person/photo'); + ).to.equal('user-file-contact:person:create/person/photo'); expect(photoCall.args[2], 'Photo binary field content').to.equal('BASE64_PHOTO_DATA'); expect(photoCall.args[3], 'Binary field content type').to.equal('image/png'); expect(photoCall.args[4], 'Binary field should be pre-encoded').to.be.true; - const signatureCall = attachmentService.add.getCall(3); + const badgeCall = attachmentService.add.getCall(3); expect( - signatureCall.args[1], - 'Signature binary field XPath-based name' - ).to.equal('user-file/contact:person:create/person/signature'); - expect(signatureCall.args[2], 'Signature binary field content').to.equal('BASE64_SIGNATURE_DATA'); - expect(signatureCall.args[3], 'Binary field content type').to.equal('image/png'); - expect(signatureCall.args[4], 'Binary field should be pre-encoded').to.be.true; + badgeCall.args[1], + 'Badge binary field XPath-based name' + ).to.equal('user-file-contact:person:create/person/badge'); + expect(badgeCall.args[2], 'Badge binary field content').to.equal('BASE64_BADGE_DATA'); + expect(badgeCall.args[3], 'Binary field content type').to.equal('image/png'); + expect(badgeCall.args[4], 'Binary field should be pre-encoded').to.be.true; + }); + }); + + describe('attachment routing to sub-contacts', () => { + // Each test uses a parent-registration shaped XML with three potential + // owners: main section , sibling , and a + // repeated under . + + const stubSiblingAndRepeat = () => { + // identity extract so prepared docs keep their _id and we can compare + extractLineageService.extract.callsFake(contact => contact); + }; + + it('routes a file uploaded inside a sibling section to that sibling doc', async () => { + const xml = + '' + + '' + + 'Kigali HF' + + '' + + 'Amina' + + 'amina.png' + + '' + + ''; + const form = { getDataStr: () => xml }; + + enketoTranslationService.contactRecordToJs.returns({ + doc: { _id: 'main1', type: 'family', name: 'Kigali HF', contact: 'NEW' }, + siblings: { contact: { _id: 'sib1', type: 'person', name: 'Amina', parent: 'PARENT' } }, + repeats: {}, + }); + stubSiblingAndRepeat(); + + const aminaFile = new File(['x'], 'amina.png', { type: 'image/png' }); + sinon.stub(FileManager, 'getCurrentFiles').returns([ aminaFile ]); + + await service.save(form, null, 'family'); + + const fileCall = attachmentService.add.getCalls() + .find(c => c.args[1] === 'user-file-amina.png'); + expect(fileCall, 'sibling-routed file attachment should exist').to.not.be.undefined; + expect(fileCall.args[0]._id, 'file should land on sibling doc').to.equal('sib1'); + expect(fileCall.args[2]).to.equal(aminaFile); + + const calls = attachmentService.add.getCalls(); + expect( + calls.some(c => c.args[0]._id === 'main1' && c.args[1] === 'user-file-amina.png'), + 'main doc should not receive the sibling file' + ).to.be.false; + }); + + it('routes a file uploaded inside a repeat child to the i-th repeat doc', async () => { + const xml = + '' + + '' + + 'Kigali HF' + + '' + + 'Child AchildA.png' + + 'Child BchildB.png' + + '' + + ''; + const form = { getDataStr: () => xml }; + + enketoTranslationService.contactRecordToJs.returns({ + doc: { _id: 'main1', type: 'family', name: 'Kigali HF' }, + siblings: {}, + repeats: { child_data: [ + { _id: 'kid1', type: 'person', name: 'Child A', parent: 'PARENT' }, + { _id: 'kid2', type: 'person', name: 'Child B', parent: 'PARENT' }, + ] }, + }); + stubSiblingAndRepeat(); + + const fileA = new File(['a'], 'childA.png', { type: 'image/png' }); + const fileB = new File(['b'], 'childB.png', { type: 'image/png' }); + sinon.stub(FileManager, 'getCurrentFiles').returns([ fileA, fileB ]); + + await service.save(form, null, 'family'); + + const calls = attachmentService.add.getCalls(); + const aTarget = calls.find(c => c.args[1] === 'user-file-childA.png'); + const bTarget = calls.find(c => c.args[1] === 'user-file-childB.png'); + expect(aTarget?.args[0]._id, 'childA file -> first repeat doc').to.equal('kid1'); + expect(bTarget?.args[0]._id, 'childB file -> second repeat doc').to.equal('kid2'); + }); + + it('routes mixed uploads to their respective sub-docs', async () => { + const xml = + '' + + '' + + 'Kigali HFfacility.jpg' + + '' + + 'Amina' + + 'amina.png' + + '' + + '' + + 'Child AchildA.png' + + '' + + ''; + const form = { getDataStr: () => xml }; + + enketoTranslationService.contactRecordToJs.returns({ + doc: { _id: 'main1', type: 'family', name: 'Kigali HF', contact: 'NEW' }, + siblings: { contact: { _id: 'sib1', type: 'person', name: 'Amina', parent: 'PARENT' } }, + repeats: { child_data: [ + { _id: 'kid1', type: 'person', name: 'Child A', parent: 'PARENT' }, + ] }, + }); + stubSiblingAndRepeat(); + + const facilityFile = new File(['f'], 'facility.jpg', { type: 'image/jpeg' }); + const aminaFile = new File(['a'], 'amina.png', { type: 'image/png' }); + const childFile = new File(['c'], 'childA.png', { type: 'image/png' }); + sinon.stub(FileManager, 'getCurrentFiles').returns([ facilityFile, aminaFile, childFile ]); + + await service.save(form, null, 'family'); + + const calls = attachmentService.add.getCalls(); + const ownerOf = (name: string) => calls.find(c => c.args[1] === name)?.args[0]._id; + + expect(ownerOf('user-file-facility.jpg'), 'facility upload -> main').to.equal('main1'); + expect(ownerOf('user-file-amina.png'), 'amina upload -> sibling').to.equal('sib1'); + expect(ownerOf('user-file-childA.png'), 'child upload -> repeat[0]').to.equal('kid1'); + + // each filename appears exactly once across all add calls + ['user-file-facility.jpg', 'user-file-amina.png', 'user-file-childA.png'] + .forEach(name => { + const matches = calls.filter(c => c.args[1] === name); + expect(matches.length, `${name} should be added exactly once`).to.equal(1); + }); + }); + + it('falls back to the main doc when no XML binary node matches the filename', async () => { + const xml = + '' + + '' + + 'Kigali HF' + + ''; + const form = { getDataStr: () => xml }; + + enketoTranslationService.contactRecordToJs.returns({ + doc: { _id: 'main1', type: 'family', name: 'Kigali HF' }, + }); + + const orphan = new File(['o'], 'orphan.png', { type: 'image/png' }); + sinon.stub(FileManager, 'getCurrentFiles').returns([ orphan ]); + + await service.save(form, null, 'family'); + + const call = attachmentService.add.getCalls() + .find(c => c.args[1] === 'user-file-orphan.png'); + expect(call?.args[0]._id, 'orphan file should fall back to main').to.equal('main1'); + }); + + it('routes inline binary content from a sibling section to the sibling doc', async () => { + const xml = + '' + + '' + + 'Kigali HF' + + '' + + 'Amina' + + 'BASE64_BADGE_DATA' + + '' + + ''; + const form = { getDataStr: () => xml }; + + enketoTranslationService.contactRecordToJs.returns({ + doc: { _id: 'main1', type: 'family', name: 'Kigali HF', contact: 'NEW' }, + siblings: { contact: { _id: 'sib1', type: 'person', name: 'Amina', parent: 'PARENT' } }, + repeats: {}, + }); + stubSiblingAndRepeat(); + sinon.stub(FileManager, 'getCurrentFiles').returns([]); + + await service.save(form, null, 'family'); + + const badgeCall = attachmentService.add.getCalls() + .find(c => c.args[1] === 'user-file-contact:family:create/contact/badge'); + expect(badgeCall, 'sibling-routed inline binary should exist').to.not.be.undefined; + expect(badgeCall.args[0]._id, 'inline binary should land on sibling').to.equal('sib1'); + expect(badgeCall.args[2]).to.equal('BASE64_BADGE_DATA'); + expect(badgeCall.args[3]).to.equal('image/png'); + expect(badgeCall.args[4]).to.be.true; + expect(badgeCall.args[0].badge).to.equal('contact:family:create/contact/badge'); + }); + + it('routes inline binary content inside a repeat child to the repeat doc', async () => { + const xml = + '' + + '' + + 'Kigali HF' + + '' + + 'Child ABASE64_KID_BADGE' + + '' + + ''; + const form = { getDataStr: () => xml }; + + enketoTranslationService.contactRecordToJs.returns({ + doc: { _id: 'main1', type: 'family', name: 'Kigali HF' }, + siblings: {}, + repeats: { child_data: [ + { _id: 'kid1', type: 'person', name: 'Child A', parent: 'PARENT' }, + ] }, + }); + stubSiblingAndRepeat(); + sinon.stub(FileManager, 'getCurrentFiles').returns([]); + + const result = await service.save(form, null, 'family'); + + const badgeCall = attachmentService.add.getCalls().find(c => c.args[2] === 'BASE64_KID_BADGE'); + expect(badgeCall, 'repeat-child binary attach should exist').to.not.be.undefined; + expect(badgeCall.args[0]._id, 'binary lands on the i-th repeat doc').to.equal('kid1'); + + const kid = result.preparedDocs.find(d => d._id === 'kid1'); + expect(kid.badge).to.equal(badgeCall.args[1].replace('user-file-', '')); + }); + + it('mirrors a nested-group binary value at its dotted field path', async () => { + const xml = + '' + + '' + + '' + + 'Kigali HF' + + '
BASE64_NESTED
' + + '
' + + '
'; + const form = { getDataStr: () => xml }; + + enketoTranslationService.contactRecordToJs.returns({ + doc: { _id: 'main1', type: 'family', name: 'Kigali HF', details: {} }, + }); + sinon.stub(FileManager, 'getCurrentFiles').returns([]); + + const result = await service.save(form, null, 'family'); + const main = result.preparedDocs.find(d => d._id === 'main1'); + + const call = attachmentService.add.getCalls().find(c => c.args[2] === 'BASE64_NESTED'); + expect(call, 'nested binary attach should exist').to.not.be.undefined; + expect(main.details.photo).to.equal(call.args[1].replace('user-file-', '')); + }); + + it('skips an empty binary node (no attachment, field stays empty)', async () => { + const xml = + '' + + '' + + 'Kigali HF' + + ''; + const form = { getDataStr: () => xml }; + + enketoTranslationService.contactRecordToJs.returns({ + doc: { _id: 'main1', type: 'family', name: 'Kigali HF', photo: '' }, + }); + sinon.stub(FileManager, 'getCurrentFiles').returns([]); + + const result = await service.save(form, null, 'family'); + const main = result.preparedDocs.find(d => d._id === 'main1'); + + expect(attachmentService.add.called, 'no attachment for an empty binary node').to.be.false; + expect(main.photo).to.equal(''); + }); + + it('sanitizes field values per-doc', async () => { + const xml = + '' + + '' + + 'Kigali HF' + + '' + + 'Amina' + + 'my photo.png' + + '' + + ''; + const form = { getDataStr: () => xml }; + + enketoTranslationService.contactRecordToJs.returns({ + doc: { _id: 'main1', type: 'family', name: 'Kigali HF', contact: 'NEW' }, + siblings: { + contact: { _id: 'sib1', type: 'person', name: 'Amina', photo: 'my photo.png', parent: 'PARENT' } + }, + repeats: {}, + }); + stubSiblingAndRepeat(); + + const aminaFile = new File(['x'], 'my photo.png', { type: 'image/png' }); + sinon.stub(FileManager, 'getCurrentFiles').returns([ aminaFile ]); + + const result = await service.save(form, null, 'family'); + + const sibling = result.preparedDocs.find(d => d._id === 'sib1'); + const main = result.preparedDocs.find(d => d._id === 'main1'); + expect(sibling.photo, 'sibling photo field should be sanitized').to.equal('myphoto.png'); + expect(main.name, 'main field should be untouched').to.equal('Kigali HF'); + }); + + it('main contact re-attaches a binary under the same name (idempotent re-save)', async () => { + // On edit the form's instance default re-supplies fresh base64, and the + // save recomputes the same xpath-derived name — overwrite-in-place. + const xml = + '' + + '' + + '' + + 'Kigali HF' + + 'FRESH_BASE64' + + '' + + ''; + const form = { getDataStr: () => xml }; + + enketoTranslationService.contactRecordToJs.returns({ + doc: { _id: 'main1', type: 'family', name: 'Kigali HF', photo: 'FRESH_BASE64' }, + }); + + // Original returned by the datasource already carries the unified-named attachment. + getContact.withArgs(Qualifier.byUuid('main1')).resolves({ + _id: 'main1', + type: 'family', + name: 'Kigali HF', + _attachments: { + 'user-file-contact:family:create/family/photo': { content_type: 'image/png' }, + }, + }); + + sinon.stub(FileManager, 'getCurrentFiles').returns([]); + + const result = await service.save(form, 'main1', 'family'); + const main = result.preparedDocs.find(d => d._id === 'main1'); + + // Same key the prior save produced — CouchDB overwrite-in-place. + const photoCall = attachmentService.add.getCalls() + .find(c => c.args[1] === 'user-file-contact:family:create/family/photo'); + expect(photoCall, 'attach call should exist').to.not.be.undefined; + expect(photoCall.args[0]._id).to.equal('main1'); + expect(photoCall.args[2]).to.equal('FRESH_BASE64'); + expect(main.photo).to.equal('contact:family:create/family/photo'); + // The pre-existing attachment is reused, not orphan-removed. + expect( + attachmentService.remove.calledWith( + sinon.match({ _id: 'main1' }), + 'user-file-contact:family:create/family/photo', + ), + 'pre-existing attachment should NOT be removed', + ).to.be.false; + }); + + it('leaves a legacy slash-named binary attachment intact on edit (migration out of scope)', async () => { + // Pre-existing legacy attachment under the old slash name + // (`user-file/
/`). The save writes the new `user-file-<...>` + // attachment + bare field value; the legacy one is not orphan-removed + // (cleanup only touches `user-file-`-prefixed names), so no data loss. + const xml = + '' + + '' + + '' + + 'Kigali HF' + + 'FRESH_BASE64' + + '' + + ''; + const form = { getDataStr: () => xml }; + + enketoTranslationService.contactRecordToJs.returns({ + doc: { _id: 'main1', type: 'family', name: 'Kigali HF', photo: 'FRESH_BASE64' }, + }); + + getContact.withArgs(Qualifier.byUuid('main1')).resolves({ + _id: 'main1', + type: 'family', + name: 'Kigali HF', + _attachments: { + 'user-file/contact:family:create/family/photo': { content_type: 'image/png' }, + }, + }); + + sinon.stub(FileManager, 'getCurrentFiles').returns([]); + + const result = await service.save(form, 'main1', 'family'); + const main = result.preparedDocs.find(d => d._id === 'main1'); + + // New unified attachment + bare field value. + expect( + attachmentService.add.calledWith( + sinon.match({ _id: 'main1' }), + 'user-file-contact:family:create/family/photo', + ), + 'unified-named attachment should be written', + ).to.be.true; + expect(main.photo).to.equal('contact:family:create/family/photo'); + // Legacy slash-named attachment is left untouched (no orphan removal). + expect( + attachmentService.remove.calledWith( + sinon.match({ _id: 'main1' }), + 'user-file/contact:family:create/family/photo', + ), + 'legacy slash-named attachment should NOT be removed', + ).to.be.false; + }); + + it('routes sibling sub-contact binary writes to the sub-doc, not main', async () => { + // The binary attachment lives on a sibling sub-contact. The xpath-derived + // name embeds the parent form id, so the sub-doc field holds the bare + // reference value — enough on its own for renderer lookup. + const xml = + '' + + '' + + 'Kigali HF' + + '' + + 'Amina' + + 'FRESH_BADGE_BASE64' + + '' + + ''; + const form = { getDataStr: () => xml }; + + enketoTranslationService.contactRecordToJs.returns({ + doc: { _id: 'main1', type: 'family', name: 'Kigali HF', contact: 'NEW' }, + siblings: { + contact: { + _id: 'sib1', + type: 'person', + name: 'Amina', + badge: 'FRESH_BADGE_BASE64', + parent: 'PARENT', + }, + }, + repeats: {}, + }); + stubSiblingAndRepeat(); + sinon.stub(FileManager, 'getCurrentFiles').returns([]); + + await service.save(form, null, 'family'); + + const badgeCall = attachmentService.add.getCalls() + .find(c => c.args[1] === 'user-file-contact:family:create/contact/badge'); + expect(badgeCall, 'sibling-routed attach should exist').to.not.be.undefined; + expect(badgeCall.args[0]._id, 'attachment lands on sub-doc, not main').to.equal('sib1'); + expect(badgeCall.args[2]).to.equal('FRESH_BADGE_BASE64'); + expect(badgeCall.args[0].badge).to.equal('contact:family:create/contact/badge'); + }); + + it('runs orphan cleanup on the edit path for the main doc', async () => { + // Edit path: main doc has a stale user-file attachment that is no longer + // referenced. The per-doc orphan loop must still remove it from main. + const xml = + '' + + '' + + 'Kigali HF' + + ''; + const form = { getDataStr: () => xml }; + + enketoTranslationService.contactRecordToJs.returns({ + doc: { _id: 'main1', type: 'family', name: 'Kigali HF' }, + }); + + // Original returned by the datasource has a stale user-file attachment. + getContact + .withArgs(Qualifier.byUuid('main1')) + .resolves({ + _id: 'main1', + type: 'family', + name: 'Kigali HF', + _attachments: { 'user-file-stale.png': { content_type: 'image/png' } } + }); + + sinon.stub(FileManager, 'getCurrentFiles').returns([]); + + await service.save(form, 'main1', 'family'); + + expect( + attachmentService.remove.calledWith( + sinon.match({ _id: 'main1' }), 'user-file-stale.png' + ), + 'stale main-doc attachment should be removed' + ).to.be.true; + }); + + it('routes uploads using the type="file" attribute', async () => { + // Enketo's setVal rewrites uploaded binary nodes to type="file" at + // runtime (enketo-core form-model.js setVal). + const xml = + '' + + '' + + '' + + 'Kigali HF' + + 'clinic' + + '' + + 'Amina' + + 'amina-14_39_7.png' + + '' + + '' + + '' + + 'Child A' + + 'childA-14_39_8.png' + + '' + + '' + + ''; + const form = { getDataStr: () => xml }; + + enketoTranslationService.contactRecordToJs.returns({ + doc: { + _id: 'main1', type: 'clinic', name: 'Kigali HF', + contact: 'NEW', + }, + siblings: { contact: { _id: 'sib1', type: 'person', name: 'Amina', parent: 'PARENT' } }, + repeats: { child_data: [ + { _id: 'kid1', type: 'person', name: 'Child A', parent: 'PARENT' }, + ] }, + }); + stubSiblingAndRepeat(); + + const aminaFile = new File(['a'], 'amina-14_39_7.png', { type: 'image/png' }); + const childFile = new File(['c'], 'childA-14_39_8.png', { type: 'image/png' }); + sinon.stub(FileManager, 'getCurrentFiles').returns([ aminaFile, childFile ]); + + await service.save(form, null, 'clinic'); + + const calls = attachmentService.add.getCalls(); + const ownerOf = (name: string) => calls.find(c => c.args[1] === name)?.args[0]._id; + + expect(ownerOf('user-file-amina-14_39_7.png'), 'sibling upload -> sibling doc').to.equal('sib1'); + expect(ownerOf('user-file-childA-14_39_8.png'), 'repeat upload -> repeat doc').to.equal('kid1'); + expect( + calls.some(c => c.args[0]._id === 'main1' && c.args[1].startsWith('user-file-')), + 'main doc should not receive any sub-contact uploads' + ).to.be.false; }); }); }); diff --git a/webapp/tests/karma/ts/services/enketo-translation.service.spec.ts b/webapp/tests/karma/ts/services/enketo-translation.service.spec.ts index 078853c3d2a..fc889b6a0b7 100644 --- a/webapp/tests/karma/ts/services/enketo-translation.service.spec.ts +++ b/webapp/tests/karma/ts/services/enketo-translation.service.spec.ts @@ -337,6 +337,23 @@ describe('EnketoTranslation service', () => { }); }); + it('preserves text content of [type=binary] nodes', () => { + const xml = + ` + form/photo + SOME_BASE64 + + Alice + `; + + const js = service.reportRecordToJs(xml); + + assert.equal(js.photo, 'form/photo'); + assert.equal(js.signature, 'SOME_BASE64'); + assert.equal(js.empty, ''); + assert.equal(js.name, 'Alice'); + }); + it('converts repeated fields to arrays - #3430', () => { // given const record = ` @@ -810,6 +827,119 @@ describe('EnketoTranslation service', () => { ); }); + describe('[type=binary] handling', () => { + const buildModel = (defaultText = '') => $($.parseXML( + ` + ${defaultText} + + ` + )).children().first(); + + it('preserves the form default when saved value is empty string', () => { + const element = buildModel('DEFAULT_BASE64'); + service.bindJsonToXml(element, { photo: '', name: 'Alice' }); + assert.equal(element.find('photo').text(), 'DEFAULT_BASE64'); + assert.equal(element.find('name').text(), 'Alice'); + }); + + it('preserves the form default when saved value is null', () => { + const element = buildModel('DEFAULT_BASE64'); + service.bindJsonToXml(element, { photo: null, name: 'Alice' }); + assert.equal(element.find('photo').text(), 'DEFAULT_BASE64'); + }); + + it('preserves the form default when saved value is undefined', () => { + const element = buildModel('DEFAULT_BASE64'); + service.bindJsonToXml(element, { photo: undefined, name: 'Alice' }); + assert.equal(element.find('photo').text(), 'DEFAULT_BASE64'); + }); + + it('preserves the form default when saved value is a reference name', () => { + const element = buildModel('DEFAULT_BASE64'); + service.bindJsonToXml(element, { + photo: 'form/photo', + name: 'Alice', + }); + assert.equal(element.find('photo').text(), 'DEFAULT_BASE64'); + }); + + it('preserves the form default when saved value is a contact-form reference name', () => { + const element = buildModel('DEFAULT_BASE64'); + service.bindJsonToXml(element, { + photo: 'contact:person:create/person/photo', + name: 'Alice', + }); + assert.equal(element.find('photo').text(), 'DEFAULT_BASE64'); + }); + + it('binds through genuine inline base64 (covers injection on create)', () => { + const element = buildModel('DEFAULT_BASE64'); + service.bindJsonToXml(element, { + photo: 'INJECTED_BASE64_DATA', + name: 'Alice', + }); + assert.equal(element.find('photo').text(), 'INJECTED_BASE64_DATA'); + }); + + it('binds through base64 even when it contains slashes (not a reference)', () => { + const element = buildModel('DEFAULT_BASE64'); + // real base64 carries `+`/`=` chars, so it is not a path of node names + service.bindJsonToXml(element, { + photo: 'data:image/png;base64,iVBOR/w0K+Gg==', + name: 'Alice', + }); + assert.equal( + element.find('photo').text(), + 'data:image/png;base64,iVBOR/w0K+Gg==', + 'base64 content should not be mistaken for a reference name' + ); + }); + + it('still clears empty values on non-binary leaves (no regression)', () => { + const element = buildModel('DEFAULT_BASE64'); + service.bindJsonToXml(element, { photo: '', name: '' }); + assert.equal(element.find('name').text(), ''); + }); + + it('binds through saved filenames on [type=file] nodes (file-widget unaffected)', () => { + const element = $($.parseXML( + ` + + ` + )).children().first(); + service.bindJsonToXml(element, { upload: 'photo.jpg-1700000000000' }); + assert.equal(element.find('upload').text(), 'photo.jpg-1700000000000'); + }); + }); + + describe('isAttachmentRef', () => { + [ + [ 'form/photo', true ], + [ 'embedded_multimedia/notes_with_media/base64_note', true ], + [ 'contact:person:create/person/photo', true ], + [ 'thing_1/doc1/photo1', true ], + ].forEach(([ value, expected ]) => { + it(`recognizes reference name "${value}"`, () => { + assert.equal(service.isAttachmentRef(value), expected); + }); + }); + + [ + [ 'photo', false ], // single segment, no slash + [ '', false ], // empty + [ 'INJECTED_BASE64_DATA', false ], // single token + [ 'data:image/png;base64,iVBOR/w0K+Gg==', false ], // base64 (`+`/`=`/`;`/`,`) + [ 'aGVsbG8/d29ybGQ=', false ], // base64 with slash but `=` and one segment-ish + [ null, false ], + [ undefined, false ], + [ 42, false ], + ].forEach(([ value, expected ]) => { + it(`rejects non-reference value ${JSON.stringify(value)}`, () => { + assert.equal(service.isAttachmentRef(value), expected); + }); + }); + }); + it('should remove template-like attributes', () => { const model = ` diff --git a/webapp/tests/karma/ts/services/enketo-xml/db-doc-in-repeat-with-files.xml b/webapp/tests/karma/ts/services/enketo-xml/db-doc-in-repeat-with-files.xml new file mode 100644 index 00000000000..f06a7bf0aca --- /dev/null +++ b/webapp/tests/karma/ts/services/enketo-xml/db-doc-in-repeat-with-files.xml @@ -0,0 +1,15 @@ + + Sally + + + repeater + repeat_upload_1.png + + + + + repeater + repeat_upload_2.png + + + diff --git a/webapp/tests/karma/ts/services/enketo-xml/db-doc-orphan-file.xml b/webapp/tests/karma/ts/services/enketo-xml/db-doc-orphan-file.xml new file mode 100644 index 00000000000..db0d33d2be0 --- /dev/null +++ b/webapp/tests/karma/ts/services/enketo-xml/db-doc-orphan-file.xml @@ -0,0 +1,7 @@ + + Sally + + thing_1 + known_upload.png + + diff --git a/webapp/tests/karma/ts/services/enketo-xml/db-doc-with-binary.xml b/webapp/tests/karma/ts/services/enketo-xml/db-doc-with-binary.xml new file mode 100644 index 00000000000..277204d6262 --- /dev/null +++ b/webapp/tests/karma/ts/services/enketo-xml/db-doc-with-binary.xml @@ -0,0 +1,16 @@ + + Sally + main_photo_data + + data_record + thing_1 + some_value_1 + sub_photo_data_1 + + + data_record +
thing_2
+ some_value_2 + sub_photo_data_2 +
+
diff --git a/webapp/tests/karma/ts/services/enketo-xml/db-doc-with-file-field-cleared.xml b/webapp/tests/karma/ts/services/enketo-xml/db-doc-with-file-field-cleared.xml new file mode 100644 index 00000000000..3df2c585886 --- /dev/null +++ b/webapp/tests/karma/ts/services/enketo-xml/db-doc-with-file-field-cleared.xml @@ -0,0 +1,8 @@ + + Sally + main_upload.png + + thing_1 + + + diff --git a/webapp/tests/karma/ts/services/enketo-xml/db-doc-with-file-field.xml b/webapp/tests/karma/ts/services/enketo-xml/db-doc-with-file-field.xml new file mode 100644 index 00000000000..d1e71b0efda --- /dev/null +++ b/webapp/tests/karma/ts/services/enketo-xml/db-doc-with-file-field.xml @@ -0,0 +1,8 @@ + + Sally + main_upload.png + + thing_1 + sub_upload.png + + diff --git a/webapp/tests/karma/ts/services/enketo.service.spec.ts b/webapp/tests/karma/ts/services/enketo.service.spec.ts index e2b7dcf832a..dcdb9e2f30c 100644 --- a/webapp/tests/karma/ts/services/enketo.service.spec.ts +++ b/webapp/tests/karma/ts/services/enketo.service.spec.ts @@ -1138,7 +1138,7 @@ describe('Enketo service', () => { expect(AddAttachment.args[1][3]).to.equal(file1.type); }); - it('should remove binary data from content', async () => { + it('should rewrite [type=binary] field value to attachment name on save', async () => { form.validate.resolves(true); const content = loadXML('binary-field'); @@ -1151,18 +1151,280 @@ describe('Enketo service', () => { { doc: { } }, { _id: 'my-user', phone: '8989' } ); + // The binary loop attaches the base64 under `user-file-// + // ` and rewrites the field to the bare reference, so doc.fields + // resolves via the same `user-file-` + value rule as file-widget uploads. expect(actual.fields).to.deep.equal({ name: 'Mary', age: '10', gender: 'f', - my_file: '', + my_file: 'my-form/my_file', }); expect(AddAttachment.callCount).to.equal(1); - expect(AddAttachment.args[0][1]).to.equal('user-file/my-form/my_file'); + expect(AddAttachment.args[0][1]).to.equal('user-file-my-form/my_file'); expect(AddAttachment.args[0][2]).to.deep.equal('some image data'); expect(AddAttachment.args[0][3]).to.equal('image/png'); }); + + it('skips an empty [type=binary] node (no attachment)', async () => { + form.validate.resolves(true); + form.getDataStr.returns( + 'Mary' + ); + dbGetAttachment.resolves('
'); + + const [actual] = await service.completeNewReport( + 'my-form', + form, + { doc: { } }, + { _id: 'my-user', phone: '8989' } + ); + + expect(AddAttachment.callCount).to.equal(0); + expect(actual.fields.my_file).to.equal(''); + }); + + it('reports with an empty saved binary field re-attach under the same xpath-derived name', async () => { + // Pre-existing report has `my_file: ""` plus an attachment under + // `user-file-/`. On edit the form default re-supplies fresh + // base64, and the save writes under the same xpath-derived key + // (overwrite-in-place, no orphan) and rewrites the field to the reference. + form.validate.resolves(true); + const content = loadXML('binary-field'); + form.getDataStr.returns(content); + dbGetAttachment.resolves(''); + + const [actual] = await service.completeNewReport( + 'my-form', + form, + { doc: { } }, + { _id: 'my-user', phone: '8989' } + ); + + expect(AddAttachment.callCount).to.equal(1); + expect(AddAttachment.args[0][1]).to.equal('user-file-my-form/my_file'); + expect(actual.fields.my_file).to.equal('my-form/my_file'); + }); + + it('should route binary attachments to correct sub-docs', async () => { + form.validate.resolves(true); + const content = loadXML('db-doc-with-binary'); + form.getDataStr.returns(content); + dbGetAttachment.resolves(''); + + const actual = await service.completeNewReport( + 'my-form', + form, + { doc: {} }, + { _id: 'my-user', phone: '8989' } + ); + + // actual[0] = main doc, actual[1] = doc1, actual[2] = doc2 + expect(actual.length).to.equal(3); + + // 3 binary fields = 3 AddAttachment calls + expect(AddAttachment.callCount).to.equal(3); + + // main_photo should be attached to main doc (actual[0]) + const mainPhotoCall = AddAttachment.args.find(args => args[2] === 'main_photo_data'); + expect(mainPhotoCall).to.exist; + expect(mainPhotoCall[0]._id).to.equal(actual[0]._id); + + // sub_photo_data_1 should be attached to doc1 (actual[1]) + const subPhoto1Call = AddAttachment.args.find(args => args[2] === 'sub_photo_data_1'); + expect(subPhoto1Call).to.exist; + expect(subPhoto1Call[0]._id).to.equal(actual[1]._id); + + // sub_photo_data_2 should be attached to doc2 (actual[2]) + const subPhoto2Call = AddAttachment.args.find(args => args[2] === 'sub_photo_data_2'); + expect(subPhoto2Call).to.exist; + expect(subPhoto2Call[0]._id).to.equal(actual[2]._id); + + // Each sub-doc's binary field holds its bare reference value + // (`//`), resolved via `user-file-` + value. + expect(actual[0].fields.main_photo).to.equal('my-form/main_photo'); + expect(actual[1].photo1).to.equal('thing_1/doc1/photo1'); + expect(actual[2].photo2).to.equal('thing_2/doc2/photo2'); + }); + + it('should route FileManager files to correct sub-doc', async () => { + form.validate.resolves(true); + const content = loadXML('db-doc-with-file-field'); + form.getDataStr.returns(content); + dbGetAttachment.resolves(''); + + const mainFile = { name: 'main_upload.png', type: 'image/png' }; + const subFile = { name: 'sub_upload.png', type: 'image/png' }; + getCurrentFiles.returns([mainFile, subFile]); + + const actual = await service.completeNewReport( + 'my-form', + form, + { doc: {} }, + { _id: 'my-user', phone: '8989' } + ); + + expect(actual.length).to.equal(2); + + // FileManager file calls: main_upload.png and sub_upload.png + const mainFileCall = AddAttachment.args.find( + args => args[1] === 'user-file-main_upload.png' + ); + expect(mainFileCall).to.exist; + expect(mainFileCall[0]._id).to.equal(actual[0]._id); + + const subFileCall = AddAttachment.args.find( + args => args[1] === 'user-file-sub_upload.png' + ); + expect(subFileCall).to.exist; + expect(subFileCall[0]._id).to.equal(actual[1]._id); + }); + + it('should fall back to main doc when file is not inside a db-doc', async () => { + form.validate.resolves(true); + const content = loadXML('binary-field'); + form.getDataStr.returns(content); + dbGetAttachment.resolves(''); + + const actual = await service.completeNewReport( + 'my-form', + form, + { doc: {} }, + { _id: 'my-user', phone: '8989' } + ); + + expect(actual.length).to.equal(1); + expect(AddAttachment.callCount).to.equal(1); + expect(AddAttachment.args[0][0]._id).to.equal(actual[0]._id); + }); + + it('should fall back to main doc when FileManager file has no matching XML node', async () => { + form.validate.resolves(true); + const content = loadXML('db-doc-orphan-file'); + form.getDataStr.returns(content); + dbGetAttachment.resolves(''); + + const orphanFile = { name: 'no_such_node.png', type: 'image/png' }; + getCurrentFiles.returns([orphanFile]); + + const actual = await service.completeNewReport( + 'my-form', + form, + { doc: {} }, + { _id: 'my-user', phone: '8989' } + ); + + // actual[0] = main doc, actual[1] = doc1 + expect(actual.length).to.equal(2); + + const orphanCall = AddAttachment.args.find( + args => args[1] === 'user-file-no_such_node.png' + ); + expect(orphanCall).to.exist; + expect(orphanCall[0]._id).to.equal(actual[0]._id); + }); + + it('should route files inside db-doc nested in repeats to corresponding sub-docs', async () => { + form.validate.resolves(true); + const content = loadXML('db-doc-in-repeat-with-files'); + form.getDataStr.returns(content); + dbGetAttachment.resolves(''); + + const file1 = { name: 'repeat_upload_1.png', type: 'image/png' }; + const file2 = { name: 'repeat_upload_2.png', type: 'image/png' }; + getCurrentFiles.returns([file1, file2]); + + const actual = await service.completeNewReport( + 'my-form', + form, + { doc: {} }, + { _id: 'my-user', phone: '8989' } + ); + + // actual[0] = main doc, actual[1] = first repeat doc, actual[2] = second repeat doc + expect(actual.length).to.equal(3); + + const file1Call = AddAttachment.args.find( + args => args[1] === 'user-file-repeat_upload_1.png' + ); + expect(file1Call).to.exist; + expect(file1Call[0]._id).to.equal(actual[1]._id); + + const file2Call = AddAttachment.args.find( + args => args[1] === 'user-file-repeat_upload_2.png' + ); + expect(file2Call).to.exist; + expect(file2Call[0]._id).to.equal(actual[2]._id); + + // No file should land on the main doc. + const mainDocFileCalls = AddAttachment.args.filter( + args => args[0]._id === actual[0]._id && args[1].startsWith('user-file-') + ); + expect(mainDocFileCalls).to.be.empty; + }); + + it('should root sub-doc binary attachment names at the owner doc form id', async () => { + form.validate.resolves(true); + const content = loadXML('db-doc-with-binary'); + form.getDataStr.returns(content); + dbGetAttachment.resolves(''); + + await service.completeNewReport( + 'my-form', + form, + { doc: {} }, + { _id: 'my-user', phone: '8989' } + ); + + // The attachment name is rooted at the owner doc's own form id (sub-docs + // carry their own `form`); the main doc falls back to the report form id. + const subPhoto1Call = AddAttachment.args.find(args => args[2] === 'sub_photo_data_1'); + expect(subPhoto1Call).to.exist; + expect(subPhoto1Call[1]).to.equal('user-file-thing_1/doc1/photo1'); + + const subPhoto2Call = AddAttachment.args.find(args => args[2] === 'sub_photo_data_2'); + expect(subPhoto2Call).to.exist; + expect(subPhoto2Call[1]).to.equal('user-file-thing_2/doc2/photo2'); + + const mainPhotoCall = AddAttachment.args.find(args => args[2] === 'main_photo_data'); + expect(mainPhotoCall).to.exist; + expect(mainPhotoCall[1]).to.equal('user-file-my-form/main_photo'); + }); + + it('should not attach removed files to sub-docs during edit', async () => { + form.validate.resolves(true); + const content = loadXML('db-doc-with-file-field-cleared'); + form.getDataStr.returns(content); + dbGetAttachment.resolves(''); + + // Only the main file remains; sub_file was cleared during edit + const mainFile = { name: 'main_upload.png', type: 'image/png' }; + getCurrentFiles.returns([mainFile]); + + const actual = await service.completeNewReport( + 'my-form', + form, + { doc: {} }, + { _id: 'my-user', phone: '8989' } + ); + + // actual[0] = main doc, actual[1] = doc1 sub-doc + expect(actual.length).to.equal(2); + + // Only the main file should be attached + const mainFileCall = AddAttachment.args.find( + args => args[1] === 'user-file-main_upload.png' + ); + expect(mainFileCall).to.exist; + expect(mainFileCall[0]._id).to.equal(actual[0]._id); + + // No attachment should land on the sub-doc + const subDocAttachments = AddAttachment.args.filter( + args => args[0]._id === actual[1]._id + ); + expect(subDocAttachments).to.be.empty; + }); }); describe('multimedia', () => { diff --git a/webapp/tests/karma/ts/services/form.service.spec.ts b/webapp/tests/karma/ts/services/form.service.spec.ts index 6ee36ff43dd..6231219c0e1 100644 --- a/webapp/tests/karma/ts/services/form.service.spec.ts +++ b/webapp/tests/karma/ts/services/form.service.spec.ts @@ -1765,6 +1765,84 @@ describe('Form service', () => { expect(performanceService.track.notCalled).to.be.true; expect(performanceTracking.stop.notCalled).to.be.true; }); + + it('should reject and abort save when an attachment exceeds max size', async () => { + const form = { getDataStr: () => '' }; + const docId = null; + const type = 'some-contact-type'; + + translateService.get.returnsArg(0); + enketoTranslationService.contactRecordToJs.returns({ + doc: { _id: 'main1', type: 'main', name: 'Main' } + }); + // applyTransitions returns the docs unchanged but with a bloated + // attachment so validateAttachments rejects. + transitionsService.applyTransitions.callsFake((docs) => { + docs[0]._attachments = { + 'user-file-huge.png': { data: { size: 100 * 1024 * 1024 } }, + }; + return Promise.resolve(docs); + }); + + await expect( + service.saveContact({ docId, type }, { form, xmlVersion: undefined, duplicateCheck: undefined }, true) + ).to.be.rejectedWith(/enketo\.error\.max_attachment_size/); + + assert.equal(dbBulkDocs.callCount, 0, 'bulkDocs should not be called when validation fails'); + assert.equal(setLastChangedDoc.callCount, 0, 'setLastChangedDoc should not be called'); + expect(globalActions.setSnackbarContent.calledWith('enketo.error.max_attachment_size')).to.be.true; + }); + + it('should reject when a sub-doc has oversize attachments', async () => { + // The validation must apply to every prepared doc, not just the main one. + const form = { getDataStr: () => '' }; + const docId = null; + const type = 'family'; + + translateService.get.returnsArg(0); + enketoTranslationService.contactRecordToJs.returns({ + doc: { _id: 'main1', type: 'family', name: 'Kigali HF', contact: 'NEW' }, + siblings: { contact: { _id: 'sib1', type: 'person', name: 'Amina', parent: 'PARENT' } }, + repeats: {}, + }); + extractLineageService.extract.callsFake(c => c); + transitionsService.applyTransitions.callsFake((docs) => { + // Bloat the sibling's attachment, not the main's. + const sibling = docs.find(d => d._id === 'sib1'); + sibling._attachments = { + 'user-file-amina.png': { data: { size: 100 * 1024 * 1024 } }, + }; + return Promise.resolve(docs); + }); + + await expect( + service.saveContact({ docId, type }, { form, xmlVersion: undefined, duplicateCheck: undefined }, true) + ).to.be.rejectedWith(/enketo\.error\.max_attachment_size/); + + assert.equal(dbBulkDocs.callCount, 0, 'bulkDocs should not be called when sub-doc attachment is oversize'); + }); + + it('should pass validation and save when attachments are within size limit', async () => { + const form = { getDataStr: () => '' }; + const docId = null; + const type = 'some-contact-type'; + + enketoTranslationService.contactRecordToJs.returns({ + doc: { _id: 'main1', type: 'main', name: 'Main' } + }); + transitionsService.applyTransitions.callsFake((docs) => { + docs[0]._attachments = { + 'user-file-tiny.png': { data: { size: 1024 } }, + }; + return Promise.resolve(docs); + }); + dbBulkDocs.resolves([]); + + await service.saveContact({ docId, type }, { form, xmlVersion: undefined, duplicateCheck: undefined }, true); + + assert.equal(dbBulkDocs.callCount, 1); + expect(globalActions.setSnackbarContent.notCalled).to.be.true; + }); }); describe('load contact summary', () => { diff --git a/webapp/tests/karma/ts/services/format-data-record.service.spec.ts b/webapp/tests/karma/ts/services/format-data-record.service.spec.ts index e98501a2fb0..92aa0723c67 100644 --- a/webapp/tests/karma/ts/services/format-data-record.service.spec.ts +++ b/webapp/tests/karma/ts/services/format-data-record.service.spec.ts @@ -243,6 +243,51 @@ describe('FormatDataRecord service', () => { ]); }); + it('resolves an inline-binary field via user-file- + bare reference value', async () => { + // Inline-binary fields store the bare reference (`//`) + // as the value, attached under `user-file-`, so the same + // `user-file-` + value rule resolves it. + const report = { + _id: 'my-report', + form: 'my-form', + content_type: 'xml', + fields: { + photo: 'my-form/photo', + }, + _attachments: { + 'user-file-my-form/photo': { content_type: 'image/png' }, + }, + }; + + const result = await service.format(report); + expect(result.fields).to.deep.equal([ + { + label: 'report.my-form.photo', + value: 'my-form/photo', + depth: 0, + imagePath: 'user-file-my-form/photo', + target: undefined, + }, + ]); + }); + + it('returns no image path when neither the value nor the legacy fallback resolves', async () => { + // A value must resolve to an existing image attachment, via either + // `user-file-` + value or the legacy `user-file/`. + const report = { + _id: 'my-report', + form: 'my-form', + content_type: 'xml', + fields: { + photo: 'my-form/photo', + }, + _attachments: {}, + }; + + const result = await service.format(report); + expect((result.fields as any[])[0].imagePath).to.equal(undefined); + }); + it('returns empty image path if attachment does not exist for image name', async () => { const report = { _id: 'my-report',