From 09d5a17f6fb55b8535486309be9ab60b059fcec1 Mon Sep 17 00:00:00 2001 From: Abhishek Anand Date: Mon, 8 Jul 2024 09:51:55 +0530 Subject: [PATCH] FEATURE - handle changeset scenarios for 412 warnings (#829) * feat: handle changeset scenarios for 412 warnings changes made to group 412 responses for batch reqquest in changeset mode with a single 412 error response no changes required for isolated mode * feat: introduce a basic support for etags (#828) * feat: introduce a basic support for etags * chore: fix tests * chore: fix sonar * Update packages/ui5-middleware-fe-mockserver/src/configResolver.ts Co-authored-by: Dirk Teufel <50441133+drktfl@users.noreply.github.com> --------- Co-authored-by: Dirk Teufel <50441133+drktfl@users.noreply.github.com> * chore: apply latest changesets * patch bump * lint fixes * resolve comments --------- Co-authored-by: I569023 Co-authored-by: Nicolas Lunet Co-authored-by: Dirk Teufel <50441133+drktfl@users.noreply.github.com> Co-authored-by: github-actions --- .changeset/itchy-peas-cry.md | 5 + .../src/router/batchRouter.ts | 143 +++++- .../__snapshots__/middleware.test.ts.snap | 25 +- .../test/unit/__testData/RootElement.js | 39 ++ .../test/unit/__testData/service.cds | 426 ++++++++++-------- .../test/unit/__testData/utils.ts | 22 + .../test/unit/middleware.test.ts | 121 ++++- 7 files changed, 570 insertions(+), 211 deletions(-) create mode 100644 .changeset/itchy-peas-cry.md create mode 100644 packages/fe-mockserver-core/test/unit/__testData/utils.ts diff --git a/.changeset/itchy-peas-cry.md b/.changeset/itchy-peas-cry.md new file mode 100644 index 00000000..59612cf1 --- /dev/null +++ b/.changeset/itchy-peas-cry.md @@ -0,0 +1,5 @@ +--- +'@sap-ux/fe-mockserver-core': patch +--- + +handle changeset scenarios for 412 warnings diff --git a/packages/fe-mockserver-core/src/router/batchRouter.ts b/packages/fe-mockserver-core/src/router/batchRouter.ts index 0e4ca3a5..0d63096d 100644 --- a/packages/fe-mockserver-core/src/router/batchRouter.ts +++ b/packages/fe-mockserver-core/src/router/batchRouter.ts @@ -7,26 +7,99 @@ import type { Batch, BatchPart } from './batchParser'; import { BatchContent, getBoundary, parseBatch } from './batchParser'; import type { IncomingMessageWithTenant } from './serviceRouter'; +type Error412 = Record; + +type ErrorResponse = { + code: string; + message: string; + severity?: string; + details: ErrorDetails[]; +} & Record; + +type ErrorDetails = { + code: string; + message: string; + severity?: string; +} & Record; + +/** + * Returns the result whether isRequest part of changeset or not. + * @param part + * @returns {boolean} + */ export function isPartChangeSet(part: BatchPart | Batch): part is Batch { return (part as Batch).isChangeSet; } + +let aggregate412BatchResponseInstance: { + add412Response: (batchPartRes: string, header: string, resContent: Error412, contentId: string | undefined) => void; + getUnifiedResponse: () => string | undefined; +}; const NL = '\r\n'; +/** + * Handles the part request. + * @param partDefinition + * @param dataAccess + * @param boundary + * @param tenantId + * @param globalHeaders + * @param isChangeSetPart + * @returns {string | null} + */ async function handlePart( partDefinition: BatchPart, dataAccess: DataAccess, boundary: string, tenantId: string, - globalHeaders: Record -): Promise { + globalHeaders: Record, + isChangeSetPart?: boolean +): Promise { const partRequest = new ODataRequest({ ...partDefinition, tenantId: tenantId }, dataAccess); await partRequest.handleRequest(); + const isResponse412ChangeSet = partRequest?.statusCode === 412 && !!isChangeSetPart; + const { batchPartRes, header, resContent, contentId } = createBatchResponseObject( + partRequest, + partDefinition, + boundary, + globalHeaders, + isResponse412ChangeSet + ); + // All 412 batch responses should be transformed and returned as single response + if (isResponse412ChangeSet) { + aggregate412BatchResponseInstance.add412Response(batchPartRes, header, resContent, contentId); + return null; + } + return batchPartRes; +} + +/** + * Creates a batch response object. + * @param partRequest + * @param partDefinition + * @param boundary + * @param globalHeaders + * @param isResponse412ChangeSet + * @returns a batch response object + */ +function createBatchResponseObject( + partRequest: ODataRequest, + partDefinition: BatchPart, + boundary: string, + globalHeaders: Record, + isResponse412ChangeSet: boolean +) { let batchResponse = ''; batchResponse += `--${boundary}${NL}`; batchResponse += `Content-Type: application/http${NL}`; batchResponse += `Content-Transfer-Encoding: binary${NL}`; + let contentId; if (partDefinition.contentId) { - batchResponse += `Content-ID: ${partDefinition.contentId}${NL}`; + contentId = partDefinition.contentId; + batchResponse += `Content-ID: ${contentId}${NL}`; + } + if (partRequest.getETag()) { + batchResponse += `ETag: ${partRequest.getETag()}${NL}`; } if (partRequest.getETag()) { batchResponse += `ETag: ${partRequest.getETag()}${NL}`; @@ -41,12 +114,61 @@ async function handlePart( globalHeaders[headerName] = partRequest.globalResponseHeaders[headerName]; } batchResponse += NL; // End of part header + const header = batchResponse; if (responseData) { batchResponse += responseData; //batchResponse += NL; // End of body content } batchResponse += NL; - return batchResponse; + const resContent = isResponse412ChangeSet ? JSON.parse(responseData as string) : null; + return { batchPartRes: batchResponse, header, resContent, contentId }; +} + +/** + * Creates instance of 412 responses aggregated from batch changeset request. + * @returns void + */ +function aggregate412BatchResponse() { + const batch412Response = { + header: '', + error: { + code: '', + message: '', + severity: '', + details: [] as ErrorDetails[] + } as ErrorResponse + }; + let firstPart = true; + return { + add412Response: function ( + batchPartRes: string, + header: string, + resContent: Error412, + contentId: string | undefined + ) { + if (firstPart) { + batch412Response.header = header; + batch412Response.error = { + code: resContent.error.code, + message: resContent.error.message, + severity: resContent.error['@Common.Severity'] as string | undefined, + details: [] + }; + firstPart = false; + } + batch412Response.error.details.push(resContent.error.details[0]); + batch412Response.error.details[batch412Response.error.details.length - 1]['Content-ID'] = contentId; + }, + getUnifiedResponse: function (): string { + let batchResponse = ''; + batchResponse += batch412Response.header; + batchResponse += NL; + const { error } = batch412Response; + batchResponse += JSON.stringify({ error: error }); + batchResponse += NL; + return batchResponse; + } + }; } /** @@ -63,21 +185,28 @@ export function batchRouter(dataAccess: DataAccess): NextHandleFunction { const batchData = parseBatch(new BatchContent(body), boundary); const globalHeaders: Record = {}; let batchResponse = ''; - + //initialize the instance of aggregator of batch 412 responses + aggregate412BatchResponseInstance = aggregate412BatchResponse(); for (const part of batchData.parts) { if (isPartChangeSet(part)) { batchResponse += `--${batchData.boundary}${NL}`; batchResponse += `Content-Type: multipart/mixed; boundary=${part.boundary}${NL}`; batchResponse += NL; for (const changeSetPart of part.parts) { - batchResponse += await handlePart( + const batchPartRes = await handlePart( changeSetPart as BatchPart, dataAccess, part.boundary, req.tenantId!, - globalHeaders + globalHeaders, + true ); + if (batchPartRes !== null) { + batchResponse += batchPartRes; + } } + // append the 412 batch response + batchResponse += aggregate412BatchResponseInstance.getUnifiedResponse(); batchResponse += `--${part.boundary}--${NL}`; } else { batchResponse += await handlePart( diff --git a/packages/fe-mockserver-core/test/unit/__snapshots__/middleware.test.ts.snap b/packages/fe-mockserver-core/test/unit/__snapshots__/middleware.test.ts.snap index f06ee769..a54d6a71 100644 --- a/packages/fe-mockserver-core/test/unit/__snapshots__/middleware.test.ts.snap +++ b/packages/fe-mockserver-core/test/unit/__snapshots__/middleware.test.ts.snap @@ -39,7 +39,7 @@ exports[`V4 Requestor Sticky create, discard, try to update the discarded item: exports[`V4 Requestor can create data through a call 1`] = ` { "@odata.context": "../$metadata#RootElement/$entity", - "@odata.metadataEtag": "W/"60af-tCc45VlNT7csKPRiK0amTPyY35E"", + "@odata.metadataEtag": "W/"6309-CcQCLrzrpArd+m2Ag482j6C0HxA"", "HasActiveEntity": false, "HasDraftEntity": false, "ID": 555, @@ -56,7 +56,7 @@ exports[`V4 Requestor can create data through a call 1`] = ` exports[`V4 Requestor can create data through a call 2`] = ` { "@odata.context": "../$metadata#RootElement/$entity", - "@odata.metadataEtag": "W/"60af-tCc45VlNT7csKPRiK0amTPyY35E"", + "@odata.metadataEtag": "W/"6309-CcQCLrzrpArd+m2Ag482j6C0HxA"", "HasActiveEntity": false, "HasDraftEntity": false, "ID": 666, @@ -182,6 +182,10 @@ exports[`V4 Requestor can get the metadata 1`] = ` + + + + @@ -271,6 +275,11 @@ exports[`V4 Requestor can get the metadata 1`] = ` + + + + + @@ -599,7 +608,7 @@ exports[`V4 Requestor can get the root 1`] = ` exports[`V4 Requestor can get the root 2`] = ` { "@odata.context": "$metadata", - "@odata.metadataEtag": "W/"60af-tCc45VlNT7csKPRiK0amTPyY35E"", + "@odata.metadataEtag": "W/"6309-CcQCLrzrpArd+m2Ag482j6C0HxA"", "value": [ { "kind": "EntitySet", @@ -645,7 +654,7 @@ exports[`V4 Requestor can reload the data 1`] = ` exports[`V4 Requestor can update data through a call 1`] = ` { "@odata.context": "../$metadata#RootElement/$entity", - "@odata.metadataEtag": "W/"60af-tCc45VlNT7csKPRiK0amTPyY35E"", + "@odata.metadataEtag": "W/"6309-CcQCLrzrpArd+m2Ag482j6C0HxA"", "HasActiveEntity": false, "HasDraftEntity": false, "ID": 556, @@ -662,7 +671,7 @@ exports[`V4 Requestor can update data through a call 1`] = ` exports[`V4 Requestor can update data through a call 2`] = ` { "@odata.context": "$metadata#RootElement(ID=556)", - "@odata.metadataEtag": "W/"60af-tCc45VlNT7csKPRiK0amTPyY35E"", + "@odata.metadataEtag": "W/"6309-CcQCLrzrpArd+m2Ag482j6C0HxA"", "HasActiveEntity": false, "HasDraftEntity": false, "ID": 556, @@ -686,7 +695,7 @@ exports[`V4 Requestor can update data through a call 3`] = ` exports[`V4 Requestor can update data through a call 4`] = ` { "@odata.context": "$metadata#RootElement(ID=556)/Prop1", - "@odata.metadataEtag": "W/"60af-tCc45VlNT7csKPRiK0amTPyY35E"", + "@odata.metadataEtag": "W/"6309-CcQCLrzrpArd+m2Ag482j6C0HxA"", "HasActiveEntity": false, "HasDraftEntity": false, "ID": 556, @@ -703,7 +712,7 @@ exports[`V4 Requestor can update data through a call 4`] = ` exports[`V4 Requestor get one data 1`] = ` { "@odata.context": "$metadata#RootElement(ID=2)", - "@odata.metadataEtag": "W/"60af-tCc45VlNT7csKPRiK0amTPyY35E"", + "@odata.metadataEtag": "W/"6309-CcQCLrzrpArd+m2Ag482j6C0HxA"", "HasActiveEntity": false, "HasDraftEntity": false, "ID": 2, @@ -722,7 +731,7 @@ exports[`V4 Requestor get one data 2`] = `""`; exports[`V4 Requestor get one data 3`] = ` { "@odata.context": "$metadata#RootElement(ID=2)", - "@odata.metadataEtag": "W/"60af-tCc45VlNT7csKPRiK0amTPyY35E"", + "@odata.metadataEtag": "W/"6309-CcQCLrzrpArd+m2Ag482j6C0HxA"", "HasActiveEntity": false, "HasDraftEntity": false, "ID": 2, diff --git a/packages/fe-mockserver-core/test/unit/__testData/RootElement.js b/packages/fe-mockserver-core/test/unit/__testData/RootElement.js index f3af3dbd..ff9e7a96 100644 --- a/packages/fe-mockserver-core/test/unit/__testData/RootElement.js +++ b/packages/fe-mockserver-core/test/unit/__testData/RootElement.js @@ -17,6 +17,45 @@ module.exports = { return `STRICT :: ${actionData.data}`; } return actionData.data; + case 'bound412Action': + if (odataRequest.requestContent.headers.prefer) { + if (keys['ID'] == '1' || keys['ID'] == '2') { + odataRequest.addResponseHeader('Preference-Applied', 'handling=strict'); + this.throwError('Unable to execute the action due to a warning.', 412, { + error: { + code: 412, + message: 'Unable to execute the action due to a warning.', + details: [{ code: 'null', message: 'Unable to execute the action due to a warning.' }], + '@Common.numericSeverity': 4 + } + }); + } else if (keys['ID'] == '3' || keys['ID'] == '4') { + this.throwError('unbound transition error', 500, { + error: { + code: 500, + message: 'unbound transition error', + transition: true, + '@Common.numericSeverity': 4 + } + }); + } else { + this.throwError( + '412 executed', + 000, + [{ code: '412', message: '412 executed', numericSeverity: 1 }], + true + ); + } + } else { + this.throwError( + '412 executed', + 000, + [{ code: '412', message: '412 executed', numericSeverity: 1 }], + true + ); + } + break; + default: this.throwError('Not implemented', 501, { error: { diff --git a/packages/fe-mockserver-core/test/unit/__testData/service.cds b/packages/fe-mockserver-core/test/unit/__testData/service.cds index cc4a7333..736ef61f 100644 --- a/packages/fe-mockserver-core/test/unit/__testData/service.cds +++ b/packages/fe-mockserver-core/test/unit/__testData/service.cds @@ -1,211 +1,247 @@ -using {cuid, managed} from '@sap/cds/common'; +using { + cuid, + managed +} from '@sap/cds/common'; namespace sap.fe.core; entity RootElement { - key ID : Integer @Core.Computed; - Prop1 : String @Common.Label : 'First Prop'; - Prop2 : String @Common.Label : 'Second Propyour '; - isBoundAction1Hidden : Boolean @Common.Label : 'Bound Action 1 Hidden'; - isBoundAction2Hidden : Boolean @Common.Label : 'Bound Action 2 Hidden'; - isBoundAction3Hidden : Boolean @Common.Label : 'Bound Action 3 Hidden'; - Sibling_ID : Integer; - Sibling : Association to RootElement on Sibling.ID = Sibling_ID; - _Elements : Composition of many SubElement on _Elements.owner = $self; + key ID : Integer @Core.Computed; + Prop1 : String @Common.Label: 'First Prop'; + Prop2 : String @Common.Label: 'Second Propyour '; + isBoundAction1Hidden : Boolean @Common.Label: 'Bound Action 1 Hidden'; + isBoundAction2Hidden : Boolean @Common.Label: 'Bound Action 2 Hidden'; + isBoundAction3Hidden : Boolean @Common.Label: 'Bound Action 3 Hidden'; + Sibling_ID : Integer; + Sibling : Association to RootElement + on Sibling.ID = Sibling_ID; + _Elements : Composition of many SubElement + on _Elements.owner = $self; }; -entity SubElement @(cds.autoexpose) { - key ID : Integer @Core.Computed; - SubProp1 : String; - SubProp2 : String; - isBoundAction3Hidden: Boolean @Common.Label: 'Bound Action 3 Hidden'; - isBoundAction4Hidden: Boolean @Common.Label: 'Bound Action 4 Hidden'; - owner_ID : Integer; - owner: Association to RootElement on owner.ID = owner_ID; - sibling_ID : Integer; - Sibling : Association to SubElement on Sibling.ID = sibling_ID; +entity SubElement @(cds.autoexpose) { + key ID : Integer @Core.Computed; + SubProp1 : String; + SubProp2 : String; + isBoundAction3Hidden : Boolean @Common.Label: 'Bound Action 3 Hidden'; + isBoundAction4Hidden : Boolean @Common.Label: 'Bound Action 4 Hidden'; + owner_ID : Integer; + owner : Association to RootElement + on owner.ID = owner_ID; + sibling_ID : Integer; + Sibling : Association to SubElement + on Sibling.ID = sibling_ID; } -annotate RootElement with @UI :{ - LineItem: [ - {Value : ID}, - {Value : Prop1}, - { - $Type : 'UI.DataFieldForAction', - Label : 'Bound Action 1', - Action : 'sap.fe.core.ActionVisibility.boundAction1' - }, - { - $Type : 'UI.DataFieldForAction', - Label : 'Bound Action 2', - Action : 'sap.fe.core.ActionVisibility.boundAction2', - ![@UI.Hidden] : isBoundAction2Hidden - }, - { - $Type : 'UI.DataFieldForAction', - Label : 'Menu Action 1', - Action : 'sap.fe.core.ActionVisibility.menuAction1', - }, - { - $Type : 'UI.DataFieldForAction', - Label : 'Menu Action 2', - Action : 'sap.fe.core.ActionVisibility.menuAction2', - }, - { - $Type : 'UI.DataFieldForAction', - Label : 'Bound Action 3', - Action : 'sap.fe.core.ActionVisibility.boundAction3', - ![@UI.Hidden] : _Elements.isBoundAction3Hidden - }, - {Value : isBoundAction1Hidden}, - { - $Type : 'UI.DataFieldForAction', - Label : 'Bound Action 1 Inline', - Action : 'sap.fe.core.ActionVisibility.boundAction1', - Inline: true, - ![@UI.Hidden] : isBoundAction1Hidden - }, +annotate RootElement with @UI: { + LineItem : [ + {Value: ID}, + {Value: Prop1}, + { + $Type : 'UI.DataFieldForAction', + Label : 'Bound Action 1', + Action: 'sap.fe.core.ActionVisibility.boundAction1' + }, + { + $Type : 'UI.DataFieldForAction', + Label : 'Bound Action 2', + Action : 'sap.fe.core.ActionVisibility.boundAction2', + ![@UI.Hidden]: isBoundAction2Hidden + }, + { + $Type : 'UI.DataFieldForAction', + Label : 'Menu Action 1', + Action: 'sap.fe.core.ActionVisibility.menuAction1', + }, + { + $Type : 'UI.DataFieldForAction', + Label : 'Menu Action 2', + Action: 'sap.fe.core.ActionVisibility.menuAction2', + }, + { + $Type : 'UI.DataFieldForAction', + Label : 'Bound Action 3', + Action : 'sap.fe.core.ActionVisibility.boundAction3', + ![@UI.Hidden]: _Elements.isBoundAction3Hidden + }, + {Value: isBoundAction1Hidden}, + { + $Type : 'UI.DataFieldForAction', + Label : 'Bound Action 1 Inline', + Action : 'sap.fe.core.ActionVisibility.boundAction1', + Inline : true, + ![@UI.Hidden]: isBoundAction1Hidden + }, - {Value : Sibling.isBoundAction1Hidden, Label: 'Sibling isBoundAction2 Hidden'}, - { - $Type : 'UI.DataFieldForAction', - Label : 'Bound Action 2', - Action : 'sap.fe.core.ActionVisibility.boundAction2', - Inline: true, - ![@UI.Hidden] : Sibling.isBoundAction2Hidden - } - ], - HeaderInfo : { - $Type : 'UI.HeaderInfoType', - TypeName : 'Root Element', - TypeNamePlural : 'Root Elements', - Title : { - $Type : 'UI.DataField', - Value : Prop1 - }, - Description : { - $Type : 'UI.DataField', - Value : Prop2 - }, + { + Value: Sibling.isBoundAction1Hidden, + Label: 'Sibling isBoundAction2 Hidden' + }, + { + $Type : 'UI.DataFieldForAction', + Label : 'Bound Action 2', + Action : 'sap.fe.core.ActionVisibility.boundAction2', + Inline : true, + ![@UI.Hidden]: Sibling.isBoundAction2Hidden + }, + { + $Type : 'UI.DataFieldForAction', + Label : 'Message - 412 (Bound)', + Action : 'sap.fe.core.ActionVisibility.bound412Action', + InvocationGrouping: #ChangeSet }, - Facets: [ { - $Type : 'UI.CollectionFacet', - Facets: [ { - $Type : 'UI.ReferenceFacet', - Label : 'Identification', - Target : '@UI.Identification' - },{ - $Type : 'UI.CollectionFacet', - ID : 'GeneralInformation', - Label : 'General Information', - Facets: [ { - $Type : 'UI.ReferenceFacet', - Label : 'General Information', - Target : '@UI.FieldGroup#GeneralInformation' - }] - }, { - $Type : 'UI.ReferenceFacet', - ID : 'SubElements', - Label: 'Sub Elements', - Target: '_Elements/@UI.LineItem' + ], + HeaderInfo : { + $Type : 'UI.HeaderInfoType', + TypeName : 'Root Element', + TypeNamePlural: 'Root Elements', + Title : { + $Type: 'UI.DataField', + Value: Prop1 + }, + Description : { + $Type: 'UI.DataField', + Value: Prop2 + }, + }, + Facets : [{ + $Type : 'UI.CollectionFacet', + Facets: [ + { + $Type : 'UI.ReferenceFacet', + Label : 'Identification', + Target: '@UI.Identification' + }, + { + $Type : 'UI.CollectionFacet', + ID : 'GeneralInformation', + Label : 'General Information', + Facets: [{ + $Type : 'UI.ReferenceFacet', + Label : 'General Information', + Target: '@UI.FieldGroup#GeneralInformation' + }] + }, + { + $Type : 'UI.ReferenceFacet', + ID : 'SubElements', + Label : 'Sub Elements', + Target: '_Elements/@UI.LineItem' - }] - } - ], - Identification : [ - { - $Type : 'UI.DataFieldForAction', - Label : 'Bound Action 1', - Action : 'sap.fe.core.ActionVisibility.boundAction1', - Determining : true, - ![@UI.Hidden] : isBoundAction1Hidden - }, { - $Type : 'UI.DataFieldForAction', - Label : 'Bound Action 2', - Action : 'sap.fe.core.ActionVisibility.boundAction2', - Determining : true, - ![@UI.Hidden] : isBoundAction2Hidden - }, { - $Type : 'UI.DataFieldForAction', - Label : 'Bound Header Action 1', - Action : 'sap.fe.core.ActionVisibility.boundHeaderAction1', - Determining : false, - ![@UI.Hidden] : isBoundAction1Hidden - }, { - $Type : 'UI.DataFieldForAction', - Label : 'Bound Header Action 2', - Action : 'sap.fe.core.ActionVisibility.boundHeaderAction2', - Determining : false, - ![@UI.Hidden] : isBoundAction2Hidden - }, {Value: isBoundAction1Hidden}, {Value: isBoundAction2Hidden}, {Value: isBoundAction3Hidden}], - FieldGroup #GeneralInformation : { - Label : 'General Information', - Data : [ - {Value : ID}, - {Value : Prop1}, - {Value : Prop2} - ] - } + } + ] + }], + Identification : [ + { + $Type : 'UI.DataFieldForAction', + Label : 'Bound Action 1', + Action : 'sap.fe.core.ActionVisibility.boundAction1', + Determining : true, + ![@UI.Hidden]: isBoundAction1Hidden + }, + { + $Type : 'UI.DataFieldForAction', + Label : 'Bound Action 2', + Action : 'sap.fe.core.ActionVisibility.boundAction2', + Determining : true, + ![@UI.Hidden]: isBoundAction2Hidden + }, + { + $Type : 'UI.DataFieldForAction', + Label : 'Bound Header Action 1', + Action : 'sap.fe.core.ActionVisibility.boundHeaderAction1', + Determining : false, + ![@UI.Hidden]: isBoundAction1Hidden + }, + { + $Type : 'UI.DataFieldForAction', + Label : 'Bound Header Action 2', + Action : 'sap.fe.core.ActionVisibility.boundHeaderAction2', + Determining : false, + ![@UI.Hidden]: isBoundAction2Hidden + }, + {Value: isBoundAction1Hidden}, + {Value: isBoundAction2Hidden}, + {Value: isBoundAction3Hidden} + ], + FieldGroup #GeneralInformation: { + Label: 'General Information', + Data : [ + {Value: ID}, + {Value: Prop1}, + {Value: Prop2} + ] + } }; -annotate SubElement with @UI : { - LineItem: [ - {Value : SubProp1}, - {Value : owner.isBoundAction1Hidden, Label: 'Owner -> boundAction1 Hidden'}, - { - $Type : 'UI.DataFieldForAction', - Label : 'Bound Action 1', - Action : 'sap.fe.core.ActionVisibility.boundAction1', - ![@UI.Hidden] : owner.isBoundAction1Hidden - }, - { - $Type : 'UI.DataFieldForAction', - Label : 'Menu Action 1', - Action : 'sap.fe.core.ActionVisibility.menuAction1', - ![@UI.Hidden] : owner.isBoundAction1Hidden - }, - {Value : owner.Sibling.isBoundAction2Hidden, Label: 'Owner -> Sibling boundAction2 Hidden'}, - { - $Type : 'UI.DataFieldForAction', - Label : 'Bound Action 2', - Action : 'sap.fe.core.ActionVisibility.boundAction2', - ![@UI.Hidden] : owner.Sibling.isBoundAction2Hidden - }, - { - $Type : 'UI.DataFieldForAction', - Label : 'Menu Action 2', - Action : 'sap.fe.core.ActionVisibility.menuAction2', - ![@UI.Hidden] : owner.Sibling.isBoundAction2Hidden - }, - {Value : isBoundAction3Hidden}, - { - $Type : 'UI.DataFieldForAction', - Label : 'Bound Action 3 Inline', - Action : 'sap.fe.core.ActionVisibility.boundAction3', - Inline: true, - ![@UI.Hidden] : isBoundAction3Hidden - }, - {Value : Sibling.isBoundAction4Hidden, Label: 'Sibling boundAction4 Hidden'}, - { - $Type : 'UI.DataFieldForAction', - Label : 'Bound Action 4', - Action : 'sap.fe.core.ActionVisibility.boundAction4', - Inline: true, - ![@UI.Hidden] : Sibling.isBoundAction4Hidden - }, - ] -}; +annotate SubElement with @UI: {LineItem: [ + {Value: SubProp1}, + { + Value: owner.isBoundAction1Hidden, + Label: 'Owner -> boundAction1 Hidden' + }, + { + $Type : 'UI.DataFieldForAction', + Label : 'Bound Action 1', + Action : 'sap.fe.core.ActionVisibility.boundAction1', + ![@UI.Hidden]: owner.isBoundAction1Hidden + }, + { + $Type : 'UI.DataFieldForAction', + Label : 'Menu Action 1', + Action : 'sap.fe.core.ActionVisibility.menuAction1', + ![@UI.Hidden]: owner.isBoundAction1Hidden + }, + { + Value: owner.Sibling.isBoundAction2Hidden, + Label: 'Owner -> Sibling boundAction2 Hidden' + }, + { + $Type : 'UI.DataFieldForAction', + Label : 'Bound Action 2', + Action : 'sap.fe.core.ActionVisibility.boundAction2', + ![@UI.Hidden]: owner.Sibling.isBoundAction2Hidden + }, + { + $Type : 'UI.DataFieldForAction', + Label : 'Menu Action 2', + Action : 'sap.fe.core.ActionVisibility.menuAction2', + ![@UI.Hidden]: owner.Sibling.isBoundAction2Hidden + }, + {Value: isBoundAction3Hidden}, + { + $Type : 'UI.DataFieldForAction', + Label : 'Bound Action 3 Inline', + Action : 'sap.fe.core.ActionVisibility.boundAction3', + Inline : true, + ![@UI.Hidden]: isBoundAction3Hidden + }, + { + Value: Sibling.isBoundAction4Hidden, + Label: 'Sibling boundAction4 Hidden' + }, + { + $Type : 'UI.DataFieldForAction', + Label : 'Bound Action 4', + Action : 'sap.fe.core.ActionVisibility.boundAction4', + Inline : true, + ![@UI.Hidden]: Sibling.isBoundAction4Hidden + }, +]}; service ActionVisibility { - @odata.draft.enabled - entity RootElement as projection on core.RootElement actions { - @cds.odata.bindingparameter.name : 'self' - action boundAction1() returns RootElement; - action boundAction2() returns RootElement; - @cds.odata.bindingparameter.name : 'self' - action boundAction3() returns RootElement; - action boundActionReturnsVoid(); - @cds.odata.bindingparameter.name : 'self' - function baseFunction(data: String) returns String; + @odata.draft.enabled + entity RootElement as projection on core.RootElement + actions { + @cds.odata.bindingparameter.name: 'self' + action bound412Action() returns RootElement; + @cds.odata.bindingparameter.name: 'self' + action boundAction1() returns RootElement; + action boundAction2() returns RootElement; + @cds.odata.bindingparameter.name: 'self' + action boundAction3() returns RootElement; + action boundActionReturnsVoid(); + @cds.odata.bindingparameter.name: 'self' + function baseFunction(data : String) returns String; }; -} \ No newline at end of file +} diff --git a/packages/fe-mockserver-core/test/unit/__testData/utils.ts b/packages/fe-mockserver-core/test/unit/__testData/utils.ts new file mode 100644 index 00000000..8eda3caf --- /dev/null +++ b/packages/fe-mockserver-core/test/unit/__testData/utils.ts @@ -0,0 +1,22 @@ +/** + * Exracts JSON response from multipart content in case of changeset batch + * + * @param batchResponse + */ + +export function getJsonFromMultipartContent(batchResponse: string) { + const changeSetBoundaryprefix = '--changeset'; + const partResponses: unknown[] = []; + const responseLines = batchResponse.split(changeSetBoundaryprefix); + responseLines.forEach(function (value) { + const startJson = value.indexOf('{'); + const endJson = value.lastIndexOf('}'); + if (startJson < 0 || endJson < 0) { + return; + } + let responseJson = value.slice(startJson, endJson + 1); + responseJson = JSON.parse(responseJson); + partResponses.push(responseJson); + }); + return partResponses; +} diff --git a/packages/fe-mockserver-core/test/unit/middleware.test.ts b/packages/fe-mockserver-core/test/unit/middleware.test.ts index c8ea63fa..65e25b36 100644 --- a/packages/fe-mockserver-core/test/unit/middleware.test.ts +++ b/packages/fe-mockserver-core/test/unit/middleware.test.ts @@ -4,6 +4,7 @@ import type { Server } from 'http'; import * as http from 'http'; import * as path from 'path'; import FEMockserver from '../../src'; +import { getJsonFromMultipartContent } from '../../test/unit/__testData/utils'; import { ODataV4Requestor } from './__testData/Requestor'; jest.setTimeout(60000); @@ -126,7 +127,7 @@ describe('V4 Requestor', function () { { "@odata.context": "$metadata#RootElement(ID=1,IsActiveEntity=true)/_Elements", "@odata.count": 3, - "@odata.metadataEtag": "W/"60af-tCc45VlNT7csKPRiK0amTPyY35E"", + "@odata.metadataEtag": "W/"6309-CcQCLrzrpArd+m2Ag482j6C0HxA"", "value": [ { "HasActiveEntity": true, @@ -443,6 +444,124 @@ Content-Type:application/json;charset=UTF-8;IEEE754Compatible=true }, 1000); return myPromise; }); + + it('get a 412 warning for a single selected context', async () => { + const dataRequestor = new ODataV4Requestor('http://localhost:33331/sap/fe/core/mock/action'); + const dataRes = await dataRequestor.callAction( + '/RootElement(ID=1,IsActiveEntity=true)/sap.fe.core.ActionVisibility.bound412Action', + {} + ); + dataRes.headers['Prefer'] = 'handling=strict'; + const result: any = await dataRes.execute(); + expect(result.status).toEqual(412); + expect(result.body.error.details.length).toBe(1); + }); + + it('get 412 warnings for multiple selected contexts', async () => { + const response = await fetch('http://localhost:33331/sap/fe/core/mock/action/$batch', { + method: 'POST', + headers: new Headers({ + 'Content-Type': 'multipart/mixed; boundary=batch_id-1719917686303-234', + accept: 'multipart/mixed' + }), + body: `--batch_id-1719917686303-234 +Content-Type: multipart/mixed;boundary=changeset_id-1719917686303-235 + +--changeset_id-1719917686303-235 +Content-Type:application/http +Content-Transfer-Encoding:binary +Content-ID:0.0 + +POST RootElement(ID=1,IsActiveEntity=true)/sap.fe.core.ActionVisibility.bound412Action?$select=HasActiveEntity HTTP/1.1 +Accept:application/json;odata.metadata=minimal;IEEE754Compatible=true +Accept-Language:en +X-CSRF-Token:0504-71383 +Prefer:handling=strict +Content-Type:application/json;charset=UTF-8;IEEE754Compatible=true + +{} +--changeset_id-1719917686303-235 +Content-Type:application/http +Content-Transfer-Encoding:binary +Content-ID:1.0 + +POST RootElement(ID=2,IsActiveEntity=true)/sap.fe.core.ActionVisibility.bound412Action?$select=HasActiveEntity HTTP/1.1 +Accept:application/json;odata.metadata=minimal;IEEE754Compatible=true +Accept-Language:en +X-CSRF-Token:0504-71383 +Prefer:handling=strict +Content-Type:application/json;charset=UTF-8;IEEE754Compatible=true + +{} +--changeset_id-1719917686303-235-- +--batch_id-1719917686303-234-- +Group ID: $auto` + }); + const responseStr = await response.text(); + const responseJson: any = getJsonFromMultipartContent(responseStr); + expect(responseJson[0].error.code).toEqual(412); + expect(responseJson[0].error.details.length).toBe(2); + }); + + it('get 412 warnings with unbound transition error with multiple contexts selected', async () => { + const response = await fetch('http://localhost:33331/sap/fe/core/mock/action/$batch', { + method: 'POST', + headers: new Headers({ + 'Content-Type': 'multipart/mixed; boundary=batch_id-1719917686303-234', + accept: 'multipart/mixed' + }), + body: `--batch_id-1719917686303-234 +Content-Type: multipart/mixed;boundary=changeset_id-1719917686303-235 + +--changeset_id-1719917686303-235 +Content-Type:application/http +Content-Transfer-Encoding:binary +Content-ID:0.0 + +POST RootElement(ID=1,IsActiveEntity=true)/sap.fe.core.ActionVisibility.bound412Action?$select=HasActiveEntity HTTP/1.1 +Accept:application/json;odata.metadata=minimal;IEEE754Compatible=true +Accept-Language:en +X-CSRF-Token:0504-71383 +Prefer:handling=strict +Content-Type:application/json;charset=UTF-8;IEEE754Compatible=true + +{} +--changeset_id-1719917686303-235 +Content-Type:application/http +Content-Transfer-Encoding:binary +Content-ID:1.0 + +POST RootElement(ID=2,IsActiveEntity=true)/sap.fe.core.ActionVisibility.bound412Action?$select=HasActiveEntity HTTP/1.1 +Accept:application/json;odata.metadata=minimal;IEEE754Compatible=true +Accept-Language:en +X-CSRF-Token:0504-71383 +Prefer:handling=strict +Content-Type:application/json;charset=UTF-8;IEEE754Compatible=true + +{} +--changeset_id-1719917686303-235 +Content-Type:application/http +Content-Transfer-Encoding:binary +Content-ID:1.0 + +POST RootElement(ID=3,IsActiveEntity=true)/sap.fe.core.ActionVisibility.bound412Action?$select=HasActiveEntity HTTP/1.1 +Accept:application/json;odata.metadata=minimal;IEEE754Compatible=true +Accept-Language:en +X-CSRF-Token:0504-71383 +Prefer:handling=strict +Content-Type:application/json;charset=UTF-8;IEEE754Compatible=true + +{} +--changeset_id-1719917686303-235-- +--batch_id-1719917686303-234-- +Group ID: $auto` + }); + const responseStr = await response.text(); + const responseJson: any = getJsonFromMultipartContent(responseStr); + expect(responseJson[0].error.code).toEqual(500); + expect(responseJson[1].error.code).toEqual(412); + expect(responseJson[1].error.details.length).toBe(2); + }); beforeAll(() => { const myJSON = JSON.parse( fs.readFileSync(path.join(__dirname, '__testData', 'RootElement.json')).toString('utf-8')