diff --git a/README.md b/README.md index 64108622..54d93f90 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ This plugin can be consumed by the CAP application deployed on BTP to store thei - [Support for Link type attachments](#support-for-link-type-attachments) - [Support for Edit of Link type attachments](#support-for-edit-of-link-type-attachments) - [Support for Non-Draft Attachments](#support-for-non-draft-attachments) +- [Support for Multiple attachment facets](#support-for-multiple-attachment-facets) - [Support for Technical User](#support-for-technical-user) - [Support for Multitenancy](#support-for-multitenancy) - [Deploying and testing the application](#deploying-and-testing-the-application) @@ -631,6 +632,130 @@ service ProcessorService { entity Projects.attachments as projection on my.Projects.attachments; } ``` + +## Support for Multiple attachment facets + +The plugin supports creating multiple attachment facets or sections, each allowing various documents to be uploaded. The names of these facets are fully customizable. All existing operations available for the default attachment facet are also supported for any additional facets you create. + +Refer the following example from a sample Incidents Management app to configure multiple attachment facets. + +> **Note** +> +> This is an example of adding the `references` facet. You can add as many facets as needed by following the same pattern. + +### 1. Add facet entries in `annotations.cds` - [Example](https://github.com/cap-js/sdm/blob/develop/app/single-tenant/central-space/incidents-app/app/incidents/annotations.cds) + +Add `UI.ReferenceFacet` entries so each composition appears as a separate section on the object page. + +```cds +{ + $Type : 'UI.ReferenceFacet', + ID : 'ReferencesFacet', + Label : 'References', + Target: 'references/@UI.LineItem', +} +``` + +Add the following `references` annotation block in `annotations.cds`: + +```cds +//////////////////////////////////////////////////////////////////////////// +// +// References Details +// +annotate service.Incidents.references with @UI: { + HeaderInfo: { + $Type : 'UI.HeaderInfoType', + TypeName : '{i18n>Attachment}', + TypeNamePlural: '{i18n>Attachments}', + }, + LineItem : [ + {Value: type, @HTML5.CssDefaults: {width: '10%'}}, + {Value: filename, @HTML5.CssDefaults: {width: '25%'}}, + {Value: content, @HTML5.CssDefaults: {width: '0%'}}, + {Value: createdAt, @HTML5.CssDefaults: {width: '20%'}}, + {Value: createdBy, @HTML5.CssDefaults: {width: '20%'}}, + {Value: note, @HTML5.CssDefaults: {width: '25%'}}, + { + $Type : 'UI.DataFieldForActionGroup', + ID : 'TableActionGroup', + Label : 'Create', + ![@UI.Hidden]: {$edmJson: {$Eq: [ {$Path: 'IsActiveEntity'}, true ]}}, + Actions: [ + { + $Type : 'UI.DataFieldForAction', + Label : 'Link', + Action: 'ProcessorService.createLink', + } + ] + }, + { + @UI.Hidden: {$edmJson:{$If:[{$Eq:[{$Path: 'IsActiveEntity' },true]},true,{$If:[{$Ne:[{$Path:'mimeType'},'application/internet-shortcut']},true,false]}]}}, + $Type : 'UI.DataFieldForAction', + Label : 'Edit Link', + Action: 'ProcessorService.editLink', // -> Ensure the service name is correct + Inline: true, + IconUrl: 'sap-icon://edit', + @HTML5.CssDefaults: {width: '4%'} + }, + ] +} +{ + url @readonly; + note @(title: '{i18n>Note}'); + filename @(title: '{i18n>Filename}'); + modifiedAt @(odata.etag: null); + content + @Core.ContentDisposition: { Filename: filename, Type: 'inline' } + @(title: '{i18n>Attachment}'); + folderId @UI.Hidden; + mimeType @UI.Hidden; + status @UI.Hidden; + repositoryId @UI.Hidden; +} + +annotate service.Incidents.references with { + customProperty1 @Common.ValueListWithFixedValues; +} +``` + +### 2. Expose each facet as a projection with actions in `srv/service.cds` - [Example](https://github.com/cap-js/sdm/blob/develop/app/single-tenant/central-space/incidents-app/srv/service.cds) + +Each facet must be projected and action-enabled, for example: + +```cds +entity Incidents.references as projection on my.Incidents.references +actions { + @(Common.SideEffects : {TargetEntities: ['']},) + action createLink( + in:many $self, + @mandatory @Common.Label:'Name' name: String @UI.Placeholder: 'Enter a name for the link', + @mandatory @assert.format:'^(https?:\/\/)(([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}|localhost)(:\d{2,5})?(\/[^\s]*)?$' @Common.Label:'URL' url: String @UI.Placeholder: 'Example: https://www.example.com' + ); + action editLink( + @mandatory @assert.format:'^(https?:\/\/)(([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}|localhost)(:\d{2,5})?(\/[^\s]*)?$' + @Common.Label:'URL' url: String @UI.Placeholder: 'Example: https://www.example.com' + ); + action openAttachment() returns { value: String; }; +} +``` + +### 3. Add `controlConfiguration` entries in `manifest.json` - [Example](https://github.com/cap-js/sdm/blob/develop/app/single-tenant/central-space/incidents-app/app/incidents/webapp/manifest.json) + +For row-press behavior in every facet table, configure each line item target: + +```json +"controlConfiguration": { + "references/@com.sap.vocabularies.UI.v1.LineItem": { + "tableSettings": { + "type": "ResponsiveTable", + "selectionMode": "Auto", + "rowPress": ".extension.ns.incidents.controller.custom.onRowPress" + } + } +} +``` + ## Support for Technical User The CAP OData operations can be performed on attachments using a technical user. This flow can be used for machine-to-machine (M2M) interactions, where user involvement is not necessary. diff --git a/app/multi-tenant/central-space/cap-js-incidents-app/app/incidents/annotations.cds b/app/multi-tenant/central-space/cap-js-incidents-app/app/incidents/annotations.cds index 5e89d606..d1c4cf11 100644 --- a/app/multi-tenant/central-space/cap-js-incidents-app/app/incidents/annotations.cds +++ b/app/multi-tenant/central-space/cap-js-incidents-app/app/incidents/annotations.cds @@ -71,6 +71,24 @@ annotate service.Incidents with @( ID : 'i18nConversation', Target : 'conversation/@UI.LineItem#i18nConversation1', }, + { + $Type : 'UI.ReferenceFacet', + ID : 'AttachmentsFacet', + Label : 'Attachments', + Target: 'attachments/@UI.LineItem', + }, + { + $Type : 'UI.ReferenceFacet', + ID : 'ReferencesFacet', + Label : 'References', + Target: 'references/@UI.LineItem', + }, + { + $Type : 'UI.ReferenceFacet', + ID : 'FootnotesFacet', + Label : 'Footnotes', + Target: 'footnotes/@UI.LineItem', + }, ] ); annotate service.Incidents with @( @@ -185,6 +203,65 @@ annotate service.Incidents.conversation with @( // // Attachments Details // +annotate service.Incidents.attachments with @UI: { + HeaderInfo: { + $Type : 'UI.HeaderInfoType', + TypeName : '{i18n>Attachment}', + TypeNamePlural: '{i18n>Attachments}', + }, + LineItem : [ + {Value: type, @HTML5.CssDefaults: {width: '10%'}}, + {Value: filename, @HTML5.CssDefaults: {width: '25%'}}, + {Value: content, @HTML5.CssDefaults: {width: '0%'}}, + {Value: createdAt, @HTML5.CssDefaults: {width: '20%'}}, + {Value: createdBy, @HTML5.CssDefaults: {width: '20%'}}, + {Value: note, @HTML5.CssDefaults: {width: '25%'}}, + { + $Type : 'UI.DataFieldForActionGroup', + ID : 'TableActionGroup', + Label : 'Create', + ![@UI.Hidden]: {$edmJson: {$Eq: [ {$Path: 'IsActiveEntity'}, true ]}}, + Actions: [ + { + $Type : 'UI.DataFieldForAction', + Label : 'Link', + Action: 'ProcessorService.createLink', + } + ] + }, + { + @UI.Hidden: {$edmJson:{$If:[{$Eq:[{$Path: 'IsActiveEntity' },true]},true,{$If:[{$Ne:[{$Path:'mimeType'},'application/internet-shortcut']},true,false]}]}}, + $Type : 'UI.DataFieldForAction', + Label : 'Edit Link', + Action: 'ProcessorService.editLink', + Inline: true, + IconUrl: 'sap-icon://edit', + @HTML5.CssDefaults: {width: '4%'} + }, + ] +} +{ + url @readonly; + note @(title: '{i18n>Note}'); + filename @(title: '{i18n>Filename}'); + modifiedAt @(odata.etag: null); + content + @Core.ContentDisposition: { Filename: filename, Type: 'inline' } + @(title: '{i18n>Attachment}'); + folderId @UI.Hidden; + mimeType @UI.Hidden; + status @UI.Hidden; + repositoryId @UI.Hidden; +} + +annotate service.Incidents.attachments with { + customProperty1 @Common.ValueListWithFixedValues; +} + +//////////////////////////////////////////////////////////////////////////// +// +// References Details +// annotate service.Incidents.references with @UI: { HeaderInfo: { $Type : 'UI.HeaderInfoType', @@ -239,3 +316,62 @@ annotate service.Incidents.references with @UI: { annotate service.Incidents.references with { customProperty1 @Common.ValueListWithFixedValues; } + +//////////////////////////////////////////////////////////////////////////// +// +// Footnotes Details +// +annotate service.Incidents.footnotes with @UI: { + HeaderInfo: { + $Type : 'UI.HeaderInfoType', + TypeName : '{i18n>Footnote}', + TypeNamePlural: '{i18n>Footnotes}', + }, + LineItem : [ + {Value: type, @HTML5.CssDefaults: {width: '10%'}}, + {Value: filename, @HTML5.CssDefaults: {width: '25%'}}, + {Value: content, @HTML5.CssDefaults: {width: '0%'}}, + {Value: createdAt, @HTML5.CssDefaults: {width: '20%'}}, + {Value: createdBy, @HTML5.CssDefaults: {width: '20%'}}, + {Value: note, @HTML5.CssDefaults: {width: '25%'}}, + { + $Type : 'UI.DataFieldForActionGroup', + ID : 'TableActionGroup', + Label : 'Create', + ![@UI.Hidden]: {$edmJson: {$Eq: [ {$Path: 'IsActiveEntity'}, true ]}}, + Actions: [ + { + $Type : 'UI.DataFieldForAction', + Label : 'Link', + Action: 'ProcessorService.createLink', + } + ] + }, + { + @UI.Hidden: {$edmJson:{$If:[{$Eq:[{$Path: 'IsActiveEntity' },true]},true,{$If:[{$Ne:[{$Path:'mimeType'},'application/internet-shortcut']},true,false]}]}}, + $Type : 'UI.DataFieldForAction', + Label : 'Edit Link', + Action: 'ProcessorService.editLink', + Inline: true, + IconUrl: 'sap-icon://edit', + @HTML5.CssDefaults: {width: '4%'} + }, + ] +} +{ + url @readonly; + note @(title: '{i18n>Note}'); + filename @(title: '{i18n>Filename}'); + modifiedAt @(odata.etag: null); + content + @Core.ContentDisposition: { Filename: filename, Type: 'inline' } + @(title: '{i18n>Attachment}'); + folderId @UI.Hidden; + mimeType @UI.Hidden; + status @UI.Hidden; + repositoryId @UI.Hidden; +} + +annotate service.Incidents.footnotes with { + customProperty1 @Common.ValueListWithFixedValues; +} diff --git a/app/multi-tenant/central-space/cap-js-incidents-app/app/incidents/webapp/manifest.json b/app/multi-tenant/central-space/cap-js-incidents-app/app/incidents/webapp/manifest.json index a6597373..505a7dc0 100644 --- a/app/multi-tenant/central-space/cap-js-incidents-app/app/incidents/webapp/manifest.json +++ b/app/multi-tenant/central-space/cap-js-incidents-app/app/incidents/webapp/manifest.json @@ -153,12 +153,26 @@ } } }, + "attachments/@com.sap.vocabularies.UI.v1.LineItem": { + "tableSettings": { + "type": "ResponsiveTable", + "selectionMode": "Auto", + "rowPress": ".extension.ns.incidents.controller.custom.onRowPress" + } + }, "references/@com.sap.vocabularies.UI.v1.LineItem": { "tableSettings": { "type": "ResponsiveTable", "selectionMode": "Auto", "rowPress": ".extension.ns.incidents.mtx.controller.custom.onRowPress" } + }, + "footnotes/@com.sap.vocabularies.UI.v1.LineItem": { + "tableSettings": { + "type": "ResponsiveTable", + "selectionMode": "Auto", + "rowPress": ".extension.ns.incidents.controller.custom.onRowPress" + } } } } diff --git a/app/multi-tenant/central-space/cap-js-incidents-app/srv/services.cds b/app/multi-tenant/central-space/cap-js-incidents-app/srv/services.cds index 04f9290c..bf0cdd33 100644 --- a/app/multi-tenant/central-space/cap-js-incidents-app/srv/services.cds +++ b/app/multi-tenant/central-space/cap-js-incidents-app/srv/services.cds @@ -10,6 +10,20 @@ service ProcessorService @(requires:['support','system-user']) { entity Customers @readonly as projection on my.Customers; entity Projects as projection on my.Projects; // Non-draft entity for testing entity Projects.references as projection on my.Projects.references; + entity Incidents.attachments as projection on my.Incidents.attachments + actions { + @(Common.SideEffects : {TargetEntities: ['']},) + action createLink( + in:many $self, + @mandatory @Common.Label:'Name' name: String @UI.Placeholder: 'Enter a name for the link', + @mandatory @assert.format:'^(https?:\/\/)(([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}|localhost)(:\d{2,5})?(\/[^\s]*)?$' @Common.Label:'URL' url: String @UI.Placeholder: 'Example: https://www.example.com' + ); + action editLink( + @mandatory @assert.format:'^(https?:\/\/)(([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}|localhost)(:\d{2,5})?(\/[^\s]*)?$' + @Common.Label:'URL' url: String @UI.Placeholder: 'Example: https://www.example.com' + ); + action openAttachment() returns { value: String; }; + } entity Incidents.references as projection on my.Incidents.references actions { @(Common.SideEffects : {TargetEntities: ['']},) @@ -24,11 +38,27 @@ service ProcessorService @(requires:['support','system-user']) { ); action openAttachment() returns { value: String; }; } + entity Incidents.footnotes as projection on my.Incidents.footnotes + actions { + @(Common.SideEffects : {TargetEntities: ['']},) + action createLink( + in:many $self, + @mandatory @Common.Label:'Name' name: String @UI.Placeholder: 'Enter a name for the link', + @mandatory @assert.format:'^(https?:\/\/)(([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}|localhost)(:\d{2,5})?(\/[^\s]*)?$' @Common.Label:'URL' url: String @UI.Placeholder: 'Example: https://www.example.com' + ); + action editLink( + @mandatory @assert.format:'^(https?:\/\/)(([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}|localhost)(:\d{2,5})?(\/[^\s]*)?$' + @Common.Label:'URL' url: String @UI.Placeholder: 'Example: https://www.example.com' + ); + action openAttachment() returns { value: String; }; + } } extend my.Incidents with { - references : Composition of many Attachments; -} + attachments : Composition of many Attachments; + references : Composition of many Attachments; + footnotes : Composition of many Attachments; +} extend my.Projects with { references : Composition of many Attachments; diff --git a/app/multi-tenant/personal-space/cap-js-incidents-app/app/incidents/annotations.cds b/app/multi-tenant/personal-space/cap-js-incidents-app/app/incidents/annotations.cds index 5e89d606..d1c4cf11 100644 --- a/app/multi-tenant/personal-space/cap-js-incidents-app/app/incidents/annotations.cds +++ b/app/multi-tenant/personal-space/cap-js-incidents-app/app/incidents/annotations.cds @@ -71,6 +71,24 @@ annotate service.Incidents with @( ID : 'i18nConversation', Target : 'conversation/@UI.LineItem#i18nConversation1', }, + { + $Type : 'UI.ReferenceFacet', + ID : 'AttachmentsFacet', + Label : 'Attachments', + Target: 'attachments/@UI.LineItem', + }, + { + $Type : 'UI.ReferenceFacet', + ID : 'ReferencesFacet', + Label : 'References', + Target: 'references/@UI.LineItem', + }, + { + $Type : 'UI.ReferenceFacet', + ID : 'FootnotesFacet', + Label : 'Footnotes', + Target: 'footnotes/@UI.LineItem', + }, ] ); annotate service.Incidents with @( @@ -185,6 +203,65 @@ annotate service.Incidents.conversation with @( // // Attachments Details // +annotate service.Incidents.attachments with @UI: { + HeaderInfo: { + $Type : 'UI.HeaderInfoType', + TypeName : '{i18n>Attachment}', + TypeNamePlural: '{i18n>Attachments}', + }, + LineItem : [ + {Value: type, @HTML5.CssDefaults: {width: '10%'}}, + {Value: filename, @HTML5.CssDefaults: {width: '25%'}}, + {Value: content, @HTML5.CssDefaults: {width: '0%'}}, + {Value: createdAt, @HTML5.CssDefaults: {width: '20%'}}, + {Value: createdBy, @HTML5.CssDefaults: {width: '20%'}}, + {Value: note, @HTML5.CssDefaults: {width: '25%'}}, + { + $Type : 'UI.DataFieldForActionGroup', + ID : 'TableActionGroup', + Label : 'Create', + ![@UI.Hidden]: {$edmJson: {$Eq: [ {$Path: 'IsActiveEntity'}, true ]}}, + Actions: [ + { + $Type : 'UI.DataFieldForAction', + Label : 'Link', + Action: 'ProcessorService.createLink', + } + ] + }, + { + @UI.Hidden: {$edmJson:{$If:[{$Eq:[{$Path: 'IsActiveEntity' },true]},true,{$If:[{$Ne:[{$Path:'mimeType'},'application/internet-shortcut']},true,false]}]}}, + $Type : 'UI.DataFieldForAction', + Label : 'Edit Link', + Action: 'ProcessorService.editLink', + Inline: true, + IconUrl: 'sap-icon://edit', + @HTML5.CssDefaults: {width: '4%'} + }, + ] +} +{ + url @readonly; + note @(title: '{i18n>Note}'); + filename @(title: '{i18n>Filename}'); + modifiedAt @(odata.etag: null); + content + @Core.ContentDisposition: { Filename: filename, Type: 'inline' } + @(title: '{i18n>Attachment}'); + folderId @UI.Hidden; + mimeType @UI.Hidden; + status @UI.Hidden; + repositoryId @UI.Hidden; +} + +annotate service.Incidents.attachments with { + customProperty1 @Common.ValueListWithFixedValues; +} + +//////////////////////////////////////////////////////////////////////////// +// +// References Details +// annotate service.Incidents.references with @UI: { HeaderInfo: { $Type : 'UI.HeaderInfoType', @@ -239,3 +316,62 @@ annotate service.Incidents.references with @UI: { annotate service.Incidents.references with { customProperty1 @Common.ValueListWithFixedValues; } + +//////////////////////////////////////////////////////////////////////////// +// +// Footnotes Details +// +annotate service.Incidents.footnotes with @UI: { + HeaderInfo: { + $Type : 'UI.HeaderInfoType', + TypeName : '{i18n>Footnote}', + TypeNamePlural: '{i18n>Footnotes}', + }, + LineItem : [ + {Value: type, @HTML5.CssDefaults: {width: '10%'}}, + {Value: filename, @HTML5.CssDefaults: {width: '25%'}}, + {Value: content, @HTML5.CssDefaults: {width: '0%'}}, + {Value: createdAt, @HTML5.CssDefaults: {width: '20%'}}, + {Value: createdBy, @HTML5.CssDefaults: {width: '20%'}}, + {Value: note, @HTML5.CssDefaults: {width: '25%'}}, + { + $Type : 'UI.DataFieldForActionGroup', + ID : 'TableActionGroup', + Label : 'Create', + ![@UI.Hidden]: {$edmJson: {$Eq: [ {$Path: 'IsActiveEntity'}, true ]}}, + Actions: [ + { + $Type : 'UI.DataFieldForAction', + Label : 'Link', + Action: 'ProcessorService.createLink', + } + ] + }, + { + @UI.Hidden: {$edmJson:{$If:[{$Eq:[{$Path: 'IsActiveEntity' },true]},true,{$If:[{$Ne:[{$Path:'mimeType'},'application/internet-shortcut']},true,false]}]}}, + $Type : 'UI.DataFieldForAction', + Label : 'Edit Link', + Action: 'ProcessorService.editLink', + Inline: true, + IconUrl: 'sap-icon://edit', + @HTML5.CssDefaults: {width: '4%'} + }, + ] +} +{ + url @readonly; + note @(title: '{i18n>Note}'); + filename @(title: '{i18n>Filename}'); + modifiedAt @(odata.etag: null); + content + @Core.ContentDisposition: { Filename: filename, Type: 'inline' } + @(title: '{i18n>Attachment}'); + folderId @UI.Hidden; + mimeType @UI.Hidden; + status @UI.Hidden; + repositoryId @UI.Hidden; +} + +annotate service.Incidents.footnotes with { + customProperty1 @Common.ValueListWithFixedValues; +} diff --git a/app/multi-tenant/personal-space/cap-js-incidents-app/app/incidents/webapp/manifest.json b/app/multi-tenant/personal-space/cap-js-incidents-app/app/incidents/webapp/manifest.json index a6597373..505a7dc0 100644 --- a/app/multi-tenant/personal-space/cap-js-incidents-app/app/incidents/webapp/manifest.json +++ b/app/multi-tenant/personal-space/cap-js-incidents-app/app/incidents/webapp/manifest.json @@ -153,12 +153,26 @@ } } }, + "attachments/@com.sap.vocabularies.UI.v1.LineItem": { + "tableSettings": { + "type": "ResponsiveTable", + "selectionMode": "Auto", + "rowPress": ".extension.ns.incidents.controller.custom.onRowPress" + } + }, "references/@com.sap.vocabularies.UI.v1.LineItem": { "tableSettings": { "type": "ResponsiveTable", "selectionMode": "Auto", "rowPress": ".extension.ns.incidents.mtx.controller.custom.onRowPress" } + }, + "footnotes/@com.sap.vocabularies.UI.v1.LineItem": { + "tableSettings": { + "type": "ResponsiveTable", + "selectionMode": "Auto", + "rowPress": ".extension.ns.incidents.controller.custom.onRowPress" + } } } } diff --git a/app/multi-tenant/personal-space/cap-js-incidents-app/srv/services.cds b/app/multi-tenant/personal-space/cap-js-incidents-app/srv/services.cds index 04f9290c..bf0cdd33 100644 --- a/app/multi-tenant/personal-space/cap-js-incidents-app/srv/services.cds +++ b/app/multi-tenant/personal-space/cap-js-incidents-app/srv/services.cds @@ -10,6 +10,20 @@ service ProcessorService @(requires:['support','system-user']) { entity Customers @readonly as projection on my.Customers; entity Projects as projection on my.Projects; // Non-draft entity for testing entity Projects.references as projection on my.Projects.references; + entity Incidents.attachments as projection on my.Incidents.attachments + actions { + @(Common.SideEffects : {TargetEntities: ['']},) + action createLink( + in:many $self, + @mandatory @Common.Label:'Name' name: String @UI.Placeholder: 'Enter a name for the link', + @mandatory @assert.format:'^(https?:\/\/)(([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}|localhost)(:\d{2,5})?(\/[^\s]*)?$' @Common.Label:'URL' url: String @UI.Placeholder: 'Example: https://www.example.com' + ); + action editLink( + @mandatory @assert.format:'^(https?:\/\/)(([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}|localhost)(:\d{2,5})?(\/[^\s]*)?$' + @Common.Label:'URL' url: String @UI.Placeholder: 'Example: https://www.example.com' + ); + action openAttachment() returns { value: String; }; + } entity Incidents.references as projection on my.Incidents.references actions { @(Common.SideEffects : {TargetEntities: ['']},) @@ -24,11 +38,27 @@ service ProcessorService @(requires:['support','system-user']) { ); action openAttachment() returns { value: String; }; } + entity Incidents.footnotes as projection on my.Incidents.footnotes + actions { + @(Common.SideEffects : {TargetEntities: ['']},) + action createLink( + in:many $self, + @mandatory @Common.Label:'Name' name: String @UI.Placeholder: 'Enter a name for the link', + @mandatory @assert.format:'^(https?:\/\/)(([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}|localhost)(:\d{2,5})?(\/[^\s]*)?$' @Common.Label:'URL' url: String @UI.Placeholder: 'Example: https://www.example.com' + ); + action editLink( + @mandatory @assert.format:'^(https?:\/\/)(([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}|localhost)(:\d{2,5})?(\/[^\s]*)?$' + @Common.Label:'URL' url: String @UI.Placeholder: 'Example: https://www.example.com' + ); + action openAttachment() returns { value: String; }; + } } extend my.Incidents with { - references : Composition of many Attachments; -} + attachments : Composition of many Attachments; + references : Composition of many Attachments; + footnotes : Composition of many Attachments; +} extend my.Projects with { references : Composition of many Attachments; diff --git a/app/single-tenant/central-space/incidents-app/app/incidents/annotations.cds b/app/single-tenant/central-space/incidents-app/app/incidents/annotations.cds index 58016809..61a68aa4 100644 --- a/app/single-tenant/central-space/incidents-app/app/incidents/annotations.cds +++ b/app/single-tenant/central-space/incidents-app/app/incidents/annotations.cds @@ -71,6 +71,24 @@ annotate service.Incidents with @( ID : 'i18nConversation', Target : 'conversation/@UI.LineItem#i18nConversation1', }, + { + $Type : 'UI.ReferenceFacet', + ID : 'AttachmentsFacet', + Label : 'Attachments', + Target: 'attachments/@UI.LineItem', + }, + { + $Type : 'UI.ReferenceFacet', + ID : 'ReferencesFacet', + Label : 'References', + Target: 'references/@UI.LineItem', + }, + { + $Type : 'UI.ReferenceFacet', + ID : 'FootnotesFacet', + Label : 'Footnotes', + Target: 'footnotes/@UI.LineItem', + }, ] ); annotate service.Incidents with @( @@ -185,6 +203,65 @@ annotate service.Incidents.conversation with @( // // Attachments Details // +annotate service.Incidents.attachments with @UI: { + HeaderInfo: { + $Type : 'UI.HeaderInfoType', + TypeName : '{i18n>Attachment}', + TypeNamePlural: '{i18n>Attachments}', + }, + LineItem : [ + {Value: type, @HTML5.CssDefaults: {width: '10%'}}, + {Value: filename, @HTML5.CssDefaults: {width: '25%'}}, + {Value: content, @HTML5.CssDefaults: {width: '0%'}}, + {Value: createdAt, @HTML5.CssDefaults: {width: '20%'}}, + {Value: createdBy, @HTML5.CssDefaults: {width: '20%'}}, + {Value: note, @HTML5.CssDefaults: {width: '25%'}}, + { + $Type : 'UI.DataFieldForActionGroup', + ID : 'TableActionGroup', + Label : 'Create', + ![@UI.Hidden]: {$edmJson: {$Eq: [ {$Path: 'IsActiveEntity'}, true ]}}, + Actions: [ + { + $Type : 'UI.DataFieldForAction', + Label : 'Link', + Action: 'ProcessorService.createLink', + } + ] + }, + { + @UI.Hidden: {$edmJson:{$If:[{$Eq:[{$Path: 'IsActiveEntity' },true]},true,{$If:[{$Ne:[{$Path:'mimeType'},'application/internet-shortcut']},true,false]}]}}, + $Type : 'UI.DataFieldForAction', + Label : 'Edit Link', + Action: 'ProcessorService.editLink', + Inline: true, + IconUrl: 'sap-icon://edit', + @HTML5.CssDefaults: {width: '4%'} + }, + ] +} +{ + url @readonly; + note @(title: '{i18n>Note}'); + filename @(title: '{i18n>Filename}'); + modifiedAt @(odata.etag: null); + content + @Core.ContentDisposition: { Filename: filename, Type: 'inline' } + @(title: '{i18n>Attachment}'); + folderId @UI.Hidden; + mimeType @UI.Hidden; + status @UI.Hidden; + repositoryId @UI.Hidden; +} + +annotate service.Incidents.attachments with { + customProperty1 @Common.ValueListWithFixedValues; +} + +//////////////////////////////////////////////////////////////////////////// +// +// References Details +// annotate service.Incidents.references with @UI: { HeaderInfo: { $Type : 'UI.HeaderInfoType', @@ -239,3 +316,62 @@ annotate service.Incidents.references with @UI: { annotate service.Incidents.references with { customProperty1 @Common.ValueListWithFixedValues; } + +//////////////////////////////////////////////////////////////////////////// +// +// Footnotes Details +// +annotate service.Incidents.footnotes with @UI: { + HeaderInfo: { + $Type : 'UI.HeaderInfoType', + TypeName : '{i18n>Footnote}', + TypeNamePlural: '{i18n>Footnotes}', + }, + LineItem : [ + {Value: type, @HTML5.CssDefaults: {width: '10%'}}, + {Value: filename, @HTML5.CssDefaults: {width: '25%'}}, + {Value: content, @HTML5.CssDefaults: {width: '0%'}}, + {Value: createdAt, @HTML5.CssDefaults: {width: '20%'}}, + {Value: createdBy, @HTML5.CssDefaults: {width: '20%'}}, + {Value: note, @HTML5.CssDefaults: {width: '25%'}}, + { + $Type : 'UI.DataFieldForActionGroup', + ID : 'TableActionGroup', + Label : 'Create', + ![@UI.Hidden]: {$edmJson: {$Eq: [ {$Path: 'IsActiveEntity'}, true ]}}, + Actions: [ + { + $Type : 'UI.DataFieldForAction', + Label : 'Link', + Action: 'ProcessorService.createLink', + } + ] + }, + { + @UI.Hidden: {$edmJson:{$If:[{$Eq:[{$Path: 'IsActiveEntity' },true]},true,{$If:[{$Ne:[{$Path:'mimeType'},'application/internet-shortcut']},true,false]}]}}, + $Type : 'UI.DataFieldForAction', + Label : 'Edit Link', + Action: 'ProcessorService.editLink', + Inline: true, + IconUrl: 'sap-icon://edit', + @HTML5.CssDefaults: {width: '4%'} + }, + ] +} +{ + url @readonly; + note @(title: '{i18n>Note}'); + filename @(title: '{i18n>Filename}'); + modifiedAt @(odata.etag: null); + content + @Core.ContentDisposition: { Filename: filename, Type: 'inline' } + @(title: '{i18n>Attachment}'); + folderId @UI.Hidden; + mimeType @UI.Hidden; + status @UI.Hidden; + repositoryId @UI.Hidden; +} + +annotate service.Incidents.footnotes with { + customProperty1 @Common.ValueListWithFixedValues; +} diff --git a/app/single-tenant/central-space/incidents-app/app/incidents/webapp/manifest.json b/app/single-tenant/central-space/incidents-app/app/incidents/webapp/manifest.json index 37e319ce..d2a7b458 100644 --- a/app/single-tenant/central-space/incidents-app/app/incidents/webapp/manifest.json +++ b/app/single-tenant/central-space/incidents-app/app/incidents/webapp/manifest.json @@ -164,12 +164,26 @@ } } }, + "attachments/@com.sap.vocabularies.UI.v1.LineItem": { + "tableSettings": { + "type": "ResponsiveTable", + "selectionMode": "Auto", + "rowPress": ".extension.ns.incidents.controller.custom.onRowPress" + } + }, "references/@com.sap.vocabularies.UI.v1.LineItem": { "tableSettings": { "type": "ResponsiveTable", "selectionMode": "Auto", "rowPress": ".extension.ns.incidents.controller.custom.onRowPress" } + }, + "footnotes/@com.sap.vocabularies.UI.v1.LineItem": { + "tableSettings": { + "type": "ResponsiveTable", + "selectionMode": "Auto", + "rowPress": ".extension.ns.incidents.controller.custom.onRowPress" + } } } } diff --git a/app/single-tenant/central-space/incidents-app/srv/service.cds b/app/single-tenant/central-space/incidents-app/srv/service.cds index b17fc484..8bf831d3 100644 --- a/app/single-tenant/central-space/incidents-app/srv/service.cds +++ b/app/single-tenant/central-space/incidents-app/srv/service.cds @@ -10,6 +10,20 @@ service ProcessorService { entity Customers @readonly as projection on my.Customers; entity Projects as projection on my.Projects; // Non-draft entity entity Projects.references as projection on my.Projects.references; + entity Incidents.attachments as projection on my.Incidents.attachments + actions { + @(Common.SideEffects : {TargetEntities: ['']},) + action createLink( + in:many $self, + @mandatory @Common.Label:'Name' name: String @UI.Placeholder: 'Enter a name for the link', + @mandatory @assert.format:'^(https?:\/\/)(([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}|localhost)(:\d{2,5})?(\/[^\s]*)?$' @Common.Label:'URL' url: String @UI.Placeholder: 'Example: https://www.example.com' + ); + action editLink( + @mandatory @assert.format:'^(https?:\/\/)(([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}|localhost)(:\d{2,5})?(\/[^\s]*)?$' + @Common.Label:'URL' url: String @UI.Placeholder: 'Example: https://www.example.com' + ); + action openAttachment() returns { value: String; }; + } entity Incidents.references as projection on my.Incidents.references actions { @(Common.SideEffects : {TargetEntities: ['']},) @@ -24,9 +38,27 @@ service ProcessorService { ); action openAttachment() returns { value: String; }; } + entity Incidents.footnotes as projection on my.Incidents.footnotes + actions { + @(Common.SideEffects : {TargetEntities: ['']},) + action createLink( + in:many $self, + @mandatory @Common.Label:'Name' name: String @UI.Placeholder: 'Enter a name for the link', + @mandatory @assert.format:'^(https?:\/\/)(([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}|localhost)(:\d{2,5})?(\/[^\s]*)?$' @Common.Label:'URL' url: String @UI.Placeholder: 'Example: https://www.example.com' + ); + action editLink( + @mandatory @assert.format:'^(https?:\/\/)(([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}|localhost)(:\d{2,5})?(\/[^\s]*)?$' + @Common.Label:'URL' url: String @UI.Placeholder: 'Example: https://www.example.com' + ); + action openAttachment() returns { value: String; }; + } } -extend my.Incidents with { references: Composition of many Attachments } +extend my.Incidents with { + attachments : Composition of many Attachments; + references : Composition of many Attachments; + footnotes : Composition of many Attachments; +} extend my.Projects with { references: Composition of many Attachments } extend Attachments with { diff --git a/app/single-tenant/personal-space/incidents-app/app/incidents/annotations.cds b/app/single-tenant/personal-space/incidents-app/app/incidents/annotations.cds index 58016809..61a68aa4 100644 --- a/app/single-tenant/personal-space/incidents-app/app/incidents/annotations.cds +++ b/app/single-tenant/personal-space/incidents-app/app/incidents/annotations.cds @@ -71,6 +71,24 @@ annotate service.Incidents with @( ID : 'i18nConversation', Target : 'conversation/@UI.LineItem#i18nConversation1', }, + { + $Type : 'UI.ReferenceFacet', + ID : 'AttachmentsFacet', + Label : 'Attachments', + Target: 'attachments/@UI.LineItem', + }, + { + $Type : 'UI.ReferenceFacet', + ID : 'ReferencesFacet', + Label : 'References', + Target: 'references/@UI.LineItem', + }, + { + $Type : 'UI.ReferenceFacet', + ID : 'FootnotesFacet', + Label : 'Footnotes', + Target: 'footnotes/@UI.LineItem', + }, ] ); annotate service.Incidents with @( @@ -185,6 +203,65 @@ annotate service.Incidents.conversation with @( // // Attachments Details // +annotate service.Incidents.attachments with @UI: { + HeaderInfo: { + $Type : 'UI.HeaderInfoType', + TypeName : '{i18n>Attachment}', + TypeNamePlural: '{i18n>Attachments}', + }, + LineItem : [ + {Value: type, @HTML5.CssDefaults: {width: '10%'}}, + {Value: filename, @HTML5.CssDefaults: {width: '25%'}}, + {Value: content, @HTML5.CssDefaults: {width: '0%'}}, + {Value: createdAt, @HTML5.CssDefaults: {width: '20%'}}, + {Value: createdBy, @HTML5.CssDefaults: {width: '20%'}}, + {Value: note, @HTML5.CssDefaults: {width: '25%'}}, + { + $Type : 'UI.DataFieldForActionGroup', + ID : 'TableActionGroup', + Label : 'Create', + ![@UI.Hidden]: {$edmJson: {$Eq: [ {$Path: 'IsActiveEntity'}, true ]}}, + Actions: [ + { + $Type : 'UI.DataFieldForAction', + Label : 'Link', + Action: 'ProcessorService.createLink', + } + ] + }, + { + @UI.Hidden: {$edmJson:{$If:[{$Eq:[{$Path: 'IsActiveEntity' },true]},true,{$If:[{$Ne:[{$Path:'mimeType'},'application/internet-shortcut']},true,false]}]}}, + $Type : 'UI.DataFieldForAction', + Label : 'Edit Link', + Action: 'ProcessorService.editLink', + Inline: true, + IconUrl: 'sap-icon://edit', + @HTML5.CssDefaults: {width: '4%'} + }, + ] +} +{ + url @readonly; + note @(title: '{i18n>Note}'); + filename @(title: '{i18n>Filename}'); + modifiedAt @(odata.etag: null); + content + @Core.ContentDisposition: { Filename: filename, Type: 'inline' } + @(title: '{i18n>Attachment}'); + folderId @UI.Hidden; + mimeType @UI.Hidden; + status @UI.Hidden; + repositoryId @UI.Hidden; +} + +annotate service.Incidents.attachments with { + customProperty1 @Common.ValueListWithFixedValues; +} + +//////////////////////////////////////////////////////////////////////////// +// +// References Details +// annotate service.Incidents.references with @UI: { HeaderInfo: { $Type : 'UI.HeaderInfoType', @@ -239,3 +316,62 @@ annotate service.Incidents.references with @UI: { annotate service.Incidents.references with { customProperty1 @Common.ValueListWithFixedValues; } + +//////////////////////////////////////////////////////////////////////////// +// +// Footnotes Details +// +annotate service.Incidents.footnotes with @UI: { + HeaderInfo: { + $Type : 'UI.HeaderInfoType', + TypeName : '{i18n>Footnote}', + TypeNamePlural: '{i18n>Footnotes}', + }, + LineItem : [ + {Value: type, @HTML5.CssDefaults: {width: '10%'}}, + {Value: filename, @HTML5.CssDefaults: {width: '25%'}}, + {Value: content, @HTML5.CssDefaults: {width: '0%'}}, + {Value: createdAt, @HTML5.CssDefaults: {width: '20%'}}, + {Value: createdBy, @HTML5.CssDefaults: {width: '20%'}}, + {Value: note, @HTML5.CssDefaults: {width: '25%'}}, + { + $Type : 'UI.DataFieldForActionGroup', + ID : 'TableActionGroup', + Label : 'Create', + ![@UI.Hidden]: {$edmJson: {$Eq: [ {$Path: 'IsActiveEntity'}, true ]}}, + Actions: [ + { + $Type : 'UI.DataFieldForAction', + Label : 'Link', + Action: 'ProcessorService.createLink', + } + ] + }, + { + @UI.Hidden: {$edmJson:{$If:[{$Eq:[{$Path: 'IsActiveEntity' },true]},true,{$If:[{$Ne:[{$Path:'mimeType'},'application/internet-shortcut']},true,false]}]}}, + $Type : 'UI.DataFieldForAction', + Label : 'Edit Link', + Action: 'ProcessorService.editLink', + Inline: true, + IconUrl: 'sap-icon://edit', + @HTML5.CssDefaults: {width: '4%'} + }, + ] +} +{ + url @readonly; + note @(title: '{i18n>Note}'); + filename @(title: '{i18n>Filename}'); + modifiedAt @(odata.etag: null); + content + @Core.ContentDisposition: { Filename: filename, Type: 'inline' } + @(title: '{i18n>Attachment}'); + folderId @UI.Hidden; + mimeType @UI.Hidden; + status @UI.Hidden; + repositoryId @UI.Hidden; +} + +annotate service.Incidents.footnotes with { + customProperty1 @Common.ValueListWithFixedValues; +} diff --git a/app/single-tenant/personal-space/incidents-app/app/incidents/webapp/manifest.json b/app/single-tenant/personal-space/incidents-app/app/incidents/webapp/manifest.json index ca1dd23f..36176963 100644 --- a/app/single-tenant/personal-space/incidents-app/app/incidents/webapp/manifest.json +++ b/app/single-tenant/personal-space/incidents-app/app/incidents/webapp/manifest.json @@ -164,12 +164,26 @@ } } }, + "attachments/@com.sap.vocabularies.UI.v1.LineItem": { + "tableSettings": { + "type": "ResponsiveTable", + "selectionMode": "Auto", + "rowPress": ".extension.ns.incidents.controller.custom.onRowPress" + } + }, "references/@com.sap.vocabularies.UI.v1.LineItem": { "tableSettings": { "type": "ResponsiveTable", "selectionMode": "Auto", "rowPress": ".extension.ns.incidents.controller.custom.onRowPress" } + }, + "footnotes/@com.sap.vocabularies.UI.v1.LineItem": { + "tableSettings": { + "type": "ResponsiveTable", + "selectionMode": "Auto", + "rowPress": ".extension.ns.incidents.controller.custom.onRowPress" + } } } } diff --git a/app/single-tenant/personal-space/incidents-app/srv/service.cds b/app/single-tenant/personal-space/incidents-app/srv/service.cds index b17fc484..8bf831d3 100644 --- a/app/single-tenant/personal-space/incidents-app/srv/service.cds +++ b/app/single-tenant/personal-space/incidents-app/srv/service.cds @@ -10,6 +10,20 @@ service ProcessorService { entity Customers @readonly as projection on my.Customers; entity Projects as projection on my.Projects; // Non-draft entity entity Projects.references as projection on my.Projects.references; + entity Incidents.attachments as projection on my.Incidents.attachments + actions { + @(Common.SideEffects : {TargetEntities: ['']},) + action createLink( + in:many $self, + @mandatory @Common.Label:'Name' name: String @UI.Placeholder: 'Enter a name for the link', + @mandatory @assert.format:'^(https?:\/\/)(([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}|localhost)(:\d{2,5})?(\/[^\s]*)?$' @Common.Label:'URL' url: String @UI.Placeholder: 'Example: https://www.example.com' + ); + action editLink( + @mandatory @assert.format:'^(https?:\/\/)(([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}|localhost)(:\d{2,5})?(\/[^\s]*)?$' + @Common.Label:'URL' url: String @UI.Placeholder: 'Example: https://www.example.com' + ); + action openAttachment() returns { value: String; }; + } entity Incidents.references as projection on my.Incidents.references actions { @(Common.SideEffects : {TargetEntities: ['']},) @@ -24,9 +38,27 @@ service ProcessorService { ); action openAttachment() returns { value: String; }; } + entity Incidents.footnotes as projection on my.Incidents.footnotes + actions { + @(Common.SideEffects : {TargetEntities: ['']},) + action createLink( + in:many $self, + @mandatory @Common.Label:'Name' name: String @UI.Placeholder: 'Enter a name for the link', + @mandatory @assert.format:'^(https?:\/\/)(([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}|localhost)(:\d{2,5})?(\/[^\s]*)?$' @Common.Label:'URL' url: String @UI.Placeholder: 'Example: https://www.example.com' + ); + action editLink( + @mandatory @assert.format:'^(https?:\/\/)(([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}|localhost)(:\d{2,5})?(\/[^\s]*)?$' + @Common.Label:'URL' url: String @UI.Placeholder: 'Example: https://www.example.com' + ); + action openAttachment() returns { value: String; }; + } } -extend my.Incidents with { references: Composition of many Attachments } +extend my.Incidents with { + attachments : Composition of many Attachments; + references : Composition of many Attachments; + footnotes : Composition of many Attachments; +} extend my.Projects with { references: Composition of many Attachments } extend Attachments with { diff --git a/jest-integration-multifacet.config.js b/jest-integration-multifacet.config.js new file mode 100644 index 00000000..2dc8c147 --- /dev/null +++ b/jest-integration-multifacet.config.js @@ -0,0 +1,7 @@ +const integrationMultifacetConfig = { + verbose: true, + testTimeout: 500000, + testMatch: ['**/test/integration/attachments-sdm-multifacet.test.js'] +} + +module.exports = integrationMultifacetConfig diff --git a/jest-integration.config.js b/jest-integration.config.js index 349dc0dc..4489fe51 100644 --- a/jest-integration.config.js +++ b/jest-integration.config.js @@ -1,6 +1,6 @@ const integrationconfig = { verbose: true, - testTimeout: 100000, + testTimeout: 500000, testMatch: ["**/test/integration/attachments-sdm.test.js"], }; diff --git a/lib/sdm.js b/lib/sdm.js index cc9cc4ea..6dac62b0 100644 --- a/lib/sdm.js +++ b/lib/sdm.js @@ -76,6 +76,7 @@ const { largFileVirusScanErr } = require("./util/messageConsts"); const { getDestinationFromServiceBinding,retrieveJwt} = require('@sap-cloud-sdk/connectivity'); +const { executeHttpRequest } = require('@sap-cloud-sdk/http-client'); const FILE_SIZE_THRESHOLD = 400 * 1024 * 1024; // 400 MB — files above this use the chunked upload path @@ -591,7 +592,115 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; ); } - async getParentId(attachments, req, upId){ + /** + * Returns true when the entity owning `attachments` has more than one + * Composition of Attachments defined in the CDS model. + * @private + */ + isMultiCompositionEntity(attachments) { + // Entity name pattern: "Srv.ParentEntity.compositionName" + const parts = attachments.name.replace(/\.drafts$/, '').split('.'); + if (parts.length < 2) return false; + const parentEntityName = parts.slice(0, -1).join('.'); + const parentEntity = cds.model.definitions[parentEntityName]; + if (!parentEntity) return false; + return this.getAttachmentCompositions(parentEntity).length > 1; + } + + /** + * Derives the composition name from the attachment entity name. + * e.g. "ProcessorService.Incidents.references" → "references" + * @private + */ + getCompositionName(attachments) { + return attachments.name.replace(/\.drafts$/, '').split('.').pop(); + } + + /** + * For multi-composition entities, creates/finds a folder named {entityId}_{compositionName} + * directly under /root/. This ensures each composition section has its own isolated folder. + * @private + */ + async getOrCreateCompositionFolder(attachments, req, upId, compositionName) { + const { repositoryId } = getConfigurations(); + const up_ = attachments.keys.up_.keys[0].$generatedFieldName; + const entityId = req.data[up_] || upId; + const composedFolderName = `${entityId}_${compositionName}`; + + const folderIds = await getFolderIdForEntity(attachments, req, repositoryId, upId); + for (const folder of folderIds) { + if (folder.folderId !== null) { + return folder.folderId; + } + } + + // Try to find the folder in SDM by path /root/{entityId}_{compositionName} + const destination = await this.getDestination(req); + const getFolderByPathURL = + this.creds.uri + + "browser/" + + repositoryId + + "/root/" + + composedFolderName + + "?cmisselector=object"; + try { + const response = await executeHttpRequest( + destination, { method: 'GET', url: getFolderByPathURL } + ); + return response.data.properties["cmis:objectId"].value; + } catch { + // Folder doesn't exist yet — create it below + } + + const originalUpValue = req.data[up_]; + req.data[up_] = composedFolderName; + const response = await createFolder( + req, + this.creds, + attachments, + composedFolderName, + destination + ); + // Restore original value + req.data[up_] = originalUpValue; + + if (response.status == 201) { + return response.data.succinctProperties["cmis:objectId"]; + } + if (response.status == 403 && response.response?.data == userDoesNotHaveRequiredScope) { + console.error('[getOrCreateCompositionFolder] User not authorized to create composition folder'); + req.reject(403, userNotAuthorisedError); + } + // If folder creation failed (e.g., 409 conflict), try to fetch it again + try { + const retryResponse = await executeHttpRequest( + destination, { method: 'GET', url: getFolderByPathURL } + ); + return retryResponse.data.properties["cmis:objectId"].value; + } catch { + console.error('[getOrCreateCompositionFolder] Failed to create or find folder', { + composedFolderName, + status: response.status || response.response?.status, + message: response.message || response.response?.data?.message + }); + req.reject(500, `Failed to create folder for composition: ${compositionName}`); + } + } + + async getParentId(attachments, req, upId) { + if (this.isMultiCompositionEntity(attachments)) { + const compositionName = this.getCompositionName(attachments); + return this.getOrCreateCompositionFolder(attachments, req, upId, compositionName); + } + return this.getOrCreateFlatFolder(attachments, req, upId); + } + + /** + * folder strategy: /{entityId} + * Used when an entity has exactly one Composition of Attachments. + * @private + */ + async getOrCreateFlatFolder(attachments, req, upId) { const { repositoryId } = getConfigurations(); const folderIds = await getFolderIdForEntity(attachments, req, repositoryId, upId); let parentId = null; @@ -748,18 +857,42 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; async addFolderToParentIdList(req, attachments) { const destination = await this.getDestination(req); - const folderId = await getFolderIdByIDAsPath( - req, - this.creds, - destination, - attachments - ); - if (folderId) { - if (!req.parentId) { - req.parentId = []; + if (this.isMultiCompositionEntity(attachments)) { + // For multi-composition entities, look for /root/{entityId}_{compositionName} + const compositionName = this.getCompositionName(attachments); + const up_ = attachments.keys.up_.keys[0].$generatedFieldName; + const idValue = up_.split("__")[1]; + const entityId = req.data[idValue]; + if (!entityId) return; + const composedFolderName = `${entityId}_${compositionName}`; + const { repositoryId } = getConfigurations(); + const getFolderByPathURL = + this.creds.uri + "browser/" + repositoryId + "/root/" + + composedFolderName + "?cmisselector=object"; + try { + const response = await executeHttpRequest( + destination, { method: 'GET', url: getFolderByPathURL } + ); + const folderId = response.data.properties["cmis:objectId"].value; + if (folderId) { + if (!req.parentId) { req.parentId = []; } + req.parentId.push(folderId); + } + } catch { + // Folder doesn't exist — nothing to delete + } + } else { + const folderId = await getFolderIdByIDAsPath( + req, + this.creds, + destination, + attachments + ); + if (folderId) { + if (!req.parentId) { req.parentId = []; } + req.parentId.push(folderId); } - req.parentId.push(folderId); } } @@ -769,40 +902,91 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; if (!baseEntity) { return; } - const attachmentCompositions = this.getAttachmentCompositions({ name: baseEntityName }); + const diffData = req.event == "DELETE" ? await req.diff() : null; for (const compositionName of attachmentCompositions) { - let draftAttachments = cds.model.definitions[baseEntityName + "." + compositionName + ".drafts"]; - if(draftAttachments) { - const attachmentsToDeleteFromDraft = await getURLsToDeleteFromDraftAttachments(req.data.ID, draftAttachments); - if (attachmentsToDeleteFromDraft?.length > 0) { - if (!req.attachmentsToDelete) { - req.attachmentsToDelete = []; - } - req.attachmentsToDelete.push(...attachmentsToDeleteFromDraft); - } - const diffData = await req.diff(); - if (req.event == "DELETE" && diffData[compositionName]?.length == attachmentsToDeleteFromDraft?.length) { - - const destination = await this.getDestination(req); - const folderId = await getFolderIdByIDAsPath( - req, - this.creds, - destination, - draftAttachments - ); - if (folderId) { - if (!req.parentId) { - req.parentId = []; - } - req.parentId.push(folderId); - } - } + const draftAttachments = this.getDraftAttachmentsEntity(baseEntityName, compositionName); + if (!draftAttachments) { + continue; + } + + const attachmentsToDeleteFromDraft = await this.collectDraftAttachmentDeletionUrls(req, draftAttachments); + if (!this.shouldCollectDraftParentFolder(req, diffData, compositionName, attachmentsToDeleteFromDraft)) { + continue; } + + const folderId = await this.resolveDraftParentFolderId(req, draftAttachments, compositionName); + this.appendParentId(req, folderId); } } + getDraftAttachmentsEntity(baseEntityName, compositionName) { + return cds.model.definitions[baseEntityName + "." + compositionName + ".drafts"]; + } + + async collectDraftAttachmentDeletionUrls(req, draftAttachments) { + const attachmentsToDeleteFromDraft = await getURLsToDeleteFromDraftAttachments(req.data.ID, draftAttachments); + if (attachmentsToDeleteFromDraft?.length > 0) { + if (!req.attachmentsToDelete) { + req.attachmentsToDelete = []; + } + req.attachmentsToDelete.push(...attachmentsToDeleteFromDraft); + } + return attachmentsToDeleteFromDraft; + } + + shouldCollectDraftParentFolder(req, diffData, compositionName, attachmentsToDeleteFromDraft) { + return req.event == "DELETE" && diffData?.[compositionName]?.length == attachmentsToDeleteFromDraft?.length; + } + + async resolveDraftParentFolderId(req, draftAttachments, compositionName) { + const destination = await this.getDestination(req); + if (this.isMultiCompositionEntity(draftAttachments)) { + return this.getComposedDraftFolderId(req, draftAttachments, compositionName, destination); + } + return getFolderIdByIDAsPath( + req, + this.creds, + destination, + draftAttachments + ); + } + + async getComposedDraftFolderId(req, draftAttachments, compositionName, destination) { + const up_ = draftAttachments.keys.up_.keys[0].$generatedFieldName; + const idValue = up_.split("__")[1]; + const entityId = req.data[idValue]; + if (!entityId) { + return null; + } + + const composedFolderName = `${entityId}_${compositionName}`; + const { repositoryId } = getConfigurations(); + const getFolderByPathURL = + this.creds.uri + "browser/" + repositoryId + "/root/" + + composedFolderName + "?cmisselector=object"; + + try { + const response = await executeHttpRequest( + destination, { method: 'GET', url: getFolderByPathURL } + ); + return response.data.properties["cmis:objectId"].value; + } catch { + return null; + } + } + + appendParentId(req, folderId) { + if (!folderId) { + return; + } + if (!req.parentId) { + req.parentId = []; + } + req.parentId.push(folderId); + } + async attachURLsToDeleteFromAttachmentsDraft(req) { let draftAttachments = cds.model.definitions[req.target.name]; if(draftAttachments) { @@ -1725,6 +1909,11 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; super.registerHandlers(srv); } + // Prevent duplicate registrations when one entity has multiple attachment compositions + this._registeredEntityHandlers = this._registeredEntityHandlers || new Set(); + this._registeredTargetHandlers = this._registeredTargetHandlers || new Set(); + this._registeredGlobalActionHandlers = this._registeredGlobalActionHandlers || false; + // Get all entities with attachments compositions Object.values(srv.entities).forEach((entity) => { for (let elementName in entity.elements) { @@ -1741,101 +1930,120 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; registerSDMHandlers(srv, entity, target) { - // Handle DELETE and UPDATE for both draft and non-draft entities - srv.before( - ["DELETE","UPDATE"], - entity, - this.attachDeletionData.bind(this) - ); + const entityKey = entity.name; + const targetKey = target.name; + + // Register entity-level handlers exactly once per parent entity + if (!this._registeredEntityHandlers.has(entityKey)) { + this._registeredEntityHandlers.add(entityKey); - // Handle DELETE for draft entities - if (entity.drafts) { + // Handle DELETE and UPDATE for both draft and non-draft entities srv.before( - ["DELETE"], - entity.drafts, - this.attachDraftDeletionData.bind(this) + ["DELETE","UPDATE"], + entity, + this.attachDeletionData.bind(this) + ); + + // Handle DELETE for draft entities + if (entity.drafts) { + srv.before( + ["DELETE"], + entity.drafts, + this.attachDraftDeletionData.bind(this) + ); + } + + // Draft-specific handlers + if (entity.drafts) { + srv.before("DELETE", entity.drafts, this.handleDraftDiscardForLinks.bind(this)); + srv.after("SAVE", entity, this.handleDraftSaveForLinks.bind(this)); + srv.before("SAVE", entity, this.draftEntityRenameHandler.bind(this)); + } else { + // Non-draft rename/update handler + srv.before("UPDATE", entity, this.nonDraftEntityRenameHandler.bind(this)); + } + + srv.after( + ["DELETE","UPDATE"], + entity.drafts ? [entity, entity.drafts] : [entity], + this.deleteAttachmentsWithKeys.bind(this) ); } - // Handle DELETE on attachment entity (draft and non-draft) - if (target.drafts) { - srv.before(["DELETE"], target.drafts, this.attachURLsToDeleteFromAttachmentsDraft.bind(this)); - } - - // Handle direct DELETE on attachment entity - srv.before( - "DELETE", - target, - this.attachNonDraftAttachmentDeletionData.bind(this) - ); - - // Draft-specific handlers - if (entity.drafts) { - srv.before("DELETE", entity.drafts, this.handleDraftDiscardForLinks.bind(this)); - srv.after("SAVE", entity, this.handleDraftSaveForLinks.bind(this)); - srv.before("SAVE", entity, this.draftEntityRenameHandler.bind(this)); - } else { - // Non-draft rename/update handler - srv.before("UPDATE", entity, this.nonDraftEntityRenameHandler.bind(this)); - } - - // Repository filtering and settings (both draft and non-draft) - const targets = target.drafts ? [target, target.drafts] : [target]; - srv.before("READ", targets, this.setRepository.bind(this)); - srv.before("READ", targets, this.filterAttachments.bind(this)); - - // Handle PUT for draft attachments - if (target.drafts) { + + // Register target-level handlers once per attachment composition entity + if (!this._registeredTargetHandlers.has(targetKey)) { + this._registeredTargetHandlers.add(targetKey); + + // Handle DELETE on attachment entity (draft and non-draft) + if (target.drafts) { + srv.before(["DELETE"], target.drafts, this.attachURLsToDeleteFromAttachmentsDraft.bind(this)); + } + + // Handle direct DELETE on attachment entity srv.before( - "PUT", - target.drafts, - this.draftAttachmentUploadHandler.bind(this) + "DELETE", + target, + this.attachNonDraftAttachmentDeletionData.bind(this) ); - } else { - // Handle PUT for non-draft attachments (content upload) + + // Repository filtering and settings (both draft and non-draft) + const targets = target.drafts ? [target, target.drafts] : [target]; + srv.before("READ", targets, this.setRepository.bind(this)); + srv.before("READ", targets, this.filterAttachments.bind(this)); + + // Handle PUT for draft attachments + if (target.drafts) { + srv.before( + "PUT", + target.drafts, + this.draftAttachmentUploadHandler.bind(this) + ); + } else { + // Handle PUT for non-draft attachments (content upload) + srv.before( + "PUT", + target, + this.nonDraftAttachmentCreateHandler.bind(this) + ); + } + + // Handle CREATE for non-draft attachments srv.before( - "PUT", + "CREATE", target, this.nonDraftAttachmentCreateHandler.bind(this) ); + + // Handle direct UPDATE/PATCH on non-draft attachment entity + srv.before( + "UPDATE", + target, + this.nonDraftAttachmentUpdateHandler.bind(this) + ); + + srv.after( + "DELETE", + target, + this.deleteAttachmentsWithKeys.bind(this) + ); } - - // Handle CREATE for non-draft attachments - srv.before( - "CREATE", - target, - this.nonDraftAttachmentCreateHandler.bind(this) - ); - // Handle direct UPDATE/PATCH on non-draft attachment entity - srv.before( - "UPDATE", - target, - this.nonDraftAttachmentUpdateHandler.bind(this) - ); - - srv.after( - ["DELETE","UPDATE"], - entity.drafts ? [entity, entity.drafts] : [entity], - this.deleteAttachmentsWithKeys.bind(this) - ); - - srv.after( - "DELETE", - target, - this.deleteAttachmentsWithKeys.bind(this) - ); - - // Handler for custom action 'openAttachment' - srv.on('openAttachment', async (req) => { - return this.openAttachment(req); - }); - // Handler for custom action 'createLink' - srv.on('createLink', async (req) => { - return this.handleCreateLinkAction(req); - }); - // Handler for custom action 'editLink' - srv.on('editLink', async (req) => { - return this.handleEditLinkAction(req); - }); + // Register service-level action handlers once + if (!this._registeredGlobalActionHandlers) { + this._registeredGlobalActionHandlers = true; + + // Handler for custom action 'openAttachment' + srv.on('openAttachment', async (req) => { + return this.openAttachment(req); + }); + // Handler for custom action 'createLink' + srv.on('createLink', async (req) => { + return this.handleCreateLinkAction(req); + }); + // Handler for custom action 'editLink' + srv.on('editLink', async (req) => { + return this.handleEditLinkAction(req); + }); + } } }; diff --git a/package.json b/package.json index 2a340a5a..7e5542e8 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,9 @@ "scripts": { "start": "cds-serve", "test": "jest --coverage --runInBand --config jest.config.js", - "integration-test": "jest --config jest-integration.config.js", + "integration-test": "jest --config jest-integration.config.js --runInBand && jest --config jest-integration-multifacet.config.js --runInBand && npm run --silent integration-test:done", + "integration-test:done": "echo \"All single-facet and multifacet integration tests passed\"", + "integration-test-multifacet": "jest --config jest-integration-multifacet.config.js", "integration-test-non-draft": "jest --config jest-integration-non-draft.config.js", "integration-test-subscription": "jest --config jest-integration-subscription.config.js", "integration-test-repo-specific": "jest --config jest-integration-repo-specific.config.js", @@ -50,11 +52,6 @@ "lint": "npx eslint --fix . --no-cache" }, "cds": { - "server": { - "body_parser": { - "limit": "2gb" - } - }, "requires": { "sdm": { "vcap": { @@ -63,7 +60,7 @@ }, "kinds": { "sdm": { - "impl": "@cap-js/sdm/lib/sdm" + "impl": "./lib/sdm" } }, "[development]": { diff --git a/test/integration/api.js b/test/integration/api.js index 3641328c..f17bfbfd 100644 --- a/test/integration/api.js +++ b/test/integration/api.js @@ -8,6 +8,10 @@ class Api { this.config = config } + getActiveFacet() { + return process.env.SDM_TEST_FACET || 'references'; + } + async createEntityDraft(appUrl, serviceName, entityName){ let response; let incidentID; @@ -211,11 +215,12 @@ class Api { async createAttachment(appUrl, serviceName, entityName, incidentID, postData, file){ let response; + const activeFacet = this.getActiveFacet(); postData['filename'] = file.filename; try{ response = await axios.post( - `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=false)/references`, + `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=false)/${activeFacet}`, postData, this.config ) @@ -226,7 +231,7 @@ class Api { // responseStatus.attachmentID.push(response.data.ID) try{ await axios.put( - `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=false)/references(ID=${response.data.ID},IsActiveEntity=false)/content`, + `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=false)/${activeFacet}(ID=${response.data.ID},IsActiveEntity=false)/content`, formDataPut, this.config @@ -267,10 +272,11 @@ class Api { } async readAttachment(appUrl, serviceName, entityName, incidentID, attachment){ + const activeFacet = this.getActiveFacet(); try{ let response; response = await axios.get( - `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=true)/references(up__ID=${incidentID},ID=${attachment},IsActiveEntity=true)/content`, + `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=true)/${activeFacet}(up__ID=${incidentID},ID=${attachment},IsActiveEntity=true)/content`, this.config ); if (response.status === 200 && response.data) { @@ -293,10 +299,11 @@ class Api { async fetchMetadata(appUrl, serviceName, entityName, incidentID, attachment) { let response; + const activeFacet = this.getActiveFacet(); try { response = await axios.get( - `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=true)/references(up__ID=${incidentID},ID=${attachment},IsActiveEntity=true)`, + `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=true)/${activeFacet}(up__ID=${incidentID},ID=${attachment},IsActiveEntity=true)`, this.config ); @@ -321,10 +328,11 @@ class Api { async fetchMetadataDraft(appUrl, serviceName, entityName, incidentID, attachment) { let response; + const activeFacet = this.getActiveFacet(); try { response = await axios.get( - `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=false)/references(up__ID=${incidentID},ID=${attachment},IsActiveEntity=false)`, + `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=false)/${activeFacet}(up__ID=${incidentID},ID=${attachment},IsActiveEntity=false)`, this.config ); @@ -349,9 +357,10 @@ class Api { async updateAttachment(appUrl, serviceName, entityName, incidentID, updateData, attachment){ let response; + const activeFacet = this.getActiveFacet(); try{ response = await axios.patch( - `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=false)/references(ID=${attachment},IsActiveEntity=false)`, + `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=false)/${activeFacet}(ID=${attachment},IsActiveEntity=false)`, updateData, this.config ); @@ -375,9 +384,10 @@ class Api { async deleteAttachment(appUrl, serviceName, incidentID, attachment,entityName){ let response; + const activeFacet = this.getActiveFacet(); try{ response = await axios.delete( - `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=false)/references(ID=${attachment},IsActiveEntity=false)`, + `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=false)/${activeFacet}(ID=${attachment},IsActiveEntity=false)`, this.config ); if (response.status === 204) { @@ -400,6 +410,7 @@ class Api { async createLink(appUrl, serviceName, entityName, incidentID, srvpath, name, url) { let response; + const activeFacet = this.getActiveFacet(); try { const linkData = { name: name, @@ -407,7 +418,7 @@ class Api { }; response = await axios.post( - `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=false)/references/${srvpath}.createLink`, + `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=false)/${activeFacet}/${srvpath}.createLink`, linkData, this.config ) @@ -437,13 +448,14 @@ class Api { async editLink(appUrl, serviceName, entityName, incidentID, linkID, srvpath, url) { let response; + const activeFacet = this.getActiveFacet(); try { const linkData = { url: url }; // Construct OData editLink URL - const requestUrl = `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=false)/references(up__ID=${incidentID},ID=${linkID},IsActiveEntity=false)/${srvpath}.editLink`; + const requestUrl = `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=false)/${activeFacet}(up__ID=${incidentID},ID=${linkID},IsActiveEntity=false)/${srvpath}.editLink`; response = await axios.post(requestUrl, linkData, this.config); @@ -472,9 +484,10 @@ class Api { async getAttachmentsList(appUrl, serviceName, entityName, incidentID) { let response; + const activeFacet = this.getActiveFacet(); try { response = await axios.get( - `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=false)/references`, + `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=false)/${activeFacet}`, this.config ); if (response.status === 200 && response.data && response.data.value) { @@ -498,9 +511,10 @@ class Api { async getActiveAttachmentsList(appUrl, serviceName, entityName, incidentID) { let response; + const activeFacet = this.getActiveFacet(); try { response = await axios.get( - `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=true)/references`, + `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=true)/${activeFacet}`, this.config ); if (response.status === 200 && response.data && response.data.value) { @@ -524,9 +538,10 @@ class Api { async openAttachment(appUrl, serviceName, entityName, incidentID, srvpath, attachment) { let response; + const activeFacet = this.getActiveFacet(); try { response = await axios.post( - `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=false)/references(ID=${attachment},IsActiveEntity=false)/${srvpath}.openAttachment`, + `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=false)/${activeFacet}(ID=${attachment},IsActiveEntity=false)/${srvpath}.openAttachment`, {}, this.config ) @@ -551,9 +566,10 @@ class Api { async openAttachmentSaved(appUrl, serviceName, entityName, incidentID, srvpath, attachment) { let response; + const activeFacet = this.getActiveFacet(); try { response = await axios.post( - `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=true)/references(ID=${attachment},IsActiveEntity=true)/${srvpath}.openAttachment`, + `https://${appUrl}/odata/v4/${serviceName}/${entityName}(ID=${incidentID},IsActiveEntity=true)/${activeFacet}(ID=${attachment},IsActiveEntity=true)/${srvpath}.openAttachment`, {}, this.config ) diff --git a/test/integration/attachments-sdm-multifacet.test.js b/test/integration/attachments-sdm-multifacet.test.js new file mode 100644 index 00000000..466447e2 --- /dev/null +++ b/test/integration/attachments-sdm-multifacet.test.js @@ -0,0 +1,2374 @@ +const multifacets = ['attachments', 'references', 'footnotes'] +const facetStates = new Map() +let baselineState + +const snapshotFacetState = () => ({ + token, + noSDMRoleToken, + api, + apiNoSDMRole, + incidentID, + appUrl, + serviceName, + entityName, + srvpath, + attachments: [...attachments], + incidentToDelete, + incidentIDCustomProperty1, + incidentIDCustomProperty2, + attachment1, + attachment2, + attachment3, + editLinkIncidentID, + editLinkAttachmentID +}) + +const restoreFacetState = (state) => { + token = state.token + noSDMRoleToken = state.noSDMRoleToken + api = state.api + apiNoSDMRole = state.apiNoSDMRole + incidentID = state.incidentID + appUrl = state.appUrl + serviceName = state.serviceName + entityName = state.entityName + srvpath = state.srvpath + attachments = [...state.attachments] + incidentToDelete = state.incidentToDelete + incidentIDCustomProperty1 = state.incidentIDCustomProperty1 + incidentIDCustomProperty2 = state.incidentIDCustomProperty2 + attachment1 = state.attachment1 + attachment2 = state.attachment2 + attachment3 = state.attachment3 + editLinkIncidentID = state.editLinkIncidentID + editLinkAttachmentID = state.editLinkAttachmentID +} + +const itForAllFacets = (name, fn, timeout) => it(name, async () => { + const previousFacet = process.env.SDM_TEST_FACET + for (const facet of multifacets) { + process.env.SDM_TEST_FACET = facet + if (!facetStates.has(facet)) { + facetStates.set( + facet, + baselineState + ? { ...baselineState, attachments: [...baselineState.attachments] } + : snapshotFacetState() + ) + } + restoreFacetState(facetStates.get(facet)) + await fn() + facetStates.set(facet, snapshotFacetState()) + } + if (previousFacet === undefined) delete process.env.SDM_TEST_FACET + else process.env.SDM_TEST_FACET = previousFacet +}, timeout) + +const axios = require('axios'); +const credentials = require('./credentials.json'); +const Api = require('./api'); +const expect = require('@sap/cds/lib/test/expect'); +const tenancyModel = process.env.TENANCY_MODEL || 'single'; +const tenant = process.env.TENANT; +const tokenFlow = process.env.TOKEN_FLOW || 'namedUser'; + +const { userNotAuthorisedErrorEditLink } = require("../../lib/util/messageConsts"); + +let token; +let noSDMRoleToken; +let api; +let apiNoSDMRole; +let incidentID; +let appUrl; +let serviceName = 'processor'; +let entityName = 'Incidents'; +let srvpath = 'ProcessorService'; +let attachments = [] +let incidentToDelete; +let incidentIDCustomProperty1; +let incidentIDCustomProperty2; +let attachment1; +let attachment2; +let attachment3; +let editLinkIncidentID; +let editLinkAttachmentID; + +beforeAll(async () => { + let clientId; + let clientSecret; + let authUrl; + + if (tenancyModel === 'multi') { + appUrl = credentials.appUrlMT; + clientId = credentials.clientIDMT; + clientSecret = credentials.clientSecretMT; + + if (tenant === 'SDM-DEV-CONSUMER-EU12') { + console.log('Running integration tests | SDM-DEV-CONSUMER-EU12 tenant'); + authUrl = credentials.authUrlMTSDC; + } else if (tenant === 'SDMGoogleWorkspaceConsumer') { + console.log('Running integration tests | SDMGoogleWorkspaceConsumer tenant'); + authUrl = credentials.authUrlMTGWC; + } + } else { + console.log('Running integration tests | Single tenant Scenario | Multi facet'); + appUrl = credentials.appUrl; + clientId = credentials.clientID; + clientSecret = credentials.clientSecret; + authUrl = credentials.authUrl; + } + + if (tokenFlow === 'technicalUser') { + console.log('Technical user token flow'); + try { + const authRes = await axios.post( + `${authUrl}/oauth/token?grant_type=client_credentials`, + null, + { + auth: { + username: clientId, + password: clientSecret + } + } + ); + token = authRes.data.access_token; + } catch (error) { + console.error("Failed to generate technical user Token:", error.message); + throw error; + } + } else if (tokenFlow === 'namedUser') { + console.log('Named user token flow'); + try { + const authRes = await axios.get( + `${authUrl}/oauth/token?grant_type=password&username=${credentials.username}&password=${credentials.password}`, + { + auth: { + username: clientId, + password: clientSecret + } + } + ); + token = authRes.data.access_token; + } catch (error) { + console.error("Failed to generate Token:", error.message); + throw error; + } + + try { + const authResNoSDMRole = await axios.get( + `${authUrl}/oauth/token?grant_type=password&username=${credentials.noSDMRoleUsername}&password=${credentials.noSDMRoleUserPassword}`, + { + auth: { + username: clientId, + password: clientSecret + } + } + ); + noSDMRoleToken = authResNoSDMRole.data.access_token; + } catch (error) { + console.error("Failed to generate No-SDM-Role Token:", error.message); + throw error; + } + } else { + throw new Error(`Invalid TOKEN_FLOW specified: ${tokenFlow}. Expected 'namedUser' or 'technicalUser'.`); + } + + const config = { + headers: { 'Authorization': "Bearer " + token } + }; + api = new Api(config); + + baselineState = snapshotFacetState(); + for (const facet of multifacets) { + facetStates.set(facet, { ...baselineState, attachments: [...baselineState.attachments] }); + } +}); + +describe('Attachments Integration Tests --CREATE', () => { + //When an attachment is created, the function also attempts to read it from drafts. If this attempt fails, an error is thrown and the attachment is not created. + itForAllFacets('should create an entity and check if it has been created', async () => { + let response = await api.createEntityDraft(appUrl, serviceName, entityName); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + incidentID = response.incidentID; + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, incidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + }); + + itForAllFacets('should upload a single attachment and check if it has been uploaded with content --pdf', async () => { + const file = + { + filename: "sample.pdf", + filepath: "./test/integration/sample.pdf" + } + + const postData = { + up__ID: incidentID, + mimeType: "application/pdf", + createdAt: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(), + createdBy: "test@test.com", + modifiedBy: "test@test.com" + }; + + let response = await api.editEntity(appUrl, serviceName, entityName, incidentID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + response = await api.createAttachment(appUrl, serviceName, entityName, incidentID, postData, file); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + attachments.push(response.ID); + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, incidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + }); + + itForAllFacets('should upload a single attachment and check if it has been uploaded with content --exe', async () => { + //A separate test case is formed for exe as the postData will vary, and unlike pdf it can't be viewed in browser + const file = + { + filename: "sample.exe", + filepath: "./test/integration/sample.exe" + } + + const postData = { + up__ID: incidentID, + mimeType: "application/exe", + createdAt: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(), + createdBy: "test@test.com", + modifiedBy: "test@test.com" + }; + + let response = await api.editEntity(appUrl, serviceName, entityName, incidentID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + response = await api.createAttachment(appUrl, serviceName, entityName, incidentID, postData, file); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + attachments.push(response.ID); + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, incidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + }); + + itForAllFacets('should not upload an attachment when user does not have SDM role', async () => { + if (tokenFlow !== 'technicalUser') { + const file = + { + filename: "sample3.pdf", + filepath: "./test/integration/sample3.pdf" + } + + const postData = { + up__ID: incidentID, + mimeType: "application/pdf", + createdAt: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(), + createdBy: "test@test.com", + modifiedBy: "test@test.com" + }; + const config = { + headers: {'Authorization': "Bearer " + noSDMRoleToken} + }; + apiNoSDMRole = new Api(config); + let response = await apiNoSDMRole.editEntity(appUrl, serviceName, entityName, incidentID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + response = await apiNoSDMRole.createAttachment(appUrl, serviceName, entityName, incidentID, postData, file); + expect(response.message).toBe("Create attachment API call (put) failed : Request failed with status code 403"); + response = await apiNoSDMRole.saveEntityDraft(appUrl, serviceName, entityName, srvpath, incidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + } +}); + + itForAllFacets('should not allow upload of duplicate files in same entity', async () => { + const file = + { + filename: "sample.pdf", + filepath: "./test/integration/sample.pdf" + } + + const postData = { + up__ID: incidentID, + mimeType: "application/pdf", + createdAt: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(), + createdBy: "test@test.com", + modifiedBy: "test@test.com" + }; + + let response = await api.editEntity(appUrl, serviceName, entityName, incidentID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + response = await api.createAttachment(appUrl, serviceName, entityName, incidentID, postData, file); + if (response.status == "OK") { + throw new Error("Error : " + response.message) + } + response = await api.deleteAttachment(appUrl, serviceName, incidentID, response.ID,entityName); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, incidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + }); + + itForAllFacets('should not allow upload of duplicate files in same entity --draft', async () => { + const files = [ + { + filename: "sample2.pdf", + filepath: "./test/integration/sample2.pdf" + }, + { + filename: "sample2.pdf", + filepath: "./test/integration/sample2.pdf" + } + ] + + const postData = { + up__ID: incidentID, + mimeType: "application/pdf", + createdAt: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(), + createdBy: "test@test.com", + modifiedBy: "test@test.com" + }; + + let response = await api.editEntity(appUrl, serviceName, entityName, incidentID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + response = await api.createAttachment(appUrl, serviceName, entityName, incidentID, postData, files[0]); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + response = await api.createAttachment(appUrl, serviceName, entityName, incidentID, postData, files[1]); + if (response.status == "OK") { + throw new Error("Error : " + "Duplicate attachment was created") + } + response = await api.deleteAttachment(appUrl, serviceName, incidentID, response.ID,entityName); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, incidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + }); + + itForAllFacets('should allow upload of a duplicate file in a different entity', async () => { + let response = await api.createEntityDraft(appUrl, serviceName, entityName, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + incidentToDelete = response.incidentID; + + const file = + { + filename: "sample.pdf", + filepath: "./test/integration/sample.pdf" + } + + const postData = { + up__ID: response.incidentID, + mimeType: "application/pdf", + createdAt: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(), + createdBy: "test@test.com", + modifiedBy: "test@test.com" + }; + + response = await api.createAttachment(appUrl, serviceName, entityName, incidentToDelete, postData, file); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, incidentToDelete); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + }); + + itForAllFacets('should fail to upload duplicate attachment and verify error from DI', async () => { + let diErrorEntityID; + + let response = await api.createEntityDraft(appUrl, serviceName, entityName, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + diErrorEntityID = response.incidentID; + + const file = { + filename: "sample.pdf", + filepath: "./test/integration/sample.pdf" + }; + const postData = { + up__ID: diErrorEntityID, + mimeType: "application/pdf", + createdAt: new Date().toISOString(), + createdBy: "test@test.com", + modifiedBy: "test@test.com" + }; + + // First upload should succeed + response = await api.createAttachment(appUrl, serviceName, entityName, diErrorEntityID, postData, file); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, diErrorEntityID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + // Enter draft mode again + response = await api.editEntity(appUrl, serviceName, entityName, diErrorEntityID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + // Second upload of same file — DI should throw error (file already exists in repository) + response = await api.createAttachment(appUrl, serviceName, entityName, diErrorEntityID, postData, file); + expect(response.status).toBe("FAILED"); + expect(response.message).toBeDefined(); + + // Clean up the failed draft attachment (POST succeeded, PUT to DI failed) + if (response.ID) { + await api.deleteAttachment(appUrl, serviceName, diErrorEntityID, response.ID, entityName); + } + + // Verify draft still has only 1 attachment (the original) + const draftList = await api.getAttachmentsList(appUrl, serviceName, entityName, diErrorEntityID); + expect(draftList.status).toBe("OK"); + expect(draftList.attachments.length).toBe(1); + + // Third attempt — same DI error + response = await api.createAttachment(appUrl, serviceName, entityName, diErrorEntityID, postData, file); + expect(response.status).toBe("FAILED"); + expect(response.message).toBeDefined(); + + if (response.ID) { + await api.deleteAttachment(appUrl, serviceName, diErrorEntityID, response.ID, entityName); + } + + // Still only 1 attachment in draft + const draftList2 = await api.getAttachmentsList(appUrl, serviceName, entityName, diErrorEntityID); + expect(draftList2.status).toBe("OK"); + expect(draftList2.attachments.length).toBe(1); + + // Cleanup + response = await api.deleteEntity(appUrl, serviceName, entityName, diErrorEntityID); + if (response.status !== "OK") { + console.warn("Cleanup failed for diErrorEntityID:", response.message); + } + }); +}); + +describe('Attachments Integration Tests --READ', () => { + itForAllFacets('should read the created attachment', async () => { + if (tokenFlow !== 'technicalUser') { + //This test case also reads files not supported by browser (.exe) + for (let i = 0; i < attachments.length; i++) { + const response = await api.readAttachment(appUrl, serviceName, entityName, incidentID, attachments[i]); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + } + const config = { + headers: {'Authorization': "Bearer " + noSDMRoleToken} + }; + apiNoSDMRole = new Api(config); + const response = await apiNoSDMRole.readAttachment(appUrl, serviceName, entityName, incidentID, attachments[0]); + console.log(response.message); + expect(response.message).toBe("Read attachment API call failed : Request failed with status code 403"); + } + + }); + + itForAllFacets('should not read an attachment that doesnt exist', async () => { + const invalidAttachment = 'invalid-attachment-id'; + const response = await api.readAttachment(appUrl, serviceName, entityName, incidentID, invalidAttachment); + if (response.status == "OK") { + throw new Error("Error : " + response.message) + } + }); + + itForAllFacets('should handle gracefully when attachment has been deleted from backend repository', async () => { + const { deleteDocumentFromCmis } = require('./utills/cmis-document-helper'); + let readBackendDeletedEntityID; + + let response = await api.createEntityDraft(appUrl, serviceName, entityName, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + readBackendDeletedEntityID = response.incidentID; + + const file = { + filename: "sample.pdf", + filepath: "./test/integration/sample.pdf" + }; + const postData = { + up__ID: readBackendDeletedEntityID, + mimeType: "application/pdf", + createdAt: new Date().toISOString(), + createdBy: "test@test.com", + modifiedBy: "test@test.com" + }; + + response = await api.createAttachment(appUrl, serviceName, entityName, readBackendDeletedEntityID, postData, file); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + const attachmentID = response.ID; + + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, readBackendDeletedEntityID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + // Verify attachment is readable before backend deletion + response = await api.readAttachment(appUrl, serviceName, entityName, readBackendDeletedEntityID, attachmentID); + expect(response.status).toBe("OK"); + + // Delete attachment directly from CMIS backend + await deleteDocumentFromCmis(readBackendDeletedEntityID, "sample.pdf"); + + // Try to read the attachment through app — may fail gracefully after backend deletion + response = await api.readAttachment(appUrl, serviceName, entityName, readBackendDeletedEntityID, attachmentID); + console.log("Read response after backend deletion:", response.message || response.status); + + // App metadata should still show the attachment (app state not synced with backend) + response = await api.fetchMetadata(appUrl, serviceName, entityName, readBackendDeletedEntityID, attachmentID); + expect(response.status).toBe("OK"); + + // Cleanup + response = await api.deleteEntity(appUrl, serviceName, entityName, readBackendDeletedEntityID); + if (response.status !== "OK") { + console.warn("Cleanup failed for readBackendDeletedEntityID:", response.message); + } + }); +}); + +describe('Attachments Integration Tests --UPDATE', () => { + itForAllFacets('should update valid properties of attachments during create of entity', async () => { + let response = await api.createEntityDraft(appUrl, serviceName, entityName, srvpath); + + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + incidentIDCustomProperty1 = response.incidentID; + const postData = { + up__ID: incidentIDCustomProperty1, + mimeType: "application/pdf", + createdAt: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(), + createdBy: "test@test.com", + modifiedBy: "test@test.com" + } + + const file = + { + filename: "sample.pdf", + filepath: "./test/integration/sample.pdf" + } + + response = await api.createAttachment(appUrl, serviceName, entityName, incidentIDCustomProperty1, postData, file); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + attachment1 = response.ID; + let updateData = { + filename : "sample_updated.pdf", + customProperty1_code: "test", + customProperty2: 100, + customProperty5: "2025-03-24T05:20:07Z", + customProperty6: true + } + response = await api.updateAttachment(appUrl, serviceName, entityName, incidentIDCustomProperty1, updateData, attachment1); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, incidentIDCustomProperty1); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + response = await api.fetchMetadata(appUrl, serviceName, entityName, incidentIDCustomProperty1, attachment1); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + expect(response.data.filename).toBe("sample_updated.pdf"); + expect(response.data.customProperty1_code).toBe("test"); + expect(response.data.customProperty2).toBe(100); + expect(response.data.customProperty5).toBe("2025-03-24T05:20:07Z"); + expect(response.data.customProperty6).toBe(true); + + // CMIS backend validation + const { getCmisProperty, getCmisPropertyOrNull } = require('./utills/cmis-document-helper'); + const cmisName = await getCmisProperty(incidentIDCustomProperty1, "sample_updated.pdf", "cmis:name"); + expect(cmisName).toBe("sample_updated.pdf"); + const cmisString = await getCmisPropertyOrNull(incidentIDCustomProperty1, "sample_updated.pdf", "Working:DocumentInfoRecordString"); + expect(cmisString).toBe("test"); + const cmisInt = await getCmisPropertyOrNull(incidentIDCustomProperty1, "sample_updated.pdf", "Working:DocumentInfoRecordInt"); + expect(cmisInt).toBe("100"); + const cmisBool = await getCmisPropertyOrNull(incidentIDCustomProperty1, "sample_updated.pdf", "Working:DocumentInfoRecordBoolean"); + expect(cmisBool).toBe("true"); + const cmisDate = await getCmisPropertyOrNull(incidentIDCustomProperty1, "sample_updated.pdf", "Working:DocumentInfoRecordDate"); + expect(cmisDate).not.toBe(null); + }); + + itForAllFacets('should update valid properties of attachments after save of entity', async () => { + let response = await api.editEntity(appUrl, serviceName, entityName, incidentIDCustomProperty1, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + let updateData = { + filename : "sample_updated1.pdf", + customProperty1_code: "test123", + customProperty2: 123, + customProperty5: "2026-03-24T05:20:07Z", + customProperty6: false + } + response = await api.updateAttachment(appUrl, serviceName, entityName, incidentIDCustomProperty1, updateData, attachment1); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, incidentIDCustomProperty1); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + response = await api.fetchMetadata(appUrl, serviceName, entityName, incidentIDCustomProperty1, attachment1); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + expect(response.data.filename).toBe("sample_updated1.pdf"); + expect(response.data.customProperty1_code).toBe("test123"); + expect(response.data.customProperty2).toBe(123); + expect(response.data.customProperty5).toBe("2026-03-24T05:20:07Z"); + expect(response.data.customProperty6).toBe(false); + + // CMIS backend validation + const { getCmisProperty, getCmisPropertyOrNull } = require('./utills/cmis-document-helper'); + const cmisName = await getCmisProperty(incidentIDCustomProperty1, "sample_updated1.pdf", "cmis:name"); + expect(cmisName).toBe("sample_updated1.pdf"); + const cmisString = await getCmisPropertyOrNull(incidentIDCustomProperty1, "sample_updated1.pdf", "Working:DocumentInfoRecordString"); + expect(cmisString).toBe("test123"); + const cmisInt = await getCmisPropertyOrNull(incidentIDCustomProperty1, "sample_updated1.pdf", "Working:DocumentInfoRecordInt"); + expect(cmisInt).toBe("123"); + const cmisBool = await getCmisPropertyOrNull(incidentIDCustomProperty1, "sample_updated1.pdf", "Working:DocumentInfoRecordBoolean"); + expect(cmisBool).toBe("false"); + const cmisDate = await getCmisPropertyOrNull(incidentIDCustomProperty1, "sample_updated1.pdf", "Working:DocumentInfoRecordDate"); + expect(cmisDate).not.toBe(null); + }); + + itForAllFacets('should not update invalid properties of attachments and should update valid properties during create of entity', async () => { + let response = await api.createEntityDraft(appUrl, serviceName, entityName, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + incidentIDCustomProperty2 = response.incidentID; + + const postData = { + up__ID: incidentIDCustomProperty2, + mimeType: "application/pdf", + createdAt: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(), + createdBy: "test@test.com", + modifiedBy: "test@test.com" + } + + const files = [ + { + filename: "sample.pdf", + filepath: "./test/integration/sample.pdf" + }, + { + filename: "sample2.pdf", + filepath: "./test/integration/sample2.pdf" + } + ] + + + response = await api.createAttachment(appUrl, serviceName, entityName, incidentIDCustomProperty2, postData, files[0]); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + attachment2 = response.ID; + response = await api.createAttachment(appUrl, serviceName, entityName, incidentIDCustomProperty2, postData, files[1]); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + attachment3 = response.ID; + + let updateDataInvalid = { + filename : "sample_updated_invalid.pdf", + customProperty1_code: "test", + customProperty2: 100, + customProperty5: "2025-03-24T05:20:07Z", + customProperty6: true, + customProperty3: "invalid value", + customProperty4: "invalid value" + + } + let updateDataValid = { + filename : "sample_updated_valid.pdf", + customProperty1_code: "test", + customProperty2: 100, + customProperty5: "2025-03-24T05:20:07Z", + customProperty6: true + } + response = await api.updateAttachment(appUrl, serviceName, entityName, incidentIDCustomProperty2, updateDataInvalid, attachment2); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + response = await api.updateAttachment(appUrl, serviceName, entityName, incidentIDCustomProperty2, updateDataValid, attachment3); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, incidentIDCustomProperty2); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + if (response.sapMessages && response.sapMessages.length > 0) { + expect(response.sapMessages).toBe('[{"code":"500","message":"The following secondary properties are not supported:\\n\\n\\t\\u2022 id1\\n\\t\\u2022 id2\\n\\nPlease contact your administrator for assistance with any necessary adjustments.","numericSeverity":3}]'); + } + response = await api.fetchMetadata(appUrl, serviceName, entityName, incidentIDCustomProperty2, attachment2); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + expect(response.data.filename).toBe("sample.pdf"); + expect(response.data.customProperty1_code).toBe(null); + expect(response.data.customProperty2).toBe(null); + expect(response.data.customProperty5).toBe(null); + expect(response.data.customProperty6).toBe(null); + response = await api.fetchMetadata(appUrl, serviceName, entityName, incidentIDCustomProperty2, attachment3); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + expect(response.data.filename).toBe("sample_updated_valid.pdf"); + expect(response.data.customProperty1_code).toBe("test"); + expect(response.data.customProperty2).toBe(100); + expect(response.data.customProperty5).toBe("2025-03-24T05:20:07Z"); + expect(response.data.customProperty6).toBe(true); + + // CMIS backend validation + const { getCmisPropertyOrNull } = require('./utills/cmis-document-helper'); + // Attachment with invalid props — CMIS backend should have no secondary properties set + const cmisStringInvalid = await getCmisPropertyOrNull(incidentIDCustomProperty2, "sample.pdf", "Working:DocumentInfoRecordString"); + expect(cmisStringInvalid).toBe(null); + // Attachment with valid props — CMIS backend should reflect exact values + const cmisStringValid = await getCmisPropertyOrNull(incidentIDCustomProperty2, "sample_updated_valid.pdf", "Working:DocumentInfoRecordString"); + expect(cmisStringValid).toBe("test"); + const cmisIntValid = await getCmisPropertyOrNull(incidentIDCustomProperty2, "sample_updated_valid.pdf", "Working:DocumentInfoRecordInt"); + expect(cmisIntValid).toBe("100"); + const cmisBoolValid = await getCmisPropertyOrNull(incidentIDCustomProperty2, "sample_updated_valid.pdf", "Working:DocumentInfoRecordBoolean"); + expect(cmisBoolValid).toBe("true"); + }); + + itForAllFacets('should not update invalid properties of attachments and should update valid properties after save of entity', async () => { + let response = await api.editEntity(appUrl, serviceName, entityName, incidentIDCustomProperty2, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + let updateDataInvalid = { + filename : "sample_updated_invalid.pdf", + customProperty1_code: "test123", + customProperty2: 123, + customProperty5: "2026-03-24T05:20:07Z", + customProperty6: false, + customProperty3: "invalid value", + customProperty4: "invalid value" + } + let updateDataValid = { + filename : "sample_updated.pdf", + customProperty1_code: "test123", + customProperty2: 123, + customProperty5: "2026-03-24T05:20:07Z", + customProperty6: false + } + response = await api.updateAttachment(appUrl, serviceName, entityName, incidentIDCustomProperty2, updateDataInvalid, attachment2); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + response = await api.updateAttachment(appUrl, serviceName, entityName, incidentIDCustomProperty2, updateDataValid, attachment3); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, incidentIDCustomProperty2); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + if (response.sapMessages && response.sapMessages.length > 0) { + expect(response.sapMessages).toBe('[{"code":"500","message":"The following secondary properties are not supported:\\n\\n\\t\\u2022 id1\\n\\t\\u2022 id2\\n\\nPlease contact your administrator for assistance with any necessary adjustments.","numericSeverity":3}]'); + } + response = await api.fetchMetadata(appUrl, serviceName, entityName, incidentIDCustomProperty2, attachment2); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + expect(response.data.filename).toBe("sample.pdf"); + expect(response.data.customProperty1_code).toBe(null); + expect(response.data.customProperty2).toBe(null); + expect(response.data.customProperty5).toBe(null); + expect(response.data.customProperty6).toBe(null); + response = await api.fetchMetadata(appUrl, serviceName, entityName, incidentIDCustomProperty2, attachment3); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + expect(response.data.filename).toBe("sample_updated.pdf"); + expect(response.data.customProperty1_code).toBe("test123"); + expect(response.data.customProperty2).toBe(123); + expect(response.data.customProperty5).toBe("2026-03-24T05:20:07Z"); + expect(response.data.customProperty6).toBe(false); + + // CMIS backend validation + const { getCmisPropertyOrNull } = require('./utills/cmis-document-helper'); + // Attachment with invalid props — CMIS backend should still have no secondary properties + const cmisStringInvalid = await getCmisPropertyOrNull(incidentIDCustomProperty2, "sample.pdf", "Working:DocumentInfoRecordString"); + expect(cmisStringInvalid).toBe(null); + // Attachment with valid props — CMIS backend should reflect updated exact values + const cmisStringValid = await getCmisPropertyOrNull(incidentIDCustomProperty2, "sample_updated.pdf", "Working:DocumentInfoRecordString"); + expect(cmisStringValid).toBe("test123"); + const cmisIntValid = await getCmisPropertyOrNull(incidentIDCustomProperty2, "sample_updated.pdf", "Working:DocumentInfoRecordInt"); + expect(cmisIntValid).toBe("123"); + const cmisBoolValid = await getCmisPropertyOrNull(incidentIDCustomProperty2, "sample_updated.pdf", "Working:DocumentInfoRecordBoolean"); + expect(cmisBoolValid).toBe("false"); + }); + + itForAllFacets('should fail to rename attachment to filename that already exists in backend', async () => { + const { createDocumentInCmis } = require('./utills/cmis-document-helper'); + let renameBackendConflictEntityID; + + let response = await api.createEntityDraft(appUrl, serviceName, entityName, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + renameBackendConflictEntityID = response.incidentID; + + const file = { + filename: "sample.pdf", + filepath: "./test/integration/sample.pdf" + }; + const postData = { + up__ID: renameBackendConflictEntityID, + mimeType: "application/pdf", + createdAt: new Date().toISOString(), + createdBy: "test@test.com", + modifiedBy: "test@test.com" + }; + + response = await api.createAttachment(appUrl, serviceName, entityName, renameBackendConflictEntityID, postData, file); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + const attachmentID = response.ID; + + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, renameBackendConflictEntityID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + // Inject a file with conflicting name directly into CMIS backend + await createDocumentInCmis("backend-file.pdf", "./test/integration/sample.pdf", renameBackendConflictEntityID); + + // Enter draft mode + response = await api.editEntity(appUrl, serviceName, entityName, renameBackendConflictEntityID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + // Rename attachment to the conflicting name in draft — rename succeeds in draft + const renameData = { filename: "backend-file.pdf" }; + response = await api.updateAttachment(appUrl, serviceName, entityName, renameBackendConflictEntityID, renameData, attachmentID); + expect(response.status).toBe("OK"); + + // Save — should fail because DI already has "backend-file.pdf" + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, renameBackendConflictEntityID, true); + expect(response.status).toBe("FAILED"); + expect(response.message).toContain("already exists"); + + // Cleanup + response = await api.deleteEntity(appUrl, serviceName, entityName, renameBackendConflictEntityID); + if (response.status !== "OK") { + console.warn("Cleanup failed for renameBackendConflictEntityID:", response.message); + } + }); +}); + +describe('Attachments Integration Tests --LINK', () => { + itForAllFacets('should successfully create a link and verify it is openable after multiple edits', async () => { + let linkIncidentID; + let linkAttachmentID; + let secondLinkAttachmentID; + + const linkName = 'GitHub'; + const linkUrl = 'https://github.com'; + const secondLinkName = 'Stack Overflow'; + const secondLinkUrl = 'https://stackoverflow.com'; + + let response = await api.createEntityDraft(appUrl, serviceName, entityName); + console.log("Response in Link "+response.status); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + linkIncidentID = response.incidentID; + + response = await api.createLink(appUrl, serviceName, entityName, linkIncidentID, srvpath, linkName, linkUrl); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Get the attachments list to find the created link's ID + response = await api.getAttachmentsList(appUrl, serviceName, entityName, linkIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Find the link attachment by name and URL + const linkAttachment = response.attachments.find(att => + att.filename === linkName && att.linkUrl === linkUrl + ); + if (!linkAttachment) { + throw new Error("Error : Created link not found in attachments list") + } + linkAttachmentID = linkAttachment.ID; + + // Save the draft after creating the first link + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, linkIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Fetch metadata to verify the first link exists and has correct properties + response = await api.fetchMetadata(appUrl, serviceName, entityName, linkIncidentID, linkAttachmentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + expect(response.data.filename).toBe(linkName); + expect(response.data.linkUrl).toBe(linkUrl); + + // Second edit: Test that we can create another link after editing the same entity again + response = await api.editEntity(appUrl, serviceName, entityName, linkIncidentID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Create a second link in the same entity + response = await api.createLink(appUrl, serviceName, entityName, linkIncidentID, srvpath, secondLinkName, secondLinkUrl); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Get the updated attachments list + response = await api.getAttachmentsList(appUrl, serviceName, entityName, linkIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Find the second link attachment + const secondLinkAttachment = response.attachments.find(att => + att.filename === secondLinkName && att.linkUrl === secondLinkUrl + ); + if (!secondLinkAttachment) { + throw new Error("Error : Second link not found in attachments list") + } + secondLinkAttachmentID = secondLinkAttachment.ID; + + // Verify we now have 2 links total + expect(response.attachments.length).toBe(2); + + // Save the draft after creating the second link + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, linkIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Third edit: Test that both links are accessible in different states + response = await api.editEntity(appUrl, serviceName, entityName, linkIncidentID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Test that the first link is openable from draft state (IsActiveEntity=false) + response = await api.openAttachment(appUrl, serviceName, entityName, linkIncidentID, srvpath, linkAttachmentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Save the draft to test opening second link from saved state + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, linkIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Test that the second link is openable from saved state (IsActiveEntity=true) + response = await api.openAttachmentSaved(appUrl, serviceName, entityName, linkIncidentID, srvpath, secondLinkAttachmentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + +// Test that the second link throws authorization error for use without sdm role +//here use the noSDMRoleUsername +const config = { + headers: { 'Authorization': "Bearer " + noSDMRoleToken } + }; + if (tokenFlow !== 'technicalUser') { + apiNoSDMRole = new Api(config); + response = await apiNoSDMRole.openAttachmentSaved(appUrl, serviceName, entityName, linkIncidentID, srvpath, secondLinkAttachmentID); + expect(response.message).toBe("Open attachment saved API call failed : Request failed with status code 403"); + } + // Verify metadata for both links after multiple edits + response = await api.fetchMetadata(appUrl, serviceName, entityName, linkIncidentID, linkAttachmentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + expect(response.data.filename).toBe(linkName); + expect(response.data.linkUrl).toBe(linkUrl); + + response = await api.fetchMetadata(appUrl, serviceName, entityName, linkIncidentID, secondLinkAttachmentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + expect(response.data.filename).toBe(secondLinkName); + expect(response.data.linkUrl).toBe(secondLinkUrl); + + // Cleanup - delete the entity created for link testing + response = await api.deleteEntity(appUrl, serviceName, entityName, linkIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + }); + + itForAllFacets('should allow creation of a link with the same name and URL in a different entity', async () => { + // Define the same link parameters as the previous test + const linkName = 'GitHub'; + const linkUrl = 'https://github.com'; + let secondLinkIncidentID; + let secondLinkAttachmentID; + + let response = await api.createEntityDraft(appUrl, serviceName, entityName); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + secondLinkIncidentID = response.incidentID; + + // Create the link with the same name and URL as the previous test + response = await api.createLink(appUrl, serviceName, entityName, secondLinkIncidentID, srvpath, linkName, linkUrl); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Get the attachments list to find the created link's ID + response = await api.getAttachmentsList(appUrl, serviceName, entityName, secondLinkIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Find the link attachment by name and URL + const linkAttachment = response.attachments.find(att => + att.filename === linkName && att.linkUrl === linkUrl + ); + if (!linkAttachment) { + throw new Error("Error : Created link not found in attachments list") + } + secondLinkAttachmentID = linkAttachment.ID; + + // Save the draft after creating the link + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, secondLinkIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Verify the link was created with correct properties + response = await api.fetchMetadata(appUrl, serviceName, entityName, secondLinkIncidentID, secondLinkAttachmentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + expect(response.data.filename).toBe(linkName); + expect(response.data.linkUrl).toBe(linkUrl); + + // Cleanup - delete the second entity + response = await api.deleteEntity(appUrl, serviceName, entityName, secondLinkIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + }); + + itForAllFacets('should fail to create links with invalid parameters and prevent duplicate names', async () => { + let testIncidentID; + let validLinkAttachmentID; + + let response = await api.createEntityDraft(appUrl, serviceName, entityName); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + testIncidentID = response.incidentID; + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, testIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + response = await api.editEntity(appUrl, serviceName, entityName, testIncidentID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Test 1: URL without proper protocol + try { + const invalidUrl = 'example.com'; + response = await api.createLink(appUrl, serviceName, entityName, testIncidentID, srvpath, 'ValidName', invalidUrl); + if (response.status === "OK") { + throw new Error("Error : Link creation should have failed for invalid URL format") + } + expect(response.message).toBe("Enter a value matching the pattern ^(https?:\\/\\/)(([a-zA-Z0-9\\-]+\\.)+[a-zA-Z]{2,}|localhost)(:\\d{2,5})?(\\/[^\\s]*)?$."); + } catch (error) { + expect(error.message).toBe("Enter a value matching the pattern ^(https?:\\/\\/)(([a-zA-Z0-9\\-]+\\.)+[a-zA-Z]{2,}|localhost)(:\\d{2,5})?(\\/[^\\s]*)?$."); + } + + // Test 2: Link name has restricted characters (/) + try { + const nameWithSlash = 't/es'; + response = await api.createLink(appUrl, serviceName, entityName, testIncidentID, srvpath, nameWithSlash, 'https://example1.com'); + if (response.status === "OK") { + throw new Error("Error : Link creation should have failed for name containing '/' character") + } + expect(response.message.trim()).toBe("Link could not be created. The following name(s) contain unsupported characters (/, \\). \n\n\t• t/es\n\nRename the file(s) and try again."); + } catch (error) { + expect(error.message.trim()).toBe("Link could not be created. The following name(s) contain unsupported characters (/, \\). \n\n\t• t/es\n\nRename the file(s) and try again."); + } + + // Test 4: Empty link name + try { + response = await api.createLink(appUrl, serviceName, entityName, testIncidentID, srvpath, '', 'https://example3.com'); + if (response.status === "OK") { + throw new Error("Error : Link creation should have failed for empty name") + } + // Server should return an error for empty name + expect(response.status).toBe("ERROR"); + expect(response.message).toBeDefined(); + } catch (error) { + // If it throws an exception, that's also acceptable validation + expect(error.message).toBeDefined(); + } + + // Test 5: Empty link URL + try { + response = await api.createLink(appUrl, serviceName, entityName, testIncidentID, srvpath, 'ValidName', ''); + if (response.status === "OK") { + throw new Error("Error : Link creation should have failed for empty URL") + } + // Server should return an error for empty URL + expect(response.status).toBe("ERROR"); + expect(response.message).toBeDefined(); + } catch (error) { + // If it throws an exception, that's also acceptable validation + expect(error.message).toBeDefined(); + } + + // Test 6: Both name and URL empty + try { + response = await api.createLink(appUrl, serviceName, entityName, testIncidentID, srvpath, '', ''); + if (response.status === "OK") { + throw new Error("Error : Link creation should have failed for both empty name and URL") + } + // Server should return an error for both empty fields + expect(response.status).toBe("ERROR"); + expect(response.message).toBeDefined(); + } catch (error) { + // If it throws an exception, that's also acceptable validation + expect(error.message).toBeDefined(); + } + + // Create a valid link first to test duplicate scenario + const validLinkName = 'TestLink'; + const validLinkUrl = 'https://test.example.com'; + response = await api.createLink(appUrl, serviceName, entityName, testIncidentID, srvpath, validLinkName, validLinkUrl); + if (response.status !== "OK") { + throw new Error("Error : Failed to create valid link for duplicate test: " + response.message) + } + + // Get the attachments list to verify the valid link was created + response = await api.getAttachmentsList(appUrl, serviceName, entityName, testIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Find the valid link attachment + const validLinkAttachment = response.attachments.find(att => + att.filename === validLinkName && att.linkUrl === validLinkUrl + ); + if (!validLinkAttachment) { + throw new Error("Error : Valid link not found in attachments list") + } + validLinkAttachmentID = validLinkAttachment.ID; + + // Test 7: Duplicate link name (same name, different URL) + try { + response = await api.createLink(appUrl, serviceName, entityName, testIncidentID, srvpath, validLinkName, 'https://different.example.com'); + if (response.status === "OK") { + throw new Error("Error : Link creation should have failed for duplicate name") + } + // Server should return an error for duplicate name + expect(response.status).toBe("ERROR"); + expect(response.message).toBeDefined(); + } catch (error) { + // If it throws an exception, that's also acceptable validation + expect(error.message).toBeDefined(); + } + + // Save the draft to persist the valid link + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, testIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Verify the valid link still exists and has correct properties + response = await api.fetchMetadata(appUrl, serviceName, entityName, testIncidentID, validLinkAttachmentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + expect(response.data.filename).toBe(validLinkName); + expect(response.data.linkUrl).toBe(validLinkUrl); + + // Cleanup - delete the test entity + response = await api.deleteEntity(appUrl, serviceName, entityName, testIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + }); + + itForAllFacets('should successfully delete a link using deleteAttachment API', async () => { + let deleteTestIncidentID; + let deleteTestLinkID; + + let response = await api.createEntityDraft(appUrl, serviceName, entityName); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + deleteTestIncidentID = response.incidentID; + + // Create a link to delete + const linkName = 'LinkToDelete'; + const linkUrl = 'https://delete-test.com'; + response = await api.createLink(appUrl, serviceName, entityName, deleteTestIncidentID, srvpath, linkName, linkUrl); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Get the link ID + response = await api.getAttachmentsList(appUrl, serviceName, entityName, deleteTestIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + const linkAttachment = response.attachments.find(att => + att.filename === linkName && att.linkUrl === linkUrl + ); + if (!linkAttachment) { + throw new Error("Error : Created link not found in attachments list") + } + deleteTestLinkID = linkAttachment.ID; + + // Save the draft to persist the link + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, deleteTestIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Edit entity again to delete the link + response = await api.editEntity(appUrl, serviceName, entityName, deleteTestIncidentID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Delete the link using deleteAttachment API + response = await api.deleteAttachment(appUrl, serviceName, deleteTestIncidentID, deleteTestLinkID, entityName); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Save the draft after deletion + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, deleteTestIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Verify the link was deleted by trying to fetch its metadata (should fail) + try { + response = await api.fetchMetadata(appUrl, serviceName, entityName, deleteTestIncidentID, deleteTestLinkID); + if (response.status === "OK") { + throw new Error("Error : Link should have been deleted but metadata was still found") + } + } catch (error) { + // Expected - link should not exist anymore + expect(error).toBeDefined(); + } + + // Cleanup - delete the test entity + response = await api.deleteEntity(appUrl, serviceName, entityName, deleteTestIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + }); + + itForAllFacets('should successfully rename a link using updateAttachment API', async () => { + let renameSuccessIncidentID; + let renameSuccessLinkID; + + let response = await api.createEntityDraft(appUrl, serviceName, entityName); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + renameSuccessIncidentID = response.incidentID; + + // Create a link to rename + const originalName = 'OriginalSuccessName'; + const linkUrl = 'https://rename-success-test.com'; + response = await api.createLink(appUrl, serviceName, entityName, renameSuccessIncidentID, srvpath, originalName, linkUrl); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Get the link ID + response = await api.getAttachmentsList(appUrl, serviceName, entityName, renameSuccessIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + const linkAttachment = response.attachments.find(att => + att.filename === originalName && att.linkUrl === linkUrl + ); + if (!linkAttachment) { + throw new Error("Error : Created link not found in attachments list") + } + renameSuccessLinkID = linkAttachment.ID; + + // Save the draft to persist the link + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, renameSuccessIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Edit entity again to rename the link + response = await api.editEntity(appUrl, serviceName, entityName, renameSuccessIncidentID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Rename the link using updateAttachment API + const newName = 'RenamedSuccessName'; + const updateData = { + filename: newName + }; + response = await api.updateAttachment(appUrl, serviceName, entityName, renameSuccessIncidentID, updateData, renameSuccessLinkID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Save the draft after renaming - this should succeed + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, renameSuccessIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Verify the link was renamed by fetching its metadata + response = await api.fetchMetadata(appUrl, serviceName, entityName, renameSuccessIncidentID, renameSuccessLinkID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + expect(response.data.filename).toBe(newName); + expect(response.data.linkUrl).toBe(linkUrl); // URL should remain unchanged + + // Cleanup - delete the test entity + response = await api.deleteEntity(appUrl, serviceName, entityName, renameSuccessIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + }); + + itForAllFacets('should fail to rename link with restricted characters', async () => { + let renameRestrictedIncidentID; + let renameRestrictedLinkID; + + let response = await api.createEntityDraft(appUrl, serviceName, entityName); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + renameRestrictedIncidentID = response.incidentID; + + // Create a link to rename + const originalName = 'OriginalRestrictedName'; + const linkUrl = 'https://rename-restricted-test.com'; + response = await api.createLink(appUrl, serviceName, entityName, renameRestrictedIncidentID, srvpath, originalName, linkUrl); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Get the link ID + response = await api.getAttachmentsList(appUrl, serviceName, entityName, renameRestrictedIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + const linkAttachment = response.attachments.find(att => + att.filename === originalName && att.linkUrl === linkUrl + ); + if (!linkAttachment) { + throw new Error("Error : Created link not found in attachments list") + } + renameRestrictedLinkID = linkAttachment.ID; + + // Save the draft to persist the link + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, renameRestrictedIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Edit entity again to rename the link + response = await api.editEntity(appUrl, serviceName, entityName, renameRestrictedIncidentID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Try to rename the link with restricted characters + const invalidName = 'Invalid/Name'; + const updateData = { + filename: invalidName + }; + response = await api.updateAttachment(appUrl, serviceName, entityName, renameRestrictedIncidentID, updateData, renameRestrictedLinkID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Save the draft after renaming - this should fail with sap-messages + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, renameRestrictedIncidentID, true); + expect(response.status).toBe("FAILED"); + expect(response.message.trim()).toBe("Update unsuccessful. The following filename(s) contain unsupported characters (/, \\). \n\n\t• Invalid/Name\n\nRename the file(s) and try again."); + + // Verify the link was NOT renamed by fetching its metadata + response = await api.fetchMetadata(appUrl, serviceName, entityName, renameRestrictedIncidentID, renameRestrictedLinkID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + expect(response.data.filename).toBe(originalName); // Should still have original name + + // Cleanup - delete the test entity (may fail if entity is in invalid state) + response = await api.deleteEntity(appUrl, serviceName, entityName, renameRestrictedIncidentID); + if (response.status !== "OK") { + // If delete fails, try to edit and then delete to clean up invalid state + try { + response = await api.editEntity(appUrl, serviceName, entityName, renameRestrictedIncidentID, srvpath); + if (response.status === "OK") { + await api.deleteEntity(appUrl, serviceName, entityName, renameRestrictedIncidentID); + } + } catch { + // If cleanup still fails, log but don't fail the test + console.warn("Cleanup failed for restricted test entity:", renameRestrictedIncidentID); + } + } + }); + + itForAllFacets('should fail to rename link with duplicate name', async () => { + let renameDuplicateIncidentID; + let renameDuplicateLink2ID; + + let response = await api.createEntityDraft(appUrl, serviceName, entityName); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + renameDuplicateIncidentID = response.incidentID; + + // Create first link + const firstName = 'FirstLink'; + const firstUrl = 'https://first-link.com'; + response = await api.createLink(appUrl, serviceName, entityName, renameDuplicateIncidentID, srvpath, firstName, firstUrl); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Create second link + const secondName = 'SecondLink'; + const secondUrl = 'https://second-link.com'; + response = await api.createLink(appUrl, serviceName, entityName, renameDuplicateIncidentID, srvpath, secondName, secondUrl); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Get both link IDs + response = await api.getAttachmentsList(appUrl, serviceName, entityName, renameDuplicateIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + const firstLink = response.attachments.find(att => + att.filename === firstName && att.linkUrl === firstUrl + ); + const secondLink = response.attachments.find(att => + att.filename === secondName && att.linkUrl === secondUrl + ); + + if (!firstLink || !secondLink) { + throw new Error("Error : Created links not found in attachments list") + } + renameDuplicateLink2ID = secondLink.ID; + + // Save the draft to persist both links + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, renameDuplicateIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Edit entity again to rename the second link + response = await api.editEntity(appUrl, serviceName, entityName, renameDuplicateIncidentID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Try to rename the second link to have the same name as the first link + const updateData = { + filename: firstName // This should create a duplicate + }; + response = await api.updateAttachment(appUrl, serviceName, entityName, renameDuplicateIncidentID, updateData, renameDuplicateLink2ID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Save the draft after renaming - this should fail with sap-messages + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, renameDuplicateIncidentID, true); + expect(response.status).toBe("FAILED"); + expect(response.message.trim()).toBe("The file(s) FirstLink have been added multiple times. Please rename and try again."); + + // Fix the duplicate name issue for proper cleanup - rename back to original name + const fixUpdateData = { + filename: secondName + "_fixed" // Use a different name to resolve conflict + }; + response = await api.updateAttachment(appUrl, serviceName, entityName, renameDuplicateIncidentID, fixUpdateData, renameDuplicateLink2ID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Save with the fixed name to restore entity to valid state + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, renameDuplicateIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Cleanup - delete the test entity + response = await api.deleteEntity(appUrl, serviceName, entityName, renameDuplicateIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + }); + + itForAllFacets('should successfully edit an existing link with valid URL using editLink API', async () => { + let response = await api.createEntityDraft(appUrl, serviceName, entityName); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + editLinkIncidentID = response.incidentID; + + const originalName = 'OriginalLink'; + const originalUrl = 'https://original.com'; + response = await api.createLink(appUrl, serviceName, entityName, editLinkIncidentID, srvpath, originalName, originalUrl); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + response = await api.getAttachmentsList(appUrl, serviceName, entityName, editLinkIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + const linkAttachment = response.attachments.find(att => + att.filename === originalName && att.linkUrl === originalUrl + ); + if (!linkAttachment) { + throw new Error("Error : Created link not found in attachments list") + } + editLinkAttachmentID = linkAttachment.ID; + + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, editLinkIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + response = await api.editEntity(appUrl, serviceName, entityName, editLinkIncidentID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + const updatedUrl = 'https://updated-valid.com'; + response = await api.editLink(appUrl, serviceName, entityName, editLinkIncidentID, editLinkAttachmentID, srvpath, updatedUrl); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, editLinkIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + response = await api.fetchMetadata(appUrl, serviceName, entityName, editLinkIncidentID, editLinkAttachmentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + expect(response.data.filename).toBe(originalName); + expect(response.data.linkUrl).toBe(updatedUrl); + + response = await api.openAttachmentSaved(appUrl, serviceName, entityName, editLinkIncidentID, srvpath, editLinkAttachmentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + }); + + itForAllFacets('should validate URL format when using editLink API', async () => { + let invalidEditIncidentID; + let invalidEditLinkID; + + let response = await api.createEntityDraft(appUrl, serviceName, entityName); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + invalidEditIncidentID = response.incidentID; + + const originalName = 'TestEditLink'; + const originalUrl = 'https://original-test.com'; + response = await api.createLink(appUrl, serviceName, entityName, invalidEditIncidentID, srvpath, originalName, originalUrl); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + response = await api.getAttachmentsList(appUrl, serviceName, entityName, invalidEditIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + const linkAttachment = response.attachments.find(att => + att.filename === originalName && att.linkUrl === originalUrl + ); + if (!linkAttachment) { + throw new Error("Error : Created link not found in attachments list") + } + invalidEditLinkID = linkAttachment.ID; + + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, invalidEditIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + response = await api.editEntity(appUrl, serviceName, entityName, invalidEditIncidentID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + const invalidUrl = 'invalid-url-format'; + response = await api.editLink(appUrl, serviceName, entityName, invalidEditIncidentID, invalidEditLinkID, srvpath, invalidUrl); + + if (response.status !== "OK" && expect(response.message).toContain("Enter a value matching the pattern")) { + // Try to rename invalid link + response = await api.editLink(appUrl, serviceName, entityName, invalidEditIncidentID, invalidEditLinkID, srvpath, originalUrl); + if (response.status !== "OK") { + throw new Error(`Error in renaming of Invalid to Valid link: ${response.message}`); + } + // Save entity draft + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, invalidEditIncidentID); + + if (response.status === "OK") { + // Delete entity + response = await api.deleteEntity(appUrl, serviceName, entityName, invalidEditIncidentID); + if (response.status !== "OK") { + throw new Error(`Error: ${response.message}`); + } + } + } + }); + + itForAllFacets('should validate URL requirement when using editLink API', async () => { + let emptyUrlEditIncidentID; + let emptyUrlEditLinkID; + + let response = await api.createEntityDraft(appUrl, serviceName, entityName); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + emptyUrlEditIncidentID = response.incidentID; + + const originalName = 'TestEmptyUrlEdit'; + const originalUrl = 'https://original-empty-test.com'; + response = await api.createLink(appUrl, serviceName, entityName, emptyUrlEditIncidentID, srvpath, originalName, originalUrl); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + response = await api.getAttachmentsList(appUrl, serviceName, entityName, emptyUrlEditIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + const linkAttachment = response.attachments.find(att => + att.filename === originalName && att.linkUrl === originalUrl + ); + if (!linkAttachment) { + throw new Error("Error : Created link not found in attachments list") + } + emptyUrlEditLinkID = linkAttachment.ID; + + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, emptyUrlEditIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + response = await api.editEntity(appUrl, serviceName, entityName, emptyUrlEditIncidentID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + const emptyUrl = ''; + response = await api.editLink(appUrl, serviceName, entityName, emptyUrlEditIncidentID, emptyUrlEditLinkID, srvpath, emptyUrl); + + if (response.status !== "OK" && expect(response.message).toContain("Multiple errors occurred, see details below.")) { + // Try to rename invalid link + response = await api.editLink(appUrl, serviceName, entityName, emptyUrlEditIncidentID, emptyUrlEditLinkID, srvpath, originalUrl); + if (response.status !== "OK") { + throw new Error(`Error in renaming of Invalid to Valid link: ${response.message}`); + } + // Save entity draft + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, emptyUrlEditIncidentID); + if (response.status === "OK") { + // Delete entity + response = await api.deleteEntity(appUrl, serviceName, entityName, emptyUrlEditIncidentID); + if (response.status !== "OK") { + throw new Error(`Error: ${response.message}`); + } + } + } + }); + + itForAllFacets('should discard draft edited link and revert to original URL', async () => { + let discardTestIncidentID; + let discardTestLinkID; + + // Create entity + let response = await api.createEntityDraft(appUrl, serviceName, entityName); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + discardTestIncidentID = response.incidentID; + + // Create link type attachment (original URL) + const originalName = 'DiscardTestLink'; + const originalUrl = 'https://abc.com'; + response = await api.createLink(appUrl, serviceName, entityName, discardTestIncidentID, srvpath, originalName, originalUrl); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Get the link ID + response = await api.getAttachmentsList(appUrl, serviceName, entityName, discardTestIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + const linkAttachment = response.attachments.find(att => + att.filename === originalName && att.linkUrl === originalUrl + ); + if (!linkAttachment) { + throw new Error("Error : Created link not found in attachments list") + } + discardTestLinkID = linkAttachment.ID; + + // Save entity + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, discardTestIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Edit same entity + response = await api.editEntity(appUrl, serviceName, entityName, discardTestIncidentID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Edit same link type attachment (new URL) + const updatedUrl = 'https://xyz.com'; + response = await api.editLink(appUrl, serviceName, entityName, discardTestIncidentID, discardTestLinkID, srvpath, updatedUrl); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Discard draft + response = await api.discardDraft(appUrl, serviceName, entityName, discardTestIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Read attachment - should revert back to original URL (https://abc.com) + response = await api.fetchMetadata(appUrl, serviceName, entityName, discardTestIncidentID, discardTestLinkID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Verify the link reverted back to the original URL + expect(response.data.filename).toBe(originalName); + expect(response.data.linkUrl).toBe(originalUrl); // Should be https://abc.com, not https://xyz.com + + // Cleanup - delete the test entity + response = await api.deleteEntity(appUrl, serviceName, entityName, discardTestIncidentID); + if (response.status !== "OK") { + // Log cleanup failure but don't fail the test since the main functionality passed + console.warn("Cleanup failed for discardTestIncidentID:", response.message); + } + }); + + itForAllFacets('should not allow editing a link without SDM role', async () => { + if (tokenFlow !== 'technicalUser') { + // Enter draft mode with no-SDM-role user (reusing editLinkIncidentID from previous test) + const config = { + headers: {'Authorization': "Bearer " + noSDMRoleToken} + }; + apiNoSDMRole = new Api(config); + let response = await apiNoSDMRole.editEntity(appUrl, serviceName, entityName, editLinkIncidentID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Try to edit the link with valid URL using no-SDM-role user + const updatedUrl = 'https://updated-norole.com'; + response = await apiNoSDMRole.editLink(appUrl, serviceName, entityName, editLinkIncidentID, editLinkAttachmentID, srvpath, updatedUrl); + expect(response.status).toBe("FAILED"); + expect(response.message).toBe(userNotAuthorisedErrorEditLink); + + // Save entity draft with no-SDM-role user to exit draft mode + response = await apiNoSDMRole.saveEntityDraft(appUrl, serviceName, entityName, srvpath, editLinkIncidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + + // Cleanup - delete entity with authorized user + response = await api.deleteEntity(appUrl, serviceName, entityName, editLinkIncidentID); + if (response.status !== "OK") { + console.warn("Cleanup failed for editLinkIncidentID:", response.message); + } + } + }); + + itForAllFacets('should verify createdBy field on link attachment matches authenticated user', async () => { + let linkCreatedByEntityID; + + let response = await api.createEntityDraft(appUrl, serviceName, entityName, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + linkCreatedByEntityID = response.incidentID; + + response = await api.createLink(appUrl, serviceName, entityName, linkCreatedByEntityID, srvpath, "testLink", "https://www.example.com"); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, linkCreatedByEntityID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + // Get link ID + response = await api.getActiveAttachmentsList(appUrl, serviceName, entityName, linkCreatedByEntityID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + expect(response.attachments.length).toBeGreaterThan(0); + const linkID = response.attachments[0].ID; + + // Fetch metadata and verify createdBy/modifiedBy + response = await api.fetchMetadata(appUrl, serviceName, entityName, linkCreatedByEntityID, linkID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + expect(response.data.createdBy).toBeTruthy(); + expect(response.data.modifiedBy).toBeTruthy(); + // When using named user token, createdBy should match the authenticated username + if (credentials.username) { + expect(response.data.createdBy).toBe(credentials.username); + } + + // Cleanup + response = await api.deleteEntity(appUrl, serviceName, entityName, linkCreatedByEntityID); + if (response.status !== "OK") { + console.warn("Cleanup failed for linkCreatedByEntityID:", response.message); + } + }); + + itForAllFacets('should delete link not present in repository and remove from UI', async () => { + const { deleteDocumentFromCmis } = require('./utills/cmis-document-helper'); + let linkNotInRepoEntityID; + + let response = await api.createEntityDraft(appUrl, serviceName, entityName, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + linkNotInRepoEntityID = response.incidentID; + + response = await api.createLink(appUrl, serviceName, entityName, linkNotInRepoEntityID, srvpath, "linkToDelete", "https://www.example.com/delete-test"); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, linkNotInRepoEntityID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + // Get link ID + response = await api.getActiveAttachmentsList(appUrl, serviceName, entityName, linkNotInRepoEntityID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + expect(response.attachments.length).toBeGreaterThan(0); + const linkID = response.attachments[0].ID; + + // Delete link from CMIS backend directly + await deleteDocumentFromCmis(linkNotInRepoEntityID, "linkToDelete"); + + // Enter draft mode + response = await api.editEntity(appUrl, serviceName, entityName, linkNotInRepoEntityID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + // Delete through app — should succeed even though link is gone from CMIS + response = await api.deleteAttachment(appUrl, serviceName, linkNotInRepoEntityID, linkID, entityName); + expect(response.status).toBe("OK"); + + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, linkNotInRepoEntityID); + expect(response.status).toBe("OK"); + + // Verify no attachments remain + response = await api.getActiveAttachmentsList(appUrl, serviceName, entityName, linkNotInRepoEntityID); + expect(response.status).toBe("OK"); + expect(response.attachments.length).toBe(0); + + // Cleanup + response = await api.deleteEntity(appUrl, serviceName, entityName, linkNotInRepoEntityID); + if (response.status !== "OK") { + console.warn("Cleanup failed for linkNotInRepoEntityID:", response.message); + } + }); + + itForAllFacets('should fail to rename link to filename that already exists in backend', async () => { + const { createDocumentInCmis } = require('./utills/cmis-document-helper'); + let linkBackendConflictEntityID; + + let response = await api.createEntityDraft(appUrl, serviceName, entityName, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + linkBackendConflictEntityID = response.incidentID; + + response = await api.createLink(appUrl, serviceName, entityName, linkBackendConflictEntityID, srvpath, "originalLink", "https://www.example.com/original"); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, linkBackendConflictEntityID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + // Get link ID + response = await api.getActiveAttachmentsList(appUrl, serviceName, entityName, linkBackendConflictEntityID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + const linkID = response.attachments[0].ID; + + // Inject a file with conflicting name directly into CMIS backend + await createDocumentInCmis("backendLink", "./test/integration/sample.pdf", linkBackendConflictEntityID); + + // Enter draft mode + response = await api.editEntity(appUrl, serviceName, entityName, linkBackendConflictEntityID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + // Rename link to the conflicting name in draft — rename succeeds in draft + const renameData = { filename: "backendLink" }; + response = await api.updateAttachment(appUrl, serviceName, entityName, linkBackendConflictEntityID, renameData, linkID); + expect(response.status).toBe("OK"); + + // Save — should fail because DI already has "backendLink" + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, linkBackendConflictEntityID, true); + expect(response.status).toBe("FAILED"); + expect(response.message).toContain("already exists"); + + // Cleanup + response = await api.deleteEntity(appUrl, serviceName, entityName, linkBackendConflictEntityID); + if (response.status !== "OK") { + console.warn("Cleanup failed for linkBackendConflictEntityID:", response.message); + } + }); + + itForAllFacets('should fail to rename link with whitespace-only name', async () => { + let linkWhitespaceEntityID; + + let response = await api.createEntityDraft(appUrl, serviceName, entityName, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + linkWhitespaceEntityID = response.incidentID; + + response = await api.createLink(appUrl, serviceName, entityName, linkWhitespaceEntityID, srvpath, "linkToRename", "https://www.example.com/rename-test"); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, linkWhitespaceEntityID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + // Get link ID + response = await api.getActiveAttachmentsList(appUrl, serviceName, entityName, linkWhitespaceEntityID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + const linkID = response.attachments[0].ID; + + // Enter draft mode + response = await api.editEntity(appUrl, serviceName, entityName, linkWhitespaceEntityID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + // Rename to whitespace-only name in draft — rename succeeds in draft + const renameData = { filename: " " }; + response = await api.updateAttachment(appUrl, serviceName, entityName, linkWhitespaceEntityID, renameData, linkID); + expect(response.status).toBe("OK"); + + // Save — should fail because filename cannot be empty/whitespace + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, linkWhitespaceEntityID, true); + expect(response.status).toBe("FAILED"); + const msg = response.message.toLowerCase(); + expect( + msg.includes("cannot be empty") || msg.includes("could not be updated") || msg.includes("whitespace") + ).toBe(true); + + // Cleanup + response = await api.deleteEntity(appUrl, serviceName, entityName, linkWhitespaceEntityID); + if (response.status !== "OK") { + console.warn("Cleanup failed for linkWhitespaceEntityID:", response.message); + } + }); +}); + +describe('Attachments Integration Tests --CMIS METADATA', () => { + itForAllFacets('should verify SDM createdBy field matches the authenticated user', async () => { + // Create a fresh entity with an attachment to verify createdBy + let response = await api.createEntityDraft(appUrl, serviceName, entityName); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + const metadataEntityID = response.incidentID; + + const file = { + filename: "metadata-test.pdf", + filepath: "./test/integration/sample.pdf" + }; + + const postData = { + up__ID: metadataEntityID, + mimeType: "application/pdf", + createdAt: new Date().toISOString(), + createdBy: "test@test.com", + modifiedBy: "test@test.com" + }; + + response = await api.createAttachment(appUrl, serviceName, entityName, metadataEntityID, postData, file); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, metadataEntityID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + // Verify cmis:createdBy using CMIS helper + const { getCmisProperty } = require('./utills/cmis-document-helper'); + const createdBy = await getCmisProperty(metadataEntityID, "metadata-test.pdf", "cmis:createdBy"); + expect(createdBy).toBeTruthy(); + expect(createdBy).toBe(credentials.username); + console.log(`SDM createdBy field verified: ${createdBy}`); + + // Cleanup + response = await api.deleteEntity(appUrl, serviceName, entityName, metadataEntityID); + if (response.status !== "OK") { + console.warn("Cleanup failed:", response.message); + } + }); +}); + +describe('Attachments Integration Tests --DELETE', () => { + itForAllFacets('should delete the attachments of an entity', async () => { + let response = await api.editEntity(appUrl, serviceName, entityName, incidentID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + response = await api.deleteAttachment(appUrl, serviceName, incidentID, attachments[0],entityName); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, incidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + }); + + itForAllFacets('should delete an entity and all its attachments', async () => { + let response = await api.deleteEntity(appUrl, serviceName, entityName, incidentToDelete); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + response = await api.deleteEntity(appUrl, serviceName, entityName, incidentIDCustomProperty1); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + response = await api.deleteEntity(appUrl, serviceName, entityName, incidentIDCustomProperty2); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + }); + + itForAllFacets('should delete an entity after all its attachments have been deleted', async () => { + const response = await api.deleteEntity(appUrl, serviceName, entityName, incidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + }); + itForAllFacets('should create an entity, edit and delete it without attachments', async () => { + + let response = await api.createEntityDraft(appUrl, serviceName, entityName); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + incidentID = response.incidentID; + + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, incidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + let editresponse = await api.editEntity(appUrl, serviceName, entityName, incidentID, srvpath); + if (editresponse.status !== "OK") { + throw new Error("Error : " + editresponse.message) + } + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, incidentID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message) + } + let deleteresponse = await api.deleteEntity(appUrl, serviceName, entityName, incidentID); + if (deleteresponse.status !== "OK") { + throw new Error("Error : " + deleteresponse.message) + } + }); + + itForAllFacets('should delete attachment not present in repository and remove from UI', async () => { + const { deleteDocumentFromCmis } = require('./utills/cmis-document-helper'); + let delNotInRepoEntityID; + + let response = await api.createEntityDraft(appUrl, serviceName, entityName, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + delNotInRepoEntityID = response.incidentID; + + const file = { + filename: "sample.pdf", + filepath: "./test/integration/sample.pdf" + }; + const postData = { + up__ID: delNotInRepoEntityID, + mimeType: "application/pdf", + createdAt: new Date().toISOString(), + createdBy: "test@test.com", + modifiedBy: "test@test.com" + }; + + response = await api.createAttachment(appUrl, serviceName, entityName, delNotInRepoEntityID, postData, file); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + const attachmentID = response.ID; + + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, delNotInRepoEntityID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + // Delete the attachment from CMIS backend directly + await deleteDocumentFromCmis(delNotInRepoEntityID, "sample.pdf"); + + // Enter draft mode + response = await api.editEntity(appUrl, serviceName, entityName, delNotInRepoEntityID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + // Delete through app — should succeed even though file is not in CMIS + response = await api.deleteAttachment(appUrl, serviceName, delNotInRepoEntityID, attachmentID, entityName); + expect(response.status).toBe("OK"); + + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, delNotInRepoEntityID); + expect(response.status).toBe("OK"); + + // Verify no attachments remain + response = await api.getActiveAttachmentsList(appUrl, serviceName, entityName, delNotInRepoEntityID); + expect(response.status).toBe("OK"); + expect(response.attachments.length).toBe(0); + + // Cleanup + response = await api.deleteEntity(appUrl, serviceName, entityName, delNotInRepoEntityID); + if (response.status !== "OK") { + console.warn("Cleanup failed for delNotInRepoEntityID:", response.message); + } + }); + + itForAllFacets('should verify entity and its attachments are not accessible after entity delete', async () => { + let deleteEntityTestID; + + let response = await api.createEntityDraft(appUrl, serviceName, entityName, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + deleteEntityTestID = response.incidentID; + + const file = { + filename: "sample.pdf", + filepath: "./test/integration/sample.pdf" + }; + const postData = { + up__ID: deleteEntityTestID, + mimeType: "application/pdf", + createdAt: new Date().toISOString(), + createdBy: "test@test.com", + modifiedBy: "test@test.com" + }; + + response = await api.createAttachment(appUrl, serviceName, entityName, deleteEntityTestID, postData, file); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, deleteEntityTestID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + // Entity exists before deletion + response = await api.checkEntity(appUrl, serviceName, entityName, deleteEntityTestID); + expect(response.status).toBe("OK"); + + // Delete entity + response = await api.deleteEntity(appUrl, serviceName, entityName, deleteEntityTestID); + expect(response.status).toBe("OK"); + + // Entity should no longer be accessible + response = await api.checkEntity(appUrl, serviceName, entityName, deleteEntityTestID); + expect(response.status).toBe("FAILED"); + }); + + itForAllFacets('should verify attachments cleaned up after discarding draft', async () => { + let discardDraftEntityID; + + let response = await api.createEntityDraft(appUrl, serviceName, entityName, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + discardDraftEntityID = response.incidentID; + + const file = { + filename: "sample.pdf", + filepath: "./test/integration/sample.pdf" + }; + const postData = { + up__ID: discardDraftEntityID, + mimeType: "application/pdf", + createdAt: new Date().toISOString(), + createdBy: "test@test.com", + modifiedBy: "test@test.com" + }; + + // Upload attachment in draft (do not save) + response = await api.createAttachment(appUrl, serviceName, entityName, discardDraftEntityID, postData, file); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + // Discard draft before saving + response = await api.discardDraft(appUrl, serviceName, entityName, discardDraftEntityID); + expect(response.status).toBe("OK"); + + // Entity was never activated — should not exist in active state + response = await api.checkEntity(appUrl, serviceName, entityName, discardDraftEntityID); + expect(response.status).toBe("FAILED"); + }); + + itForAllFacets('should verify all attachments removed after deleting all attachments from entity', async () => { + let deleteAllEntityID; + + let response = await api.createEntityDraft(appUrl, serviceName, entityName, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + deleteAllEntityID = response.incidentID; + + const file = { + filename: "sample.pdf", + filepath: "./test/integration/sample.pdf" + }; + const postData = { + up__ID: deleteAllEntityID, + mimeType: "application/pdf", + createdAt: new Date().toISOString(), + createdBy: "test@test.com", + modifiedBy: "test@test.com" + }; + + response = await api.createAttachment(appUrl, serviceName, entityName, deleteAllEntityID, postData, file); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + const attachmentID = response.ID; + + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, deleteAllEntityID); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + // Enter draft mode + response = await api.editEntity(appUrl, serviceName, entityName, deleteAllEntityID, srvpath); + if (response.status !== "OK") { + throw new Error("Error : " + response.message); + } + + // Delete all attachments + response = await api.deleteAttachment(appUrl, serviceName, deleteAllEntityID, attachmentID, entityName); + expect(response.status).toBe("OK"); + + response = await api.saveEntityDraft(appUrl, serviceName, entityName, srvpath, deleteAllEntityID); + expect(response.status).toBe("OK"); + + // Verify no attachments remain + response = await api.getActiveAttachmentsList(appUrl, serviceName, entityName, deleteAllEntityID); + expect(response.status).toBe("OK"); + expect(response.attachments.length).toBe(0); + + // Cleanup + response = await api.deleteEntity(appUrl, serviceName, entityName, deleteAllEntityID); + if (response.status !== "OK") { + console.warn("Cleanup failed for deleteAllEntityID:", response.message); + } + }); +}); \ No newline at end of file diff --git a/test/integration/attachments-sdm.test.js b/test/integration/attachments-sdm.test.js index bb653671..5c1e23f6 100644 --- a/test/integration/attachments-sdm.test.js +++ b/test/integration/attachments-sdm.test.js @@ -40,7 +40,7 @@ beforeAll(async () => { authUrl = credentials.authUrlMTGWC; } } else { - console.log('Running integration tests | Single tenant Scenario'); + console.log('Running integration tests | Single tenant Scenario | Single facet'); appUrl = credentials.appUrl; clientId = credentials.clientID; clientSecret = credentials.clientSecret; diff --git a/test/integration/utills/cmis-document-helper.js b/test/integration/utills/cmis-document-helper.js index 89c916b9..4c94d5fc 100644 --- a/test/integration/utills/cmis-document-helper.js +++ b/test/integration/utills/cmis-document-helper.js @@ -20,8 +20,15 @@ function extractId(line) { return line; } +async function ParentFolderObjectId(entityId) { + const activeFacet = process.env.SDM_TEST_FACET || 'references'; + const folderName = `${entityId}_${activeFacet}`; + const folderLine = await runAndCaptureOutput(GET_OBJECT_ID_SCRIPT, folderName); + return extractId(folderLine); +} + /** - * Resolves the CMIS parent folder ID from `entityId + "__attachments"`, then uploads + * Resolves the CMIS parent folder ID from `entityId + "_" + facet`, then uploads * a local file to that folder via create.sh. * * @param {string} cmisName - the name the document will have in the CMIS repository @@ -29,8 +36,7 @@ function extractId(line) { * @param {string} entityId - the entity ID whose attachments folder is the upload target */ async function createDocumentInCmis(cmisName, filePath, entityId) { - const folderLine = await runAndCaptureOutput(GET_OBJECT_ID_SCRIPT, entityId); - const parentFolderObjectId = extractId(folderLine); + const parentFolderObjectId = await ParentFolderObjectId(entityId); console.log('Resolved parent folder object ID:', parentFolderObjectId); const exitCode = await run(CREATE_SCRIPT, cmisName, filePath, parentFolderObjectId); @@ -41,15 +47,14 @@ async function createDocumentInCmis(cmisName, filePath, entityId) { /** * Resolves the CMIS object ID of a document by name inside the folder named - * `entityId + "__attachments"`, then deletes it via the delete.sh script. + * `entityId + "_" + facet`, then deletes it via the delete.sh script. * * @param {string} entityId - the entity ID whose attachments folder is the parent * @param {string} fileName - the cmis:name of the document to delete */ async function deleteDocumentFromCmis(entityId, fileName) { // Step 1: resolve the parent folder object ID - const folderLine = await runAndCaptureOutput(GET_OBJECT_ID_SCRIPT, entityId); - const parentFolderObjectId = extractId(folderLine); + const parentFolderObjectId = await ParentFolderObjectId(entityId); console.log('Resolved parent folder object ID:', parentFolderObjectId); // Step 2: resolve the document object ID by filename inside the parent folder @@ -73,8 +78,7 @@ async function deleteDocumentFromCmis(entityId, fileName) { * @param {string} outputPath - local path to save the downloaded content */ async function readDocumentFromCmis(entityId, fileName, outputPath) { - const folderLine = await runAndCaptureOutput(GET_OBJECT_ID_SCRIPT, entityId); - const parentFolderObjectId = extractId(folderLine); + const parentFolderObjectId = await ParentFolderObjectId(entityId); console.log('Resolved parent folder object ID:', parentFolderObjectId); const docLine = await runAndCaptureOutput(GET_OBJECT_ID_SCRIPT, fileName, parentFolderObjectId, 'cmis:document'); @@ -96,8 +100,7 @@ async function readDocumentFromCmis(entityId, fileName, outputPath) { * @returns {Promise} the JSON metadata string returned by the CMIS API */ async function readDocumentMetadataFromCmis(entityId, fileName) { - const folderLine = await runAndCaptureOutput(GET_OBJECT_ID_SCRIPT, entityId); - const parentFolderObjectId = extractId(folderLine); + const parentFolderObjectId = await ParentFolderObjectId(entityId); console.log('Resolved parent folder object ID:', parentFolderObjectId); const docLine = await runAndCaptureOutput(GET_OBJECT_ID_SCRIPT, fileName, parentFolderObjectId, 'cmis:document'); diff --git a/test/lib/sdm.test.js b/test/lib/sdm.test.js index 367607ac..26ee57fc 100644 --- a/test/lib/sdm.test.js +++ b/test/lib/sdm.test.js @@ -1,6 +1,7 @@ const SDMAttachmentsService = require("../../lib/sdm"); const NodeCache = require("node-cache"); const { getDestinationFromServiceBinding, retrieveJwt } = require("@sap-cloud-sdk/connectivity"); +const { executeHttpRequest } = require("@sap-cloud-sdk/http-client"); const { getConfigurations, isRepositoryVersioned, @@ -11,6 +12,8 @@ const { getSecondaryPropertiesWithInvalidDefinition, getSecondaryTypeProperties, getUpdatedSecondaryProperties, + transformSDMServiceBindingToClientCredentialsDestination, + transformSDMServiceBindingToJWTBearerCredentialsDestination, checkIfSDMRolesExistInToken, decodeAccessToken } = require("../../lib/util"); @@ -436,6 +439,39 @@ describe("SDMAttachmentsService", () => { }); expect(result).toEqual(mockDestination); }); + + it("should pass transform callback through technical destination lookup", async () => { + const service = new SDMAttachmentsService(); + + cds.context = { + user: { + authInfo: { + token: { + payload: { + ext_attr: { + zdn: "test-subdomain" + } + } + } + } + } + }; + + getSdmInstanceName.mockReturnValue("sdm-instance"); + transformSDMServiceBindingToClientCredentialsDestination.mockReturnValue({ transformed: true }); + getDestinationFromServiceBinding.mockImplementationOnce(async ({ serviceBindingTransformFn }) => { + const transformed = serviceBindingTransformFn({ binding: true }, { option: true }); + expect(transformSDMServiceBindingToClientCredentialsDestination).toHaveBeenCalledWith( + { binding: true }, + { option: true }, + "test-subdomain" + ); + expect(transformed).toEqual({ transformed: true }); + return { url: "http://example.com" }; + }); + + await service.getTechnicalDestination(); + }); }); describe("getDestination", () => { @@ -489,6 +525,77 @@ describe("SDMAttachmentsService", () => { expect(getDestinationFromServiceBinding).not.toHaveBeenCalled(); expect(result).toEqual(cachedDestination); }); + + it("should execute client-credentials transform callback when origin is missing", async () => { + const service = new SDMAttachmentsService(); + const mockReq = { _sdmDestination: undefined }; + + cds.context = { + user: { + authInfo: { + token: { + payload: { + ext_attr: { + zdn: "test-subdomain" + } + } + } + } + } + }; + + retrieveJwt.mockReturnValue("user-jwt"); + getSdmInstanceName.mockReturnValue("sdm-instance"); + transformSDMServiceBindingToClientCredentialsDestination.mockReturnValue({ transformed: true }); + getDestinationFromServiceBinding.mockImplementationOnce(async ({ serviceBindingTransformFn }) => { + const transformed = serviceBindingTransformFn({ binding: true }, { option: true }); + expect(transformSDMServiceBindingToClientCredentialsDestination).toHaveBeenCalledWith( + { binding: true }, + { option: true }, + "test-subdomain" + ); + expect(transformed).toEqual({ transformed: true }); + return { url: "http://example.com" }; + }); + + await service.getDestination(mockReq); + }); + + it("should execute jwt-bearer transform callback when origin is present", async () => { + const service = new SDMAttachmentsService(); + const mockReq = { _sdmDestination: undefined }; + + cds.context = { + user: { + authInfo: { + token: { + payload: { + origin: "sap.custom", + ext_attr: { + zdn: "test-subdomain" + } + } + } + } + } + }; + + retrieveJwt.mockReturnValue("user-jwt"); + getSdmInstanceName.mockReturnValue("sdm-instance"); + transformSDMServiceBindingToJWTBearerCredentialsDestination.mockReturnValue({ transformed: true }); + getDestinationFromServiceBinding.mockImplementationOnce(async ({ serviceBindingTransformFn }) => { + const transformed = serviceBindingTransformFn({ binding: true }, { option: true }); + expect(transformSDMServiceBindingToJWTBearerCredentialsDestination).toHaveBeenCalledWith( + { binding: true }, + { option: true }, + "user-jwt" + ); + expect(transformed).toEqual({ transformed: true }); + return { url: "http://example.com" }; + }); + + await service.getDestination(mockReq); + }); }); it("should use Client Credentials when origin is not present in token", async () => { jest.clearAllMocks(); @@ -765,7 +872,7 @@ describe("SDMAttachmentsService", () => { expect(req.warn).not.toHaveBeenCalled(); }); - it('should rename draft and non-draft attachments', async () => { + it('should rename draft attachments during save', async () => { const draftAttachments = [ { HasActiveEntity: false, ID: 'draft1' }, { HasActiveEntity: false, ID: 'draft2' } @@ -1825,6 +1932,9 @@ describe("SDMAttachmentsService", () => { beforeEach(() => { jest.clearAllMocks(); service = new SDMAttachmentsService(); + service._registeredEntityHandlers = new Set(); + service._registeredTargetHandlers = new Set(); + service._registeredGlobalActionHandlers = false; mockSrv = { before: jest.fn(), @@ -2121,6 +2231,9 @@ describe("SDMAttachmentsService", () => { beforeEach(() => { jest.clearAllMocks(); service = new SDMAttachmentsService(); + service._registeredEntityHandlers = new Set(); + service._registeredTargetHandlers = new Set(); + service._registeredGlobalActionHandlers = false; mockSrv = { before: jest.fn(), @@ -2129,10 +2242,12 @@ describe("SDMAttachmentsService", () => { }; entity = { + name: 'entity', drafts: 'entity.drafts' }; target = { + name: 'target', drafts: 'target.drafts' }; }); @@ -3139,6 +3254,7 @@ describe("SDMAttachmentsService", () => { } }; cds.model.definitions["Attachments.references"] = { + name: "Attachments.references", includes: ['sap.attachments.Attachments'] }; @@ -3174,6 +3290,7 @@ describe("SDMAttachmentsService", () => { } }; cds.model.definitions["Attachments.references"] = { + name: "Attachments.references", includes: ['sap.attachments.Attachments'] }; @@ -3451,6 +3568,7 @@ describe("SDMAttachmentsService", () => { }; cds.model.definitions[mockReq.target.name + ".references"] = { + name: mockReq.target.name + ".references", keys: { up_: { keys: [{ ref: ["attachment"] }], @@ -4383,6 +4501,7 @@ describe("SDMAttachmentsService", () => { }; cds.model.definitions[mockReq.target.name + ".references"] = { + name: mockReq.target.name + ".references", keys: { up_: { keys: [{ ref: ["attachment"] }], @@ -4474,6 +4593,121 @@ describe("SDMAttachmentsService", () => { expect(getFolderIdByPath).not.toHaveBeenCalled(); expect(createFolder).not.toHaveBeenCalled(); }); + + it("should use composition folder strategy for multi-composition entities", async () => { + cds.model.definitions["testName"] = { + name: "testName", + elements: { + attachments: { type: "cds.Composition", target: "testName.attachments" }, + references: { type: "cds.Composition", target: "testName.references" } + } + }; + cds.model.definitions["testName.attachments"] = { includes: ["sap.attachments.Attachments"] }; + cds.model.definitions["testName.references"] = { includes: ["sap.attachments.Attachments"] }; + + const attachments = { + name: "testName.attachments", + keys: { up_: { keys: [{ $generatedFieldName: "up__ID" }] } } + }; + + mockReq.data = { ID: "123" }; + getFolderIdForEntity.mockResolvedValueOnce([{ folderId: "existing-composed-folder" }]); + + const parentId = await service.getParentId(attachments, mockReq, undefined); + + expect(parentId).toBe("existing-composed-folder"); + expect(getFolderIdByPath).not.toHaveBeenCalled(); + }); + + it("should create composition folder when lookup by path fails", async () => { + cds.model.definitions["testName"] = { + name: "testName", + elements: { + attachments: { type: "cds.Composition", target: "testName.attachments" }, + references: { type: "cds.Composition", target: "testName.references" } + } + }; + cds.model.definitions["testName.attachments"] = { includes: ["sap.attachments.Attachments"] }; + cds.model.definitions["testName.references"] = { includes: ["sap.attachments.Attachments"] }; + + const attachments = { + name: "testName.attachments", + keys: { up_: { keys: [{ $generatedFieldName: "up__ID" }] } } + }; + + mockReq.data = { ID: "123" }; + getFolderIdForEntity.mockResolvedValueOnce([]); + executeHttpRequest.mockRejectedValueOnce(new Error("not found")); + createFolder.mockResolvedValueOnce({ + status: 201, + data: { succinctProperties: { "cmis:objectId": "created-composed-folder" } } + }); + + const parentId = await service.getParentId(attachments, mockReq, undefined); + + expect(parentId).toBe("created-composed-folder"); + expect(mockReq.data.ID).toBe("123"); + }); + + it("should retry composition folder lookup after create conflict", async () => { + cds.model.definitions["testName"] = { + name: "testName", + elements: { + attachments: { type: "cds.Composition", target: "testName.attachments" }, + references: { type: "cds.Composition", target: "testName.references" } + } + }; + cds.model.definitions["testName.attachments"] = { includes: ["sap.attachments.Attachments"] }; + cds.model.definitions["testName.references"] = { includes: ["sap.attachments.Attachments"] }; + + const attachments = { + name: "testName.attachments", + keys: { up_: { keys: [{ $generatedFieldName: "up__ID" }] } } + }; + + mockReq.data = { ID: "123" }; + getFolderIdForEntity.mockResolvedValueOnce([]); + executeHttpRequest + .mockRejectedValueOnce(new Error("not found")) + .mockResolvedValueOnce({ data: { properties: { "cmis:objectId": { value: "retried-folder" } } } }); + createFolder.mockResolvedValueOnce({ status: 409, message: "conflict" }); + + const parentId = await service.getParentId(attachments, mockReq, undefined); + + expect(parentId).toBe("retried-folder"); + }); + + it("should reject when composition folder creation is unauthorized and retry also fails", async () => { + cds.model.definitions["testName"] = { + name: "testName", + elements: { + attachments: { type: "cds.Composition", target: "testName.attachments" }, + references: { type: "cds.Composition", target: "testName.references" } + } + }; + cds.model.definitions["testName.attachments"] = { includes: ["sap.attachments.Attachments"] }; + cds.model.definitions["testName.references"] = { includes: ["sap.attachments.Attachments"] }; + + const attachments = { + name: "testName.attachments", + keys: { up_: { keys: [{ $generatedFieldName: "up__ID" }] } } + }; + + mockReq.data = { ID: "123" }; + getFolderIdForEntity.mockResolvedValueOnce([]); + executeHttpRequest + .mockRejectedValueOnce(new Error("not found")) + .mockRejectedValueOnce(new Error("still not found")); + createFolder.mockResolvedValueOnce({ + status: 403, + response: { data: userDoesNotHaveRequiredScope } + }); + + await service.getParentId(attachments, mockReq, undefined); + + expect(mockReq.reject).toHaveBeenCalledWith(403, userNotAuthorisedError); + expect(mockReq.reject).toHaveBeenCalledWith(500, "Failed to create folder for composition: attachments"); + }); }); describe("isFileNameDuplicateInDrafts", () => { @@ -4670,9 +4904,11 @@ describe("SDMAttachmentsService", () => { } }; cds.model.definitions["testName.references"] = { + name: "testName.references", includes: ['sap.attachments.Attachments'] }; cds.model.definitions["testName.references.drafts"] = { + name: "testName.references.drafts", includes: ['sap.attachments.Attachments'] }; }); @@ -4755,6 +4991,124 @@ describe("SDMAttachmentsService", () => { // Ensure parentId wasn't set since folderId is falsy expect(mockReq.parentId).toBeUndefined(); }); + + it("should resolve parent folder from composition path for multi-composition drafts", async () => { + service.creds = { uri: "https://sdm.example/" }; + getConfigurations.mockReturnValue({ repositoryId: "repo123" }); + + cds.model.definitions["testName"] = { + name: "testName", + elements: { + attachments: { type: "cds.Composition", target: "testName.attachments" }, + references: { type: "cds.Composition", target: "testName.references" } + } + }; + cds.model.definitions["testName.attachments"] = { includes: ["sap.attachments.Attachments"] }; + cds.model.definitions["testName.references"] = { includes: ["sap.attachments.Attachments"] }; + cds.model.definitions["testName.references.drafts"] = { + name: "testName.references.drafts", + includes: ["sap.attachments.Attachments"], + keys: { up_: { keys: [{ $generatedFieldName: "up__ID" }] } } + }; + + mockReq.data = { ID: "entity-id-1" }; + getURLsToDeleteFromDraftAttachments.mockReset(); + getURLsToDeleteFromDraftAttachments.mockResolvedValueOnce(["url-1"]); + mockReq.diff.mockResolvedValueOnce({ references: ["url-1"] }); + setupDestinationMocks(); + executeHttpRequest.mockResolvedValueOnce({ + data: { properties: { "cmis:objectId": { value: "multi-folder-id" } } } + }); + + await service.attachDraftDeletionData(mockReq); + + expect(mockReq.attachmentsToDelete).toEqual(["url-1"]); + expect(mockReq.parentId).toEqual(["multi-folder-id"]); + expect(getFolderIdByIDAsPath).not.toHaveBeenCalled(); + }); + + it("should skip parentId when multi-composition folder lookup fails", async () => { + service.creds = { uri: "https://sdm.example/" }; + getConfigurations.mockReturnValue({ repositoryId: "repo123" }); + + cds.model.definitions["testName"] = { + name: "testName", + elements: { + attachments: { type: "cds.Composition", target: "testName.attachments" }, + references: { type: "cds.Composition", target: "testName.references" } + } + }; + cds.model.definitions["testName.attachments"] = { includes: ["sap.attachments.Attachments"] }; + cds.model.definitions["testName.references"] = { includes: ["sap.attachments.Attachments"] }; + cds.model.definitions["testName.references.drafts"] = { + name: "testName.references.drafts", + includes: ["sap.attachments.Attachments"], + keys: { up_: { keys: [{ $generatedFieldName: "up__ID" }] } } + }; + + mockReq.data = { ID: "entity-id-1" }; + getURLsToDeleteFromDraftAttachments.mockReset(); + getURLsToDeleteFromDraftAttachments.mockResolvedValueOnce(["url-1"]); + mockReq.diff.mockResolvedValueOnce({ references: ["url-1"] }); + setupDestinationMocks(); + executeHttpRequest.mockRejectedValueOnce(new Error("folder not found")); + + await service.attachDraftDeletionData(mockReq); + + expect(mockReq.attachmentsToDelete).toEqual(["url-1"]); + expect(mockReq.parentId).toBeUndefined(); + }); + }); + + describe("addFolderToParentIdList", () => { + let service; + let req; + + beforeEach(() => { + jest.clearAllMocks(); + service = new SDMAttachmentsService(); + service.creds = { uri: "https://sdm.example/" }; + getConfigurations.mockReturnValue({ repositoryId: "repo123" }); + req = { data: { ID: "entity-id-1" } }; + + cds.model.definitions["testName"] = { + name: "testName", + elements: { + attachments: { type: "cds.Composition", target: "testName.attachments" }, + references: { type: "cds.Composition", target: "testName.references" } + } + }; + cds.model.definitions["testName.attachments"] = { includes: ["sap.attachments.Attachments"] }; + cds.model.definitions["testName.references"] = { includes: ["sap.attachments.Attachments"] }; + }); + + it("should add parent folder ID for multi-composition entities", async () => { + const attachments = { + name: "testName.references", + keys: { up_: { keys: [{ $generatedFieldName: "up__ID" }] } } + }; + setupDestinationMocks(); + executeHttpRequest.mockResolvedValueOnce({ + data: { properties: { "cmis:objectId": { value: "folder-id-1" } } } + }); + + await service.addFolderToParentIdList(req, attachments); + + expect(req.parentId).toEqual(["folder-id-1"]); + }); + + it("should not add parentId when multi-composition folder does not exist", async () => { + const attachments = { + name: "testName.references", + keys: { up_: { keys: [{ $generatedFieldName: "up__ID" }] } } + }; + setupDestinationMocks(); + executeHttpRequest.mockRejectedValueOnce(new Error("not found")); + + await service.addFolderToParentIdList(req, attachments); + + expect(req.parentId).toBeUndefined(); + }); }); describe('checkRepositoryType', () => {