From 0e307f9672305e7d620e6187026185a9e7fca1a4 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Thu, 5 Feb 2026 10:17:09 +0100 Subject: [PATCH 01/17] New test for discard deleting content --- tests/integration/attachments.test.js | 53 ++++++++++++++++++++------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/tests/integration/attachments.test.js b/tests/integration/attachments.test.js index b120ef66..c25a4f53 100644 --- a/tests/integration/attachments.test.js +++ b/tests/integration/attachments.test.js @@ -187,7 +187,7 @@ describe("Tests for uploading/deleting attachments through API calls", () => { expect(contentResponse.data).toBeTruthy() // Wait for 45 seconds to let the scan status expire - await delay(45 * 1000); + await delay(45 * 1000) await GET( `odata/v4/processor/Incidents(ID=${incidentID},IsActiveEntity=true)/attachments(up__ID=${incidentID},ID=${sampleDocID},IsActiveEntity=true)/content` @@ -195,7 +195,7 @@ describe("Tests for uploading/deleting attachments through API calls", () => { expect(e.status).toEqual(202) expect(e.response.data.error.message).toContain('The last scan is older than 3 days. Please wait while the attachment is being re-scanned.') }) - }); + }) it("Scan status is translated", async () => { const incidentID = await newIncident(POST, 'processor') @@ -327,9 +327,36 @@ describe("Tests for uploading/deleting attachments through API calls", () => { }) }) + it("Discarding a saved draft should not delete attachment content", async () => { + const incidentID = await newIncident(POST, 'processor') + const scanCleanWaiter = waitForScanStatus('Clean') + + const sampleDocID = await uploadDraftAttachment(utils, POST, GET, incidentID) + expect(sampleDocID).toBeTruthy() + await scanCleanWaiter + + const contentResponse1 = await GET( + `odata/v4/processor/Incidents(ID=${incidentID},IsActiveEntity=true)/attachments(up__ID=${incidentID},ID=${sampleDocID},IsActiveEntity=true)/content` + ) + expect(contentResponse1.status).toEqual(200) + + await utils.draftModeEdit("processor", "Incidents", incidentID, "ProcessorService") + + const discardResponse = await DELETE( + `odata/v4/processor/Incidents(ID=${incidentID},IsActiveEntity=false)` + ) + expect(discardResponse.status).toEqual(204) + + // Verify the attachment content STILL exists + const contentResponse2 = await GET( + `odata/v4/processor/Incidents(ID=${incidentID},IsActiveEntity=true)/attachments(up__ID=${incidentID},ID=${sampleDocID},IsActiveEntity=true)/content` + ) + expect(contentResponse2.status).toEqual(200) + }) + it("Cancel draft where parent has composed key", async () => { - const gjahr = Math.round(Math.random() * 1000); - const sampleID = `ABC ${Math.round(Math.random() * 1000)}`; + const gjahr = Math.round(Math.random() * 1000) + const sampleID = `ABC ${Math.round(Math.random() * 1000)}` await POST( `odata/v4/processor/SampleRootWithComposedEntity`, { sampleID: sampleID, @@ -360,8 +387,8 @@ describe("Tests for uploading/deleting attachments through API calls", () => { }) it("On handler for attachments can be overwritten", async () => { - const gjahr = Math.round(Math.random() * 1000); - const sampleID = `ABC ${Math.round(Math.random() * 1000)}`; + const gjahr = Math.round(Math.random() * 1000) + const sampleID = `ABC ${Math.round(Math.random() * 1000)}` await POST( `odata/v4/processor/SampleRootWithComposedEntity`, { sampleID, @@ -1125,7 +1152,7 @@ describe("Tests for uploading/deleting attachments through API calls", () => { isNotLocal("Should detect infected files and automatically delete them after scan", async () => { const incidentID = await newIncident(POST, 'processor') - const testMal = "WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5EQVJELUFOVElWSVJVUy1URVNULUZJTEUhJEgrSCo="; + const testMal = "WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5EQVJELUFOVElWSVJVUy1URVNULUZJTEUhJEgrSCo=" const fileContent = Buffer.from(testMal, 'base64').toString("utf8") const scanInfectedWaiter = waitForScanStatus("Infected") @@ -1439,7 +1466,7 @@ describe('Testing max and min amounts of attachments', () => { message: "ABC", attachments: [] } - ); + ) expect(minStatus).toEqual(201) const { response: resMin } = await utils.draftModeSave("validation-test", "Incidents", incidentID, "ValidationTestService") @@ -1492,7 +1519,7 @@ describe('Testing max and min amounts of attachments', () => { it('Deep update of draft gives warning when minimum is not met or maximum exceeded', async () => { const incidentID = await newIncident(POST, 'validation-test') - const conversationID = cds.utils.uuid(); + const conversationID = cds.utils.uuid() await POST( `odata/v4/validation-test/Incidents(ID=${incidentID},IsActiveEntity=false)/conversation`, { @@ -1696,7 +1723,7 @@ describe('Testing max and min amounts of attachments', () => { ) const { response } = await utils.draftModeSave("validation-test", "Incidents", incidentID, "ValidationTestService") expect(response.status).toEqual(400) - const err = response.data.error.details.find(e => e.target.startsWith('conversation')); + const err = response.data.error.details.find(e => e.target.startsWith('conversation')) expect(err.code).toEqual('MinimumAmountNotFulfilled|ValidationTestService.Incidents.conversation') }) @@ -1708,7 +1735,7 @@ describe('Testing max and min amounts of attachments', () => { }) const { response } = await utils.draftModeSave("validation-test", "Incidents", highIncID, "ValidationTestService") expect(response.status).toEqual(400) - const err = response.data.error.details.find(e => e.target.startsWith('hiddenAttachments2')); + const err = response.data.error.details.find(e => e.target.startsWith('hiddenAttachments2')) expect(err.code).toEqual('MinimumAmountNotFulfilled|ValidationTestService.Incidents|hiddenAttachments2') }) @@ -1757,10 +1784,10 @@ describe('Testing max and min amounts of attachments', () => { const { response: res1 } = await utils.draftModeSave("validation-test", "Incidents", highIncID, "ValidationTestService") expect(res1.status).toEqual(400) - const errMax1 = res1.data.error.details.find(e => e.target.startsWith('hiddenAttachments')); + const errMax1 = res1.data.error.details.find(e => e.target.startsWith('hiddenAttachments')) expect(errMax1.code).toEqual('MaximumAmountExceeded') - const errMin1 = res1.data.error.details.find(e => e.target.startsWith('hiddenAttachments2')); + const errMin1 = res1.data.error.details.find(e => e.target.startsWith('hiddenAttachments2')) expect(errMin1.code).toEqual('MinimumAmountNotFulfilled|ValidationTestService.Incidents|hiddenAttachments2') await PATCH(`odata/v4/validation-test/Incidents(ID=${highIncID},IsActiveEntity=false)`, { From 16ddf408e0fb80f142cdddf9a8769b1d160a5ba8 Mon Sep 17 00:00:00 2001 From: Eric P Date: Thu, 5 Feb 2026 10:22:10 +0100 Subject: [PATCH 02/17] Update tests/integration/attachments.test.js Co-authored-by: hyperspace-insights[bot] <209611008+hyperspace-insights[bot]@users.noreply.github.com> --- tests/integration/attachments.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/attachments.test.js b/tests/integration/attachments.test.js index c25a4f53..f990013d 100644 --- a/tests/integration/attachments.test.js +++ b/tests/integration/attachments.test.js @@ -339,6 +339,7 @@ describe("Tests for uploading/deleting attachments through API calls", () => { `odata/v4/processor/Incidents(ID=${incidentID},IsActiveEntity=true)/attachments(up__ID=${incidentID},ID=${sampleDocID},IsActiveEntity=true)/content` ) expect(contentResponse1.status).toEqual(200) + expect(contentResponse1.data).toBeTruthy() await utils.draftModeEdit("processor", "Incidents", incidentID, "ProcessorService") From dace316e589cc99452e9cefb3ff5136f3dfe8f6d Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Tue, 10 Feb 2026 17:20:45 +0100 Subject: [PATCH 03/17] req.diff() replaced --- srv/basic.js | 59 +++++++++++++++++++++++++++++++--------------------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/srv/basic.js b/srv/basic.js index 3594f88d..aac024a3 100644 --- a/srv/basic.js +++ b/srv/basic.js @@ -284,35 +284,46 @@ class AttachmentsService extends cds.Service { * @param {import('@sap/cds').Request} req - The request object */ async attachDeletionData(req) { + if (!req.target?.drafts) return const attachmentCompositions = req?.target?._attachments.attachmentCompositions if (attachmentCompositions.length > 0) { - const diffData = await req.diff() - if (!diffData || Object.keys(diffData).length === 0) { - return - } - const queries = [] - const queryTargets = [] + const whereCond = req.subject?.ref?.[0]?.where + if (!whereCond) return + + const columns = ['*', ...attachmentCompositions.map(comp => ({ ref: comp, expand: ['*'] }))] + + const [draft, active] = await Promise.all([ + SELECT.one.from(req.target.drafts).where(whereCond).columns(columns), + SELECT.one.from(req.target).where(whereCond).columns(columns) + ]) + + if (!active || !draft) return + + const attachmentsToDelete = [] + for (const attachmentsComp of attachmentCompositions) { - const leaf = this.traverseDataByPath(diffData, attachmentsComp) - const deletedAttachments = Array.isArray(leaf) ? leaf.filter(obj => obj._op === "delete").map(obj => obj.ID) : [] + const activeAttachments = this.traverseDataByPath(active, attachmentsComp) || [] + const draftAttachments = this.traverseDataByPath(draft, attachmentsComp) || [] + const draftAttachmentIDs = new Set(draftAttachments.map(a => a.ID)) + + // Find attachments present in the active entity but not in the draft + const deletedAttachments = activeAttachments.filter( + (att) => att.url && !draftAttachmentIDs.has(att.ID) + ) const entityTarget = traverseEntity(req.target, attachmentsComp) - if (deletedAttachments.length) { - queries.push( - SELECT.from(entityTarget).columns("url").where({ ID: { in: [...deletedAttachments] } }) + + if (deletedAttachments.length > 0) { + attachmentsToDelete.push( + ...deletedAttachments.map(attachment => ({ + url: attachment.url, + target: entityTarget.name + })) ) - queryTargets.push(entityTarget.name) } } - if (queries.length > 0) { - const attachmentsToDelete = (await Promise.all(queries)).reduce((acc, attachments, idx) => { - attachments.forEach(attachment => attachment.target = queryTargets[idx]) - acc = acc.concat(attachments) - return acc; - }, []) - if (attachmentsToDelete.length > 0) { - req.attachmentsToDelete = attachmentsToDelete - } + if (attachmentsToDelete.length > 0) { + req.attachmentsToDelete = attachmentsToDelete } } } @@ -345,13 +356,13 @@ class AttachmentsService extends cds.Service { if (!draftEntity || !activeEntity) return - const diff = await req.diff() - if (diff._op !== "delete" || !diff.ID) return + const attachmentId = req.data?.ID + if (!attachmentId) return const attachmentsToDelete = await this.getAttachmentsToDelete({ draftEntity, activeEntity, - whereXpr: { ID: diff.ID } + whereXpr: { ID: attachmentId } }) if (attachmentsToDelete.length) { From 24ed48c42e78d459b832dbe243a5b1d1aa1c046b Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Tue, 10 Feb 2026 17:25:25 +0100 Subject: [PATCH 04/17] Remove semicolons --- srv/basic.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/srv/basic.js b/srv/basic.js index aac024a3..8a7a2925 100644 --- a/srv/basic.js +++ b/srv/basic.js @@ -16,7 +16,7 @@ class AttachmentsService extends cds.Service { try { const url = attachment.url const activeEntity = cds.model.definitions[target] - const draftEntity = target ? cds.model.definitions?.[target+'.draft'] : undefined; + const draftEntity = target ? cds.model.definitions?.[target+'.draft'] : undefined await UPDATE(activeEntity).where({ url: url }).set({ content: null, url: null, hash: null }) if (draftEntity) { @@ -254,7 +254,7 @@ class AttachmentsService extends cds.Service { if (!req.subject) return - const attachments = await SELECT.from(req.subject).columns("url"); + const attachments = await SELECT.from(req.subject).columns("url") if (attachments.length) { req.attachmentsToDelete = attachments.map(a => ({ ...a, target: req.target.name })) } From 7172d56f0c6927e44bd8b0fdae31eb24b321f39c Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 11 Feb 2026 12:14:18 +0100 Subject: [PATCH 05/17] Better filter out attachment compositions --- srv/basic.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/srv/basic.js b/srv/basic.js index 8a7a2925..bd132981 100644 --- a/srv/basic.js +++ b/srv/basic.js @@ -285,7 +285,13 @@ class AttachmentsService extends cds.Service { */ async attachDeletionData(req) { if (!req.target?.drafts) return - const attachmentCompositions = req?.target?._attachments.attachmentCompositions + + const allCompositions = req.target.compositions + if (!allCompositions) return + const attachmentCompositions = Object.entries(allCompositions) + .filter(([, comp]) => comp.target.includes('sap.attachments.Attachments')) + .map(([name]) => name) + if (attachmentCompositions.length > 0) { const whereCond = req.subject?.ref?.[0]?.where if (!whereCond) return From 256418b0551161ed67b8dd8bbe52ca243ce622e5 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 11 Feb 2026 13:01:18 +0100 Subject: [PATCH 06/17] Refactor columns selection --- srv/basic.js | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/srv/basic.js b/srv/basic.js index bd132981..391bc151 100644 --- a/srv/basic.js +++ b/srv/basic.js @@ -285,18 +285,27 @@ class AttachmentsService extends cds.Service { */ async attachDeletionData(req) { if (!req.target?.drafts) return - - const allCompositions = req.target.compositions - if (!allCompositions) return - const attachmentCompositions = Object.entries(allCompositions) - .filter(([, comp]) => comp.target.includes('sap.attachments.Attachments')) - .map(([name]) => name) - + const attachmentCompositions = req?.target?._attachments.attachmentCompositions if (attachmentCompositions.length > 0) { const whereCond = req.subject?.ref?.[0]?.where if (!whereCond) return - const columns = ['*', ...attachmentCompositions.map(comp => ({ ref: comp, expand: ['*'] }))] + const columns = ['*']; + for (const path of attachmentCompositions) { + let current = columns + for (let i = 0; i < path.length; i++) { + const segment = path[i] + let next = current.find(c => c.ref?.length === 1 && c.ref[0] === segment) + if (!next) { + next = { ref: [segment], expand: [] } + current.push(next) + } + current = next.expand + if (i === path.length - 1) { + current.push('*') + } + } + } const [draft, active] = await Promise.all([ SELECT.one.from(req.target.drafts).where(whereCond).columns(columns), From d3aa0c05299d556c410c7828f22997299333f766 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 11 Feb 2026 13:56:34 +0100 Subject: [PATCH 07/17] Syntax adjustment --- srv/basic.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/srv/basic.js b/srv/basic.js index 391bc151..3050f7f3 100644 --- a/srv/basic.js +++ b/srv/basic.js @@ -312,7 +312,7 @@ class AttachmentsService extends cds.Service { SELECT.one.from(req.target).where(whereCond).columns(columns) ]) - if (!active || !draft) return + if (!active) return const attachmentsToDelete = [] @@ -328,7 +328,7 @@ class AttachmentsService extends cds.Service { const entityTarget = traverseEntity(req.target, attachmentsComp) - if (deletedAttachments.length > 0) { + if (deletedAttachments.length) { attachmentsToDelete.push( ...deletedAttachments.map(attachment => ({ url: attachment.url, From ddb26cfe51ebcb34e35a94eda99e29cea45567bf Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Thu, 12 Feb 2026 09:09:26 +0100 Subject: [PATCH 08/17] Remove initial * --- srv/basic.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/srv/basic.js b/srv/basic.js index 3050f7f3..d1221f4f 100644 --- a/srv/basic.js +++ b/srv/basic.js @@ -290,7 +290,7 @@ class AttachmentsService extends cds.Service { const whereCond = req.subject?.ref?.[0]?.where if (!whereCond) return - const columns = ['*']; + const columns = [] for (const path of attachmentCompositions) { let current = columns for (let i = 0; i < path.length; i++) { From 3925a8210e26a409f8aa1b17b29dce198b1e15c3 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 18 Feb 2026 14:13:54 +0100 Subject: [PATCH 09/17] Initial commit for single attachment --- db/index.cds | 11 +++ .../app/incidents/annotations.cds | 26 +++++++ tests/incidents-app/db/attachments.cds | 6 +- tests/incidents-app/db/schema.cds | 5 ++ tests/incidents-app/srv/services.cds | 3 + tests/integration/attachments.test.js | 71 +++++++++++++++++++ 6 files changed, 121 insertions(+), 1 deletion(-) diff --git a/db/index.cds b/db/index.cds index 59448cc1..9fed0512 100644 --- a/db/index.cds +++ b/db/index.cds @@ -1,6 +1,7 @@ // The common root-level aspect used in applications like that: // using { Attachments } from '@cap-js/attachments' aspect Attachments : sap.attachments.Attachments {} +type Attachment : sap.attachments.SingleMediaData; using { managed, @@ -10,6 +11,16 @@ using { context sap.attachments { + type SingleMediaData @(_is_media_data) { + url : String @UI.Hidden; + content : LargeBinary @title: '{i18n>Attachment}'; // only for db-based services + mimeType : String default 'application/octet-stream' @title: '{i18n>MediaType}'; + filename : String @title: '{i18n>FileName}'; + hash : String @UI.Hidden @Core.Computed; + status : Association to one ScanStates default 'Unscanned' @title: '{i18n>ScanStatus}' @Common.Text: statusNav.name @Common.TextArrangement: #TextOnly; + lastScan : Timestamp @title: '{i18n>LastScan}' @Core.Computed; + } + aspect MediaData @(_is_media_data) { url : String @UI.Hidden; content : LargeBinary @title: '{i18n>Attachment}'; // only for db-based services diff --git a/tests/incidents-app/app/incidents/annotations.cds b/tests/incidents-app/app/incidents/annotations.cds index 4c856500..429a7834 100644 --- a/tests/incidents-app/app/incidents/annotations.cds +++ b/tests/incidents-app/app/incidents/annotations.cds @@ -244,4 +244,30 @@ annotate service.TestDetails with @( Target : 'attachments/@UI.LineItem' } ] +); + +annotate service.SingleAttachment with @( + UI.LineItem : [ + { + Value : name, + Label : 'Name', + }, + ], + UI.Facets : [ + { + $Type : 'UI.ReferenceFacet', + Label : 'General Information', + Target : '@UI.FieldGroup#General', + }, + { + $Type : 'UI.ReferenceFacet', + Label : 'Attachment', + Target : 'myAttachment/@UI.LineItem' + } + ], + UI.FieldGroup #General : { + Data : [ + { Value : name }, + ] + } ); \ No newline at end of file diff --git a/tests/incidents-app/db/attachments.cds b/tests/incidents-app/db/attachments.cds index cf27cb33..3f9fe31a 100644 --- a/tests/incidents-app/db/attachments.cds +++ b/tests/incidents-app/db/attachments.cds @@ -1,5 +1,5 @@ using {sap.capire.incidents as my} from './schema'; -using {Attachments} from '@cap-js/attachments'; +using {Attachments, Attachment} from '@cap-js/attachments'; extend my.Incidents with { @Validation.MaxItems: 2 @@ -54,3 +54,7 @@ extend my.NonDraftTest with { extend my.SingleTestDetails with { attachments : Composition of many Attachments; } + +extend my.SingleAttachment with { + myAttachment : Attachment; +} diff --git a/tests/incidents-app/db/schema.cds b/tests/incidents-app/db/schema.cds index 438e3d3f..10f6a901 100644 --- a/tests/incidents-app/db/schema.cds +++ b/tests/incidents-app/db/schema.cds @@ -100,3 +100,8 @@ entity NonDraftTest : cuid, managed { entity SingleTestDetails : cuid { abc : String; } + +entity SingleAttachment : cuid { + key ID : UUID; + name : String; +} diff --git a/tests/incidents-app/srv/services.cds b/tests/incidents-app/srv/services.cds index 1bd8eb09..e11c67d5 100644 --- a/tests/incidents-app/srv/services.cds +++ b/tests/incidents-app/srv/services.cds @@ -21,6 +21,9 @@ service ProcessorService { entity NonDraftTest as projection on my.NonDraftTest; entity SingleTestDetails as projection on my.SingleTestDetails; + + @odata.draft.enabled + entity SingleAttachment as projection on my.SingleAttachment; } /** diff --git a/tests/integration/attachments.test.js b/tests/integration/attachments.test.js index f990013d..de689b19 100644 --- a/tests/integration/attachments.test.js +++ b/tests/integration/attachments.test.js @@ -1194,6 +1194,77 @@ describe("Tests for uploading/deleting attachments through API calls", () => { }) }) +describe("Tests for single attachment entity", () => { + it("should create a SingleAttachment with an attachment", async () => { + const { data: singleAttachment } = await POST('/odata/v4/processor/SingleAttachment', { + name: 'My Single Attachment Test' + }) + expect(singleAttachment.ID).toBeDefined() + expect(singleAttachment.name).toBe('My Single Attachment Test') + + const filepath = join(__dirname, "content/sample.pdf") + const fileContent = readFileSync(filepath) + + const postRes = await POST( + `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/myAttachment`, + { + filename: basename(filepath), + mimeType: "application/pdf", + } + ) + + const putRes = await PUT( + `/odata/v4/processor/SingleAttachment_myAttachment(up__ID=${singleAttachment.ID},ID=${postRes.data.ID},IsActiveEntity=false)/content`, + fileContent, + { + headers: { + "Content-Type": "application/pdf", + }, + } + ) + expect(putRes.status).toEqual(204) + + // Activate the draft + await POST(`/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/ProcessorService.draftActivate`) + + // Verify active document + const getRes = await GET(`/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=true)/myAttachment`) + expect(getRes.data.ID).toBeDefined() + }) + + it("should delete a SingleAttachment and its attachment", async () => { + // Create a new entity to delete + const { data: singleAttachment } = await POST('/odata/v4/processor/SingleAttachment', { + name: 'Entity to be deleted' + }) + const filepath = join(__dirname, "content/sample.pdf") + const fileContent = readFileSync(filepath) + const postRes = await POST( + `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/myAttachment`, + { filename: basename(filepath) } + ) + await PUT( + `/odata/v4/processor/SingleAttachment_myAttachment(up__ID=${singleAttachment.ID},ID=${postRes.data.ID},IsActiveEntity=false)/content`, + fileContent, + { headers: { "Content-Type": "application/pdf" } } + ) + await POST(`/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/ProcessorService.draftActivate`) + + const activeAttachment = await GET(`/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=true)/myAttachment`) + const attachmentUrl = activeAttachment.data.url + expect(attachmentUrl).toBeDefined() + + // Now, delete the parent entity + const deleteRes = await DELETE(`/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=true)`) + expect(deleteRes.status).toEqual(204) + + // Verify the attachment content is gone from the object store (which is the DB in this test setup) + const db = await cds.connect.to('db') + const content = await db.run(SELECT.one.from('sap.attachments.Attachments').where({ url: attachmentUrl }).columns('content')) + expect(content.content).toBeNull() + }) +}) + describe("Tests for attachments facet disable", () => { beforeAll(async () => { // Initialize test variables From 8a1d26c366a5a205eb2f7afc60952e5065717e82 Mon Sep 17 00:00:00 2001 From: Eric P Date: Wed, 18 Feb 2026 14:24:40 +0100 Subject: [PATCH 10/17] Update tests/incidents-app/db/schema.cds Co-authored-by: hyperspace-insights[bot] <209611008+hyperspace-insights[bot]@users.noreply.github.com> --- tests/incidents-app/db/schema.cds | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/incidents-app/db/schema.cds b/tests/incidents-app/db/schema.cds index 10f6a901..c799e4e8 100644 --- a/tests/incidents-app/db/schema.cds +++ b/tests/incidents-app/db/schema.cds @@ -102,6 +102,8 @@ entity SingleTestDetails : cuid { } entity SingleAttachment : cuid { - key ID : UUID; +entity SingleAttachment : cuid { + name : String; +} name : String; } From ee53aaa508de026a92088a7796c9706cf06800f8 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 18 Feb 2026 15:49:15 +0100 Subject: [PATCH 11/17] Fix bot edit --- tests/incidents-app/db/schema.cds | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/incidents-app/db/schema.cds b/tests/incidents-app/db/schema.cds index c799e4e8..e622aebc 100644 --- a/tests/incidents-app/db/schema.cds +++ b/tests/incidents-app/db/schema.cds @@ -102,8 +102,5 @@ entity SingleTestDetails : cuid { } entity SingleAttachment : cuid { -entity SingleAttachment : cuid { - name : String; -} name : String; } From 19e40c786a629af450663256bbfb13e3f34efc22 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 18 Feb 2026 16:20:41 +0100 Subject: [PATCH 12/17] ID checks to make bot happy --- srv/basic.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/srv/basic.js b/srv/basic.js index 86d3aba8..1d6157d0 100644 --- a/srv/basic.js +++ b/srv/basic.js @@ -368,11 +368,13 @@ class AttachmentsService extends cds.Service { this.traverseDataByPath(active, attachmentsComp) || [] const draftAttachments = this.traverseDataByPath(draft, attachmentsComp) || [] - const draftAttachmentIDs = new Set(draftAttachments.map((a) => a.ID)) + const draftAttachmentIDs = new Set( + draftAttachments.filter((a) => a.ID).map((a) => a.ID), + ) // Find attachments present in the active entity but not in the draft const deletedAttachments = activeAttachments.filter( - (att) => att.url && !draftAttachmentIDs.has(att.ID), + (att) => att.url && att.ID && !draftAttachmentIDs.has(att.ID), ) const entityTarget = traverseEntity(req.target, attachmentsComp) From 977109678e9d37ee41aca3f965a1b265f950b67a Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 18 Feb 2026 17:12:30 +0100 Subject: [PATCH 13/17] Remove reference to non-existent field --- db/index.cds | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/index.cds b/db/index.cds index 9fed0512..bc1eb099 100644 --- a/db/index.cds +++ b/db/index.cds @@ -17,7 +17,7 @@ context sap.attachments { mimeType : String default 'application/octet-stream' @title: '{i18n>MediaType}'; filename : String @title: '{i18n>FileName}'; hash : String @UI.Hidden @Core.Computed; - status : Association to one ScanStates default 'Unscanned' @title: '{i18n>ScanStatus}' @Common.Text: statusNav.name @Common.TextArrangement: #TextOnly; + status : Association to one ScanStates default 'Unscanned' @title: '{i18n>ScanStatus}' @Common.Text: status.name @Common.TextArrangement: #TextOnly; lastScan : Timestamp @title: '{i18n>LastScan}' @Core.Computed; } From 8dd385a69a25bf6b843062fc559f3d9a1a247022 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 18 Feb 2026 17:14:30 +0100 Subject: [PATCH 14/17] Prettier should ignore test headers --- tests/integration/attachments.test.js | 84 +++++++++++++++++---------- 1 file changed, 53 insertions(+), 31 deletions(-) diff --git a/tests/integration/attachments.test.js b/tests/integration/attachments.test.js index 9853ab64..59d7cea5 100644 --- a/tests/integration/attachments.test.js +++ b/tests/integration/attachments.test.js @@ -1235,6 +1235,7 @@ describe("Tests for uploading/deleting attachments through API calls", () => { expect(getRes2.data.url).toBeTruthy() }) + // prettier-ignore isNotLocal("Should detect infected files and automatically delete them after scan", async () => { const incidentID = await newIncident(POST, "processor") const testMal = @@ -1291,71 +1292,92 @@ describe("Tests for uploading/deleting attachments through API calls", () => { describe("Tests for single attachment entity", () => { it("should create a SingleAttachment with an attachment", async () => { - const { data: singleAttachment } = await POST('/odata/v4/processor/SingleAttachment', { - name: 'My Single Attachment Test' - }) + const { data: singleAttachment } = await POST( + "/odata/v4/processor/SingleAttachment", + { + name: "My Single Attachment Test", + }, + ) expect(singleAttachment.ID).toBeDefined() - expect(singleAttachment.name).toBe('My Single Attachment Test') + expect(singleAttachment.name).toBe("My Single Attachment Test") const filepath = join(__dirname, "content/sample.pdf") const fileContent = readFileSync(filepath) const postRes = await POST( - `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/myAttachment`, - { - filename: basename(filepath), - mimeType: "application/pdf", - } + `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/myAttachment`, + { + filename: basename(filepath), + mimeType: "application/pdf", + }, ) const putRes = await PUT( - `/odata/v4/processor/SingleAttachment_myAttachment(up__ID=${singleAttachment.ID},ID=${postRes.data.ID},IsActiveEntity=false)/content`, - fileContent, - { - headers: { - "Content-Type": "application/pdf", - }, - } + `/odata/v4/processor/SingleAttachment_myAttachment(up__ID=${singleAttachment.ID},ID=${postRes.data.ID},IsActiveEntity=false)/content`, + fileContent, + { + headers: { + "Content-Type": "application/pdf", + }, + }, ) expect(putRes.status).toEqual(204) // Activate the draft - await POST(`/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/ProcessorService.draftActivate`) + await POST( + `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/ProcessorService.draftActivate`, + ) // Verify active document - const getRes = await GET(`/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=true)/myAttachment`) + const getRes = await GET( + `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=true)/myAttachment`, + ) expect(getRes.data.ID).toBeDefined() }) it("should delete a SingleAttachment and its attachment", async () => { // Create a new entity to delete - const { data: singleAttachment } = await POST('/odata/v4/processor/SingleAttachment', { - name: 'Entity to be deleted' - }) + const { data: singleAttachment } = await POST( + "/odata/v4/processor/SingleAttachment", + { + name: "Entity to be deleted", + }, + ) const filepath = join(__dirname, "content/sample.pdf") const fileContent = readFileSync(filepath) const postRes = await POST( - `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/myAttachment`, - { filename: basename(filepath) } + `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/myAttachment`, + { filename: basename(filepath) }, ) await PUT( - `/odata/v4/processor/SingleAttachment_myAttachment(up__ID=${singleAttachment.ID},ID=${postRes.data.ID},IsActiveEntity=false)/content`, - fileContent, - { headers: { "Content-Type": "application/pdf" } } + `/odata/v4/processor/SingleAttachment_myAttachment(up__ID=${singleAttachment.ID},ID=${postRes.data.ID},IsActiveEntity=false)/content`, + fileContent, + { headers: { "Content-Type": "application/pdf" } }, + ) + await POST( + `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/ProcessorService.draftActivate`, ) - await POST(`/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/ProcessorService.draftActivate`) - const activeAttachment = await GET(`/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=true)/myAttachment`) + const activeAttachment = await GET( + `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=true)/myAttachment`, + ) const attachmentUrl = activeAttachment.data.url expect(attachmentUrl).toBeDefined() // Now, delete the parent entity - const deleteRes = await DELETE(`/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=true)`) + const deleteRes = await DELETE( + `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=true)`, + ) expect(deleteRes.status).toEqual(204) // Verify the attachment content is gone from the object store (which is the DB in this test setup) - const db = await cds.connect.to('db') - const content = await db.run(SELECT.one.from('sap.attachments.Attachments').where({ url: attachmentUrl }).columns('content')) + const db = await cds.connect.to("db") + const content = await db.run( + SELECT.one + .from("sap.attachments.Attachments") + .where({ url: attachmentUrl }) + .columns("content"), + ) expect(content.content).toBeNull() }) }) From 0edf07fdb1eda0892edf1b2487ddd3d365d0a23d Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 18 Feb 2026 17:16:57 +0100 Subject: [PATCH 15/17] Clean up indentation --- db/index.cds | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/db/index.cds b/db/index.cds index bc1eb099..5045b415 100644 --- a/db/index.cds +++ b/db/index.cds @@ -12,13 +12,13 @@ using { context sap.attachments { type SingleMediaData @(_is_media_data) { - url : String @UI.Hidden; - content : LargeBinary @title: '{i18n>Attachment}'; // only for db-based services - mimeType : String default 'application/octet-stream' @title: '{i18n>MediaType}'; - filename : String @title: '{i18n>FileName}'; - hash : String @UI.Hidden @Core.Computed; - status : Association to one ScanStates default 'Unscanned' @title: '{i18n>ScanStatus}' @Common.Text: status.name @Common.TextArrangement: #TextOnly; - lastScan : Timestamp @title: '{i18n>LastScan}' @Core.Computed; + url : String @UI.Hidden; + content : LargeBinary @title: '{i18n>Attachment}'; // only for db-based services + mimeType : String default 'application/octet-stream' @title: '{i18n>MediaType}'; + filename : String @title: '{i18n>FileName}'; + hash : String @UI.Hidden @Core.Computed; + status : Association to one ScanStates default 'Unscanned' @title: '{i18n>ScanStatus}' @Common.Text: status.name @Common.TextArrangement: #TextOnly; + lastScan : Timestamp @title: '{i18n>LastScan}' @Core.Computed; } aspect MediaData @(_is_media_data) { From f00bb122414eac70a3a40681f87cabed715450e2 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Fri, 20 Feb 2026 09:49:46 +0100 Subject: [PATCH 16/17] Data added and tests updated --- .../sap.capire.incidents-SingleAttachment.csv | 3 ++ tests/integration/attachments.test.js | 28 +++++++++++-------- 2 files changed, 20 insertions(+), 11 deletions(-) create mode 100644 tests/incidents-app/db/data/sap.capire.incidents-SingleAttachment.csv diff --git a/tests/incidents-app/db/data/sap.capire.incidents-SingleAttachment.csv b/tests/incidents-app/db/data/sap.capire.incidents-SingleAttachment.csv new file mode 100644 index 00000000..b239f760 --- /dev/null +++ b/tests/incidents-app/db/data/sap.capire.incidents-SingleAttachment.csv @@ -0,0 +1,3 @@ +ID,name +a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d,My single attachment +b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e,My other attachment \ No newline at end of file diff --git a/tests/integration/attachments.test.js b/tests/integration/attachments.test.js index 59d7cea5..49aa8f35 100644 --- a/tests/integration/attachments.test.js +++ b/tests/integration/attachments.test.js @@ -1304,16 +1304,17 @@ describe("Tests for single attachment entity", () => { const filepath = join(__dirname, "content/sample.pdf") const fileContent = readFileSync(filepath) - const postRes = await POST( + const putRes = await PUT( `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/myAttachment`, { - filename: basename(filepath), - mimeType: "application/pdf", + filename: basename(filepath), + mimeType: "application/pdf", }, ) + expect(putRes.status).toEqual(200) - const putRes = await PUT( - `/odata/v4/processor/SingleAttachment_myAttachment(up__ID=${singleAttachment.ID},ID=${postRes.data.ID},IsActiveEntity=false)/content`, + const putContentRes = await PUT( + `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/myAttachment/content`, fileContent, { headers: { @@ -1321,7 +1322,7 @@ describe("Tests for single attachment entity", () => { }, }, ) - expect(putRes.status).toEqual(204) + expect(putContentRes.status).toEqual(204) // Activate the draft await POST( @@ -1332,7 +1333,7 @@ describe("Tests for single attachment entity", () => { const getRes = await GET( `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=true)/myAttachment`, ) - expect(getRes.data.ID).toBeDefined() + expect(getRes.data.filename).toBe(basename(filepath)) }) it("should delete a SingleAttachment and its attachment", async () => { @@ -1345,15 +1346,21 @@ describe("Tests for single attachment entity", () => { ) const filepath = join(__dirname, "content/sample.pdf") const fileContent = readFileSync(filepath) - const postRes = await POST( + + await PUT( `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/myAttachment`, - { filename: basename(filepath) }, + { + filename: basename(filepath), + mimeType: "application/pdf", + }, ) + await PUT( - `/odata/v4/processor/SingleAttachment_myAttachment(up__ID=${singleAttachment.ID},ID=${postRes.data.ID},IsActiveEntity=false)/content`, + `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/myAttachment/content`, fileContent, { headers: { "Content-Type": "application/pdf" } }, ) + await POST( `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/ProcessorService.draftActivate`, ) @@ -1376,7 +1383,6 @@ describe("Tests for single attachment entity", () => { SELECT.one .from("sap.attachments.Attachments") .where({ url: attachmentUrl }) - .columns("content"), ) expect(content.content).toBeNull() }) From 06b68c0be7c982132e6c14a651dbaca761e3cc11 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Fri, 20 Feb 2026 09:51:40 +0100 Subject: [PATCH 17/17] Prettier --- tests/integration/attachments.test.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/integration/attachments.test.js b/tests/integration/attachments.test.js index 49aa8f35..eaf7b1ac 100644 --- a/tests/integration/attachments.test.js +++ b/tests/integration/attachments.test.js @@ -1307,8 +1307,8 @@ describe("Tests for single attachment entity", () => { const putRes = await PUT( `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/myAttachment`, { - filename: basename(filepath), - mimeType: "application/pdf", + filename: basename(filepath), + mimeType: "application/pdf", }, ) expect(putRes.status).toEqual(200) @@ -1346,10 +1346,10 @@ describe("Tests for single attachment entity", () => { ) const filepath = join(__dirname, "content/sample.pdf") const fileContent = readFileSync(filepath) - + await PUT( `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/myAttachment`, - { + { filename: basename(filepath), mimeType: "application/pdf", }, @@ -1382,7 +1382,7 @@ describe("Tests for single attachment entity", () => { const content = await db.run( SELECT.one .from("sap.attachments.Attachments") - .where({ url: attachmentUrl }) + .where({ url: attachmentUrl }), ) expect(content.content).toBeNull() })