From deec1dd2866acdd2945e744fb9eddeb595baac46 Mon Sep 17 00:00:00 2001 From: C5226337 Date: Tue, 21 Oct 2025 10:14:59 +0300 Subject: [PATCH 01/12] feat: dynamically create services --- packages/fe-mockserver-core/src/api.ts | 1 + .../fe-mockserver-core/src/data/metadata.ts | 47 +++++++++++++++++++ .../src/data/serviceRegistry.ts | 33 ++++++++++++- .../src/mockdata/fileBasedMockData.ts | 6 +-- 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/packages/fe-mockserver-core/src/api.ts b/packages/fe-mockserver-core/src/api.ts index 0e434448d..8c6202fc1 100644 --- a/packages/fe-mockserver-core/src/api.ts +++ b/packages/fe-mockserver-core/src/api.ts @@ -95,6 +95,7 @@ export type ServiceConfig = { alias?: string; logger?: ILogger; metadataPath: string; + metadataContent?: string; mockdataPath: string; i18nPath?: string[]; generateMockData?: boolean; diff --git a/packages/fe-mockserver-core/src/data/metadata.ts b/packages/fe-mockserver-core/src/data/metadata.ts index 416ee3339..1ef6c61de 100644 --- a/packages/fe-mockserver-core/src/data/metadata.ts +++ b/packages/fe-mockserver-core/src/data/metadata.ts @@ -10,6 +10,8 @@ import type { RawMetadata, Singleton } from '@sap-ux/vocabularies-types'; +import { join } from 'node:path'; +import { join as joinPosix } from 'node:path/posix'; type NameAndNav = { name: string; @@ -241,4 +243,49 @@ export class ODataMetadata { return keyValues; } + + public getValueListReferences(metadataPath: string) { + const references = []; + for (const entityType of this.metadata.entityTypes) { + for (const property of entityType.entityProperties) { + const rootPath = this.metadataUrl.replace('/$metadata', ''); + const target = `${entityType.name}/${property.name}`; + for (const reference of property.annotations.Common?.ValueListReferences ?? []) { + const externalServiceMetadataPath = joinPosix(rootPath, reference as string).replace( + '/$metadata', + '' + ); + const [valueListServicePath] = externalServiceMetadataPath.split(';'); + const segments = valueListServicePath.split('/'); + let prefix = '/'; + while (segments.length) { + const next = join(prefix, segments.shift()!); + if (!rootPath.startsWith(next)) { + break; + } + prefix = next; + } + const relativeServicePath = valueListServicePath.replace(prefix, ''); + + const localPath = join( + metadataPath, + '..', + 'value-list-references', + 'mainService', + target, + `${relativeServicePath}.xml` + ); + + references.push({ + rootPath, + externalServiceMetadataPath, + localPath: localPath, + target: target, + values: property.annotations.Common?.ValueListReferences ?? [] + }); + } + } + } + return references; + } } diff --git a/packages/fe-mockserver-core/src/data/serviceRegistry.ts b/packages/fe-mockserver-core/src/data/serviceRegistry.ts index 365ac3a96..29d80e941 100644 --- a/packages/fe-mockserver-core/src/data/serviceRegistry.ts +++ b/packages/fe-mockserver-core/src/data/serviceRegistry.ts @@ -1,6 +1,7 @@ import type { ILogger } from '@ui5/logger'; import etag from 'etag'; import type { IncomingMessage, ServerResponse } from 'http'; +import { join } from 'node:path/posix'; import type { IRouter } from 'router'; import Router from 'router'; import type { MockserverConfiguration, ServiceConfig, ServiceConfigEx } from '../api'; @@ -39,7 +40,9 @@ function encode(str: string) { } async function loadMetadata(service: ServiceConfigEx, metadataProcessor: IMetadataProcessor) { - const edmx = await metadataProcessor.loadMetadata(service.metadataPath); + const edmx = service.metadataContent + ? service.metadataContent + : await metadataProcessor.loadMetadata(service.metadataPath); if (!service.noETag) { service.ETag = etag(edmx, { weak: true }); } @@ -145,7 +148,35 @@ export class ServiceRegistry { } else { metadata = await loadMetadata(mockService, processor); } + const dataAccess = new DataAccess(mockService, metadata, this.fileLoader, this.config.logger, this); + if (metadata && !mockServiceIn.metadataContent) { + const path = mockService.metadataPath.replace(/xml$/, 'valuelistreferences'); + const text = await this.fileLoader.loadFile(path); + const lines = text.split(/\r?\n/); + + const promises = []; + for (let index = 0; index < lines.length; index += 2) { + const urlPath = lines[index]; + const content = lines[index + 1]; + if (path && content) { + promises.push( + this.createServiceRegistration( + { + metadataPath: path, + metadataContent: content, + urlPath, + generateMockData: true, + mockdataPath: join(path, '..', 'data'), + watch: false + }, + log + ) + ); + } + } + await Promise.allSettled(promises); + } // Register this service for cross-service access this.registerService(mockService.urlPath, dataAccess, mockService.alias); diff --git a/packages/fe-mockserver-core/src/mockdata/fileBasedMockData.ts b/packages/fe-mockserver-core/src/mockdata/fileBasedMockData.ts index 81bedcb77..ccc8566cb 100644 --- a/packages/fe-mockserver-core/src/mockdata/fileBasedMockData.ts +++ b/packages/fe-mockserver-core/src/mockdata/fileBasedMockData.ts @@ -485,7 +485,7 @@ export class FileBasedMockData { return Math.floor(Math.random() * 10000); case 'Edm.String': if (property.maxLength) { - const remainingLength = property.maxLength - getNumberLength(lineIndex) - 2; + const remainingLength = property.maxLength - getNumberLength(lineIndex) - 1; return `${propertyName.substring(0, remainingLength)}_${lineIndex}`; } return `${propertyName}_${lineIndex}`; @@ -582,8 +582,8 @@ export class FileBasedMockData { lineIndex = currentMockData.length + 1; } if (property.maxLength) { - const remainingLength = property.maxLength - getNumberLength(lineIndex); - return `${propertyName.substring(0, remainingLength)}${lineIndex}`; + const remainingLength = property.maxLength - getNumberLength(lineIndex) - 1; + return `${propertyName.substring(0, remainingLength)}_${lineIndex}`; } return `${propertyName}_${lineIndex}`; default: From d0887a01e7e3df493d16089edaba32cc8d17d604 Mon Sep 17 00:00:00 2001 From: C5226337 Date: Thu, 30 Oct 2025 08:51:20 +0200 Subject: [PATCH 02/12] refactor: change file structure --- packages/fe-mockserver-core/src/api.ts | 1 - .../fe-mockserver-core/src/data/metadata.ts | 27 ++++++----- .../src/data/serviceRegistry.ts | 47 +++++++------------ .../src/mockdata/fileBasedMockData.ts | 6 +-- 4 files changed, 34 insertions(+), 47 deletions(-) diff --git a/packages/fe-mockserver-core/src/api.ts b/packages/fe-mockserver-core/src/api.ts index 8c6202fc1..0e434448d 100644 --- a/packages/fe-mockserver-core/src/api.ts +++ b/packages/fe-mockserver-core/src/api.ts @@ -95,7 +95,6 @@ export type ServiceConfig = { alias?: string; logger?: ILogger; metadataPath: string; - metadataContent?: string; mockdataPath: string; i18nPath?: string[]; generateMockData?: boolean; diff --git a/packages/fe-mockserver-core/src/data/metadata.ts b/packages/fe-mockserver-core/src/data/metadata.ts index 1ef6c61de..4feab2337 100644 --- a/packages/fe-mockserver-core/src/data/metadata.ts +++ b/packages/fe-mockserver-core/src/data/metadata.ts @@ -10,8 +10,8 @@ import type { RawMetadata, Singleton } from '@sap-ux/vocabularies-types'; -import { join } from 'node:path'; -import { join as joinPosix } from 'node:path/posix'; +import { join } from 'path'; +import { join as joinPosix } from 'path/posix'; type NameAndNav = { name: string; @@ -258,28 +258,25 @@ export class ODataMetadata { const [valueListServicePath] = externalServiceMetadataPath.split(';'); const segments = valueListServicePath.split('/'); let prefix = '/'; - while (segments.length) { - const next = join(prefix, segments.shift()!); + let currentSegment = segments.shift(); + while (currentSegment) { + const next = join(prefix, currentSegment); if (!rootPath.startsWith(next)) { break; } prefix = next; + currentSegment = segments.shift(); } const relativeServicePath = valueListServicePath.replace(prefix, ''); - const localPath = join( - metadataPath, - '..', - 'value-list-references', - 'mainService', - target, - `${relativeServicePath}.xml` - ); + const serviceRoot = join(metadataPath, '..', relativeServicePath, target); + const localPath = join(serviceRoot, `metadata.xml`); references.push({ rootPath, - externalServiceMetadataPath, + externalServiceMetadataPath: encode(externalServiceMetadataPath), localPath: localPath, + dataPath: serviceRoot, target: target, values: property.annotations.Common?.ValueListReferences ?? [] }); @@ -289,3 +286,7 @@ export class ODataMetadata { return references; } } + +function encode(str: string): string { + return str.replaceAll("'", '%27').replaceAll('*', '%2A'); +} diff --git a/packages/fe-mockserver-core/src/data/serviceRegistry.ts b/packages/fe-mockserver-core/src/data/serviceRegistry.ts index 29d80e941..ded45db10 100644 --- a/packages/fe-mockserver-core/src/data/serviceRegistry.ts +++ b/packages/fe-mockserver-core/src/data/serviceRegistry.ts @@ -1,7 +1,6 @@ import type { ILogger } from '@ui5/logger'; import etag from 'etag'; import type { IncomingMessage, ServerResponse } from 'http'; -import { join } from 'node:path/posix'; import type { IRouter } from 'router'; import Router from 'router'; import type { MockserverConfiguration, ServiceConfig, ServiceConfigEx } from '../api'; @@ -40,9 +39,7 @@ function encode(str: string) { } async function loadMetadata(service: ServiceConfigEx, metadataProcessor: IMetadataProcessor) { - const edmx = service.metadataContent - ? service.metadataContent - : await metadataProcessor.loadMetadata(service.metadataPath); + const edmx = await metadataProcessor.loadMetadata(service.metadataPath); if (!service.noETag) { service.ETag = etag(edmx, { weak: true }); } @@ -150,32 +147,22 @@ export class ServiceRegistry { } const dataAccess = new DataAccess(mockService, metadata, this.fileLoader, this.config.logger, this); - if (metadata && !mockServiceIn.metadataContent) { - const path = mockService.metadataPath.replace(/xml$/, 'valuelistreferences'); - const text = await this.fileLoader.loadFile(path); - const lines = text.split(/\r?\n/); - - const promises = []; - for (let index = 0; index < lines.length; index += 2) { - const urlPath = lines[index]; - const content = lines[index + 1]; - if (path && content) { - promises.push( - this.createServiceRegistration( - { - metadataPath: path, - metadataContent: content, - urlPath, - generateMockData: true, - mockdataPath: join(path, '..', 'data'), - watch: false - }, - log - ) - ); - } - } - await Promise.allSettled(promises); + if (metadata) { + const references = metadata.getValueListReferences(mockService.metadataPath); + await Promise.all( + references.map((reference) => + this.createServiceRegistration( + { + metadataPath: reference.localPath, + urlPath: reference.externalServiceMetadataPath, + generateMockData: true, + mockdataPath: reference.dataPath, + watch: false + }, + log + ) + ) + ); } // Register this service for cross-service access diff --git a/packages/fe-mockserver-core/src/mockdata/fileBasedMockData.ts b/packages/fe-mockserver-core/src/mockdata/fileBasedMockData.ts index ccc8566cb..81bedcb77 100644 --- a/packages/fe-mockserver-core/src/mockdata/fileBasedMockData.ts +++ b/packages/fe-mockserver-core/src/mockdata/fileBasedMockData.ts @@ -485,7 +485,7 @@ export class FileBasedMockData { return Math.floor(Math.random() * 10000); case 'Edm.String': if (property.maxLength) { - const remainingLength = property.maxLength - getNumberLength(lineIndex) - 1; + const remainingLength = property.maxLength - getNumberLength(lineIndex) - 2; return `${propertyName.substring(0, remainingLength)}_${lineIndex}`; } return `${propertyName}_${lineIndex}`; @@ -582,8 +582,8 @@ export class FileBasedMockData { lineIndex = currentMockData.length + 1; } if (property.maxLength) { - const remainingLength = property.maxLength - getNumberLength(lineIndex) - 1; - return `${propertyName.substring(0, remainingLength)}_${lineIndex}`; + const remainingLength = property.maxLength - getNumberLength(lineIndex); + return `${propertyName.substring(0, remainingLength)}${lineIndex}`; } return `${propertyName}_${lineIndex}`; default: From 688cf7fe63e67ba91af3e02287afcbfdb4010845 Mon Sep 17 00:00:00 2001 From: C5226337 Date: Thu, 30 Oct 2025 09:42:07 +0200 Subject: [PATCH 03/12] test: add tests --- .../fe-mockserver-core/src/data/metadata.ts | 6 +-- .../test/unit/middleware.test.ts | 50 +++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/fe-mockserver-core/src/data/metadata.ts b/packages/fe-mockserver-core/src/data/metadata.ts index 4feab2337..c8f7dad0e 100644 --- a/packages/fe-mockserver-core/src/data/metadata.ts +++ b/packages/fe-mockserver-core/src/data/metadata.ts @@ -259,7 +259,7 @@ export class ODataMetadata { const segments = valueListServicePath.split('/'); let prefix = '/'; let currentSegment = segments.shift(); - while (currentSegment) { + while (currentSegment !== undefined) { const next = join(prefix, currentSegment); if (!rootPath.startsWith(next)) { break; @@ -276,9 +276,7 @@ export class ODataMetadata { rootPath, externalServiceMetadataPath: encode(externalServiceMetadataPath), localPath: localPath, - dataPath: serviceRoot, - target: target, - values: property.annotations.Common?.ValueListReferences ?? [] + dataPath: serviceRoot }); } } diff --git a/packages/fe-mockserver-core/test/unit/middleware.test.ts b/packages/fe-mockserver-core/test/unit/middleware.test.ts index 273f2d29c..645166300 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, { type MockserverConfiguration } from '../../src'; +import FileSystemLoader from '../../src/plugins/fileSystemLoader'; import { getJsonFromMultipartContent, getStatusAndHeadersFromMultipartContent } from '../../test/unit/__testData/utils'; import { ODataV4Requestor } from './__testData/Requestor'; @@ -753,6 +754,55 @@ Group ID: $auto` }); }); +describe('services from ValueListReferences', () => { + let server: Server; + beforeAll(async function () { + const loadFile = FileSystemLoader.prototype.loadFile; + jest.spyOn(FileSystemLoader.prototype, 'loadFile').mockImplementation((path): Promise => { + if (path.includes('0001') && path.includes('metadata.xml')) { + return Promise.resolve(` + + + + +`); + } else { + return loadFile(path); + } + }); + const mockServer = new FEMockserver({ + services: [ + { + metadataPath: path.join(__dirname, 'v4', 'services', 'parametrizedSample', 'metadata.xml'), + mockdataPath: path.join(__dirname, 'v4', 'services', 'parametrizedSample'), + urlPath: '/sap/fe/core/mock/sticky', + watch: false, + generateMockData: true + } + ], + annotations: [], + plugins: [], + contextBasedIsolation: true + }); + await mockServer.isReady; + server = http.createServer(function onRequest(req, res) { + mockServer.getRouter()(req, res, finalHandler(req, res)); + }); + server.listen(33332); + }); + afterAll((done) => { + server.close(done); + }); + + it('call service from ValueListReferences', async () => { + const response = await fetch( + `http://localhost:33332/sap/srvd_f4/sap/i_companycodestdvh/0001;ps=%27srvd-zrc_arcustomer_definition-0001%27;va=%27com.sap.gateway.srvd.zrc_arcustomer_definition.v0001.et-parameterz_arcustomer2.p_companycode%27/$metadata` + ); + + expect(response.status).toEqual(200); + }); +}); + describe('V2', function () { let server: Server; beforeAll(async function () { From 3cf477c8e11331c397420acd1584dee60a496f1e Mon Sep 17 00:00:00 2001 From: C5226337 Date: Thu, 30 Oct 2025 09:51:02 +0200 Subject: [PATCH 04/12] test: check that correct metadata files are read --- .../test/unit/middleware.test.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/fe-mockserver-core/test/unit/middleware.test.ts b/packages/fe-mockserver-core/test/unit/middleware.test.ts index 645166300..a1f4c3116 100644 --- a/packages/fe-mockserver-core/test/unit/middleware.test.ts +++ b/packages/fe-mockserver-core/test/unit/middleware.test.ts @@ -756,9 +756,10 @@ Group ID: $auto` describe('services from ValueListReferences', () => { let server: Server; + let loadFileSpy: jest.SpyInstance; beforeAll(async function () { const loadFile = FileSystemLoader.prototype.loadFile; - jest.spyOn(FileSystemLoader.prototype, 'loadFile').mockImplementation((path): Promise => { + loadFileSpy = jest.spyOn(FileSystemLoader.prototype, 'loadFile').mockImplementation((path): Promise => { if (path.includes('0001') && path.includes('metadata.xml')) { return Promise.resolve(` @@ -800,6 +801,22 @@ describe('services from ValueListReferences', () => { ); expect(response.status).toEqual(200); + expect(loadFileSpy).toHaveBeenNthCalledWith( + 2, + path.join( + __dirname, + 'v4', + 'services', + 'parametrizedSample', + 'srvd_f4', + 'sap', + 'i_companycodestdvh', + '0001', + 'CustomerParameters', + 'P_CompanyCode', + 'metadata.xml' + ) + ); }); }); From be1ddc4dc541362308ac7ad4381c713261af04d3 Mon Sep 17 00:00:00 2001 From: C5226337 Date: Thu, 30 Oct 2025 10:24:26 +0200 Subject: [PATCH 05/12] fix: do not crash if value list metadata are missing --- .../src/data/serviceRegistry.ts | 17 ++++++++---- .../test/unit/middleware.test.ts | 27 +++++++++++++++++-- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/packages/fe-mockserver-core/src/data/serviceRegistry.ts b/packages/fe-mockserver-core/src/data/serviceRegistry.ts index ded45db10..f12fc9fca 100644 --- a/packages/fe-mockserver-core/src/data/serviceRegistry.ts +++ b/packages/fe-mockserver-core/src/data/serviceRegistry.ts @@ -149,9 +149,16 @@ export class ServiceRegistry { const dataAccess = new DataAccess(mockService, metadata, this.fileLoader, this.config.logger, this); if (metadata) { const references = metadata.getValueListReferences(mockService.metadataPath); - await Promise.all( - references.map((reference) => - this.createServiceRegistration( + await Promise.allSettled( + references.map(async (reference) => { + const exists = await this.fileLoader.exists(reference.localPath); + if (!exists) { + log.info( + `ValueList reference metadata file not found at "${reference.localPath}". Service "${reference.externalServiceMetadataPath}" will not be provided.` + ); + return undefined; + } + return this.createServiceRegistration( { metadataPath: reference.localPath, urlPath: reference.externalServiceMetadataPath, @@ -160,8 +167,8 @@ export class ServiceRegistry { watch: false }, log - ) - ) + ); + }) ); } diff --git a/packages/fe-mockserver-core/test/unit/middleware.test.ts b/packages/fe-mockserver-core/test/unit/middleware.test.ts index a1f4c3116..bdbadf24f 100644 --- a/packages/fe-mockserver-core/test/unit/middleware.test.ts +++ b/packages/fe-mockserver-core/test/unit/middleware.test.ts @@ -759,8 +759,16 @@ describe('services from ValueListReferences', () => { let loadFileSpy: jest.SpyInstance; beforeAll(async function () { const loadFile = FileSystemLoader.prototype.loadFile; + const exists = FileSystemLoader.prototype.exists; + jest.spyOn(FileSystemLoader.prototype, 'exists').mockImplementation((path): Promise => { + if (path.includes('i_companycodestdvh') && path.includes('metadata.xml')) { + return Promise.resolve(true); + } else { + return exists(path); + } + }); loadFileSpy = jest.spyOn(FileSystemLoader.prototype, 'loadFile').mockImplementation((path): Promise => { - if (path.includes('0001') && path.includes('metadata.xml')) { + if (path.includes('i_companycodestdvh') && path.includes('metadata.xml')) { return Promise.resolve(` @@ -795,7 +803,7 @@ describe('services from ValueListReferences', () => { server.close(done); }); - it('call service from ValueListReferences', async () => { + it.only('call service from ValueListReferences', async () => { const response = await fetch( `http://localhost:33332/sap/srvd_f4/sap/i_companycodestdvh/0001;ps=%27srvd-zrc_arcustomer_definition-0001%27;va=%27com.sap.gateway.srvd.zrc_arcustomer_definition.v0001.et-parameterz_arcustomer2.p_companycode%27/$metadata` ); @@ -817,6 +825,21 @@ describe('services from ValueListReferences', () => { 'metadata.xml' ) ); + expect(loadFileSpy).not.toHaveBeenCalledWith( + path.join( + __dirname, + 'v4', + 'services', + 'parametrizedSample', + 'srvd_f4', + 'sap', + 'i_customer_vh', + '0001', + 'CustomerType', + 'Customer', + 'metadata.xml' + ) + ); }); }); From 8fa318099b3f20a2d9df9254bb465cbaafe23bdf Mon Sep 17 00:00:00 2001 From: C5226337 Date: Thu, 30 Oct 2025 10:29:08 +0200 Subject: [PATCH 06/12] chore: changeset --- .changeset/pink-zebras-judge.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pink-zebras-judge.md diff --git a/.changeset/pink-zebras-judge.md b/.changeset/pink-zebras-judge.md new file mode 100644 index 000000000..9d33e48c8 --- /dev/null +++ b/.changeset/pink-zebras-judge.md @@ -0,0 +1,5 @@ +--- +'@sap-ux/fe-mockserver-core': patch +--- + +feat: dynamically register services defined in `Common.ValueListReferences` annotation From ab0ba874b78cee1d2bad11d8064d8bf92b47ab2a Mon Sep 17 00:00:00 2001 From: C5226337 Date: Mon, 3 Nov 2025 15:16:14 +0200 Subject: [PATCH 07/12] feat: add configuration option --- packages/fe-mockserver-core/src/api.ts | 1 + .../src/data/serviceRegistry.ts | 4 +- .../test/unit/middleware.test.ts | 58 ++++++++++++------- .../ui5-middleware-fe-mockserver/README.md | 3 +- 4 files changed, 42 insertions(+), 24 deletions(-) diff --git a/packages/fe-mockserver-core/src/api.ts b/packages/fe-mockserver-core/src/api.ts index 0e434448d..7a2232a79 100644 --- a/packages/fe-mockserver-core/src/api.ts +++ b/packages/fe-mockserver-core/src/api.ts @@ -99,6 +99,7 @@ export type ServiceConfig = { i18nPath?: string[]; generateMockData?: boolean; forceNullableValuesToNull?: boolean; + resolveValueListReferences?: boolean; debug?: boolean; strictKeyMode?: boolean; watch?: boolean; // should be forced to false in browser diff --git a/packages/fe-mockserver-core/src/data/serviceRegistry.ts b/packages/fe-mockserver-core/src/data/serviceRegistry.ts index f12fc9fca..8da05b37f 100644 --- a/packages/fe-mockserver-core/src/data/serviceRegistry.ts +++ b/packages/fe-mockserver-core/src/data/serviceRegistry.ts @@ -147,7 +147,7 @@ export class ServiceRegistry { } const dataAccess = new DataAccess(mockService, metadata, this.fileLoader, this.config.logger, this); - if (metadata) { + if (mockServiceIn.resolveValueListReferences === true && metadata) { const references = metadata.getValueListReferences(mockService.metadataPath); await Promise.allSettled( references.map(async (reference) => { @@ -162,7 +162,7 @@ export class ServiceRegistry { { metadataPath: reference.localPath, urlPath: reference.externalServiceMetadataPath, - generateMockData: true, + generateMockData: false, mockdataPath: reference.dataPath, watch: false }, diff --git a/packages/fe-mockserver-core/test/unit/middleware.test.ts b/packages/fe-mockserver-core/test/unit/middleware.test.ts index bdbadf24f..4a00652f1 100644 --- a/packages/fe-mockserver-core/test/unit/middleware.test.ts +++ b/packages/fe-mockserver-core/test/unit/middleware.test.ts @@ -756,7 +756,31 @@ Group ID: $auto` describe('services from ValueListReferences', () => { let server: Server; + let serverWithoutAutoLoading: Server; let loadFileSpy: jest.SpyInstance; + async function createServer(resolveValueListReferences: boolean, port: number): Promise { + const mockServer = new FEMockserver({ + services: [ + { + metadataPath: path.join(__dirname, 'v4', 'services', 'parametrizedSample', 'metadata.xml'), + mockdataPath: path.join(__dirname, 'v4', 'services', 'parametrizedSample'), + urlPath: '/sap/fe/core/mock/sticky', + watch: false, + generateMockData: true, + resolveValueListReferences + } + ], + annotations: [], + plugins: [], + contextBasedIsolation: true + }); + await mockServer.isReady; + const server = http.createServer(function onRequest(req, res) { + mockServer.getRouter()(req, res, finalHandler(req, res)); + }); + server.listen(port); + return server; + } beforeAll(async function () { const loadFile = FileSystemLoader.prototype.loadFile; const exists = FileSystemLoader.prototype.exists; @@ -779,31 +803,16 @@ describe('services from ValueListReferences', () => { return loadFile(path); } }); - const mockServer = new FEMockserver({ - services: [ - { - metadataPath: path.join(__dirname, 'v4', 'services', 'parametrizedSample', 'metadata.xml'), - mockdataPath: path.join(__dirname, 'v4', 'services', 'parametrizedSample'), - urlPath: '/sap/fe/core/mock/sticky', - watch: false, - generateMockData: true - } - ], - annotations: [], - plugins: [], - contextBasedIsolation: true - }); - await mockServer.isReady; - server = http.createServer(function onRequest(req, res) { - mockServer.getRouter()(req, res, finalHandler(req, res)); - }); - server.listen(33332); + server = await createServer(true, 33332); + serverWithoutAutoLoading = await createServer(false, 33333); }); afterAll((done) => { - server.close(done); + server.close(() => { + serverWithoutAutoLoading.close(done); + }); }); - it.only('call service from ValueListReferences', async () => { + it('call service from ValueListReferences', async () => { const response = await fetch( `http://localhost:33332/sap/srvd_f4/sap/i_companycodestdvh/0001;ps=%27srvd-zrc_arcustomer_definition-0001%27;va=%27com.sap.gateway.srvd.zrc_arcustomer_definition.v0001.et-parameterz_arcustomer2.p_companycode%27/$metadata` ); @@ -841,6 +850,13 @@ describe('services from ValueListReferences', () => { ) ); }); + it('call service from ValueListReferences with auto loading disabled', async () => { + const response = await fetch( + `http://localhost:33333/sap/srvd_f4/sap/i_companycodestdvh/0001;ps=%27srvd-zrc_arcustomer_definition-0001%27;va=%27com.sap.gateway.srvd.zrc_arcustomer_definition.v0001.et-parameterz_arcustomer2.p_companycode%27/$metadata` + ); + + expect(response.status).toEqual(404); + }); }); describe('V2', function () { diff --git a/packages/ui5-middleware-fe-mockserver/README.md b/packages/ui5-middleware-fe-mockserver/README.md index 0cc580e5a..709c9253a 100644 --- a/packages/ui5-middleware-fe-mockserver/README.md +++ b/packages/ui5-middleware-fe-mockserver/README.md @@ -74,7 +74,8 @@ On top of that you can specify one of the following option - mockdataPath : the path to the folder containing the mockdata files - generateMockData : whether or not you want to use automatically generated mockdata -- forceNullableValuesToNull: determine if nullable properties should be generated as null or with a default value (defaults to false) +- resolveValueListReferences : whether or not to try to resolve all services referenced in `Common.ValueListReferences` annotations and serve their metadata from `localServices` directory. +- forceNullableValuesToNull : determine if nullable properties should be generated as null or with a default value (defaults to false) Additional option are available either per service of for all services if defined globally From 766ee05f6590786138c65049b2b73dbbfb526c97 Mon Sep 17 00:00:00 2001 From: C5226337 Date: Mon, 3 Nov 2025 15:54:23 +0200 Subject: [PATCH 08/12] test: fix tests --- .../test/unit/middleware.test.ts | 153 +++++++++--------- 1 file changed, 81 insertions(+), 72 deletions(-) diff --git a/packages/fe-mockserver-core/test/unit/middleware.test.ts b/packages/fe-mockserver-core/test/unit/middleware.test.ts index 4a00652f1..4a3a1e232 100644 --- a/packages/fe-mockserver-core/test/unit/middleware.test.ts +++ b/packages/fe-mockserver-core/test/unit/middleware.test.ts @@ -755,9 +755,6 @@ Group ID: $auto` }); describe('services from ValueListReferences', () => { - let server: Server; - let serverWithoutAutoLoading: Server; - let loadFileSpy: jest.SpyInstance; async function createServer(resolveValueListReferences: boolean, port: number): Promise { const mockServer = new FEMockserver({ services: [ @@ -781,81 +778,93 @@ describe('services from ValueListReferences', () => { server.listen(port); return server; } - beforeAll(async function () { - const loadFile = FileSystemLoader.prototype.loadFile; - const exists = FileSystemLoader.prototype.exists; - jest.spyOn(FileSystemLoader.prototype, 'exists').mockImplementation((path): Promise => { - if (path.includes('i_companycodestdvh') && path.includes('metadata.xml')) { - return Promise.resolve(true); - } else { - return exists(path); - } + describe('resolveValueListReferences = true', () => { + let server: Server; + let loadFileSpy: jest.SpyInstance; + beforeAll(async function () { + const loadFile = FileSystemLoader.prototype.loadFile; + const exists = FileSystemLoader.prototype.exists; + jest.spyOn(FileSystemLoader.prototype, 'exists').mockImplementation((path): Promise => { + if (path.includes('i_companycodestdvh') && path.includes('metadata.xml')) { + return Promise.resolve(true); + } else { + return exists(path); + } + }); + loadFileSpy = jest + .spyOn(FileSystemLoader.prototype, 'loadFile') + .mockImplementation((path): Promise => { + if (path.includes('i_companycodestdvh') && path.includes('metadata.xml')) { + return Promise.resolve(` + + + + + `); + } else { + return loadFile(path); + } + }); + server = await createServer(true, 33332); }); - loadFileSpy = jest.spyOn(FileSystemLoader.prototype, 'loadFile').mockImplementation((path): Promise => { - if (path.includes('i_companycodestdvh') && path.includes('metadata.xml')) { - return Promise.resolve(` - - - - -`); - } else { - return loadFile(path); - } + afterAll((done) => { + server.close(done); }); - server = await createServer(true, 33332); - serverWithoutAutoLoading = await createServer(false, 33333); - }); - afterAll((done) => { - server.close(() => { - serverWithoutAutoLoading.close(done); - }); - }); - it('call service from ValueListReferences', async () => { - const response = await fetch( - `http://localhost:33332/sap/srvd_f4/sap/i_companycodestdvh/0001;ps=%27srvd-zrc_arcustomer_definition-0001%27;va=%27com.sap.gateway.srvd.zrc_arcustomer_definition.v0001.et-parameterz_arcustomer2.p_companycode%27/$metadata` - ); + it('call service from ValueListReferences', async () => { + const response = await fetch( + `http://localhost:33332/sap/srvd_f4/sap/i_companycodestdvh/0001;ps=%27srvd-zrc_arcustomer_definition-0001%27;va=%27com.sap.gateway.srvd.zrc_arcustomer_definition.v0001.et-parameterz_arcustomer2.p_companycode%27/$metadata` + ); - expect(response.status).toEqual(200); - expect(loadFileSpy).toHaveBeenNthCalledWith( - 2, - path.join( - __dirname, - 'v4', - 'services', - 'parametrizedSample', - 'srvd_f4', - 'sap', - 'i_companycodestdvh', - '0001', - 'CustomerParameters', - 'P_CompanyCode', - 'metadata.xml' - ) - ); - expect(loadFileSpy).not.toHaveBeenCalledWith( - path.join( - __dirname, - 'v4', - 'services', - 'parametrizedSample', - 'srvd_f4', - 'sap', - 'i_customer_vh', - '0001', - 'CustomerType', - 'Customer', - 'metadata.xml' - ) - ); + expect(response.status).toEqual(200); + expect(loadFileSpy).toHaveBeenNthCalledWith( + 2, + path.join( + __dirname, + 'v4', + 'services', + 'parametrizedSample', + 'srvd_f4', + 'sap', + 'i_companycodestdvh', + '0001', + 'CustomerParameters', + 'P_CompanyCode', + 'metadata.xml' + ) + ); + expect(loadFileSpy).not.toHaveBeenCalledWith( + path.join( + __dirname, + 'v4', + 'services', + 'parametrizedSample', + 'srvd_f4', + 'sap', + 'i_customer_vh', + '0001', + 'CustomerType', + 'Customer', + 'metadata.xml' + ) + ); + }); }); - it('call service from ValueListReferences with auto loading disabled', async () => { - const response = await fetch( - `http://localhost:33333/sap/srvd_f4/sap/i_companycodestdvh/0001;ps=%27srvd-zrc_arcustomer_definition-0001%27;va=%27com.sap.gateway.srvd.zrc_arcustomer_definition.v0001.et-parameterz_arcustomer2.p_companycode%27/$metadata` - ); + describe('resolveValueListReferences = false', () => { + let server: Server; + beforeAll(async function () { + server = await createServer(false, 33333); + }); + afterAll((done) => { + server.close(done); + }); + it('call service from ValueListReferences', async () => { + const response = await fetch( + `http://localhost:33333/sap/srvd_f4/sap/i_companycodestdvh/0001;ps=%27srvd-zrc_arcustomer_definition-0001%27;va=%27com.sap.gateway.srvd.zrc_arcustomer_definition.v0001.et-parameterz_arcustomer2.p_companycode%27/$metadata` + ); - expect(response.status).toEqual(404); + expect(response.status).toEqual(404); + }); }); }); From 52def34d0af9ce2c24b5fc3f156b625cfa1fcafd Mon Sep 17 00:00:00 2001 From: C5226337 Date: Mon, 3 Nov 2025 18:04:07 +0200 Subject: [PATCH 09/12] test: fix race conditions --- .../test/unit/middleware.test.ts | 37 +++++++++---------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/packages/fe-mockserver-core/test/unit/middleware.test.ts b/packages/fe-mockserver-core/test/unit/middleware.test.ts index 4a3a1e232..da84bbd3e 100644 --- a/packages/fe-mockserver-core/test/unit/middleware.test.ts +++ b/packages/fe-mockserver-core/test/unit/middleware.test.ts @@ -480,19 +480,13 @@ Content-Type:application/json;charset=UTF-8;IEEE754Compatible=true ); myJSON[0].Prop1 = 'SomethingElse'; fs.writeFileSync(path.join(__dirname, '__testData', 'RootElement.json'), JSON.stringify(myJSON, null, 4)); - let resolveFn: Function; - const myPromise = new Promise((resolve) => { - resolveFn = resolve; - }); - setTimeout(async function () { - dataRequestor = new ODataV4Requestor('http://localhost:33331/sap/fe/core/mock/action'); - dataRes = await dataRequestor.getList('/RootElement').execute(); - expect(dataRes.body.length).toBe(4); - expect(dataRes.body[0].Prop1).toBe('SomethingElse'); - resolveFn(); - }, 1000); - return myPromise; - }); + const sleep = new Promise((resolve) => setTimeout(resolve, 1000)); + await sleep; + dataRequestor = new ODataV4Requestor('http://localhost:33331/sap/fe/core/mock/action'); + dataRes = await dataRequestor.getList('/RootElement').execute(); + expect(dataRes.body.length).toBe(4); + expect(dataRes.body[0].Prop1).toBe('SomethingElse'); + }, 10000); it('ChangeSet failure with single error', async () => { const response = await fetch('http://localhost:33331/sap/fe/core/mock/action/$batch', { @@ -781,7 +775,16 @@ describe('services from ValueListReferences', () => { describe('resolveValueListReferences = true', () => { let server: Server; let loadFileSpy: jest.SpyInstance; - beforeAll(async function () { + + afterAll((done) => { + if (server) { + server.close(done); + } else { + done(); + } + }); + + it('call service from ValueListReferences', async () => { const loadFile = FileSystemLoader.prototype.loadFile; const exists = FileSystemLoader.prototype.exists; jest.spyOn(FileSystemLoader.prototype, 'exists').mockImplementation((path): Promise => { @@ -806,12 +809,6 @@ describe('services from ValueListReferences', () => { } }); server = await createServer(true, 33332); - }); - afterAll((done) => { - server.close(done); - }); - - it('call service from ValueListReferences', async () => { const response = await fetch( `http://localhost:33332/sap/srvd_f4/sap/i_companycodestdvh/0001;ps=%27srvd-zrc_arcustomer_definition-0001%27;va=%27com.sap.gateway.srvd.zrc_arcustomer_definition.v0001.et-parameterz_arcustomer2.p_companycode%27/$metadata` ); From 2b1557e5885ec4a3c59c34287642761351437b5f Mon Sep 17 00:00:00 2001 From: C5226337 Date: Tue, 4 Nov 2025 10:42:12 +0200 Subject: [PATCH 10/12] test: fix test isolation --- .../fe-mockserver-core/src/data/serviceRegistry.ts | 10 +++++++++- packages/fe-mockserver-core/src/index.ts | 4 ++++ .../fe-mockserver-core/test/unit/middleware.test.ts | 9 +++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/fe-mockserver-core/src/data/serviceRegistry.ts b/packages/fe-mockserver-core/src/data/serviceRegistry.ts index 8da05b37f..afa54fb13 100644 --- a/packages/fe-mockserver-core/src/data/serviceRegistry.ts +++ b/packages/fe-mockserver-core/src/data/serviceRegistry.ts @@ -1,4 +1,5 @@ import type { ILogger } from '@ui5/logger'; +import type { FSWatcher } from 'chokidar'; import etag from 'etag'; import type { IncomingMessage, ServerResponse } from 'http'; import type { IRouter } from 'router'; @@ -55,6 +56,7 @@ export class ServiceRegistry { private readonly services: Map = new Map(); private readonly aliases: Map = new Map(); private readonly registrations: Map = new Map(); + private readonly watchers: FSWatcher[] = []; private config: MockserverConfiguration; private isOpened: boolean = false; @@ -181,7 +183,7 @@ export class ServiceRegistry { watchPath.push(mockService.metadataPath); } const chokidar = await import('chokidar'); - chokidar + const watcher = chokidar .watch(watchPath, { ignoreInitial: true }) @@ -194,6 +196,7 @@ export class ServiceRegistry { dataAccess.reloadData(metadata); log.info(`Service ${mockService.urlPath} restarted`); }); + this.watchers.push(watcher); } const oDataHandlerInstance = await serviceRouter(mockService, dataAccess); @@ -352,4 +355,9 @@ export class ServiceRegistry { }) .join(', '); } + public async dispose(): Promise { + for (const watcher of this.watchers) { + await watcher.close(); + } + } } diff --git a/packages/fe-mockserver-core/src/index.ts b/packages/fe-mockserver-core/src/index.ts index 28d0807ae..fb7d7ee23 100644 --- a/packages/fe-mockserver-core/src/index.ts +++ b/packages/fe-mockserver-core/src/index.ts @@ -62,4 +62,8 @@ export default class FEMockserver { getRouter() { return this.mainRouter; } + + async dispose(): Promise { + await this.serviceRegistry.dispose(); + } } diff --git a/packages/fe-mockserver-core/test/unit/middleware.test.ts b/packages/fe-mockserver-core/test/unit/middleware.test.ts index da84bbd3e..7fff0df44 100644 --- a/packages/fe-mockserver-core/test/unit/middleware.test.ts +++ b/packages/fe-mockserver-core/test/unit/middleware.test.ts @@ -63,6 +63,9 @@ describe('V4 Requestor', function () { server = http.createServer(function onRequest(req, res) { mockServer.getRouter()(req, res, finalHandler(req, res)); }); + server.on('close', async () => { + await mockServer.dispose(); + }); server.listen(33331); }); afterAll((done) => { @@ -770,6 +773,9 @@ describe('services from ValueListReferences', () => { mockServer.getRouter()(req, res, finalHandler(req, res)); }); server.listen(port); + server.on('close', async () => { + await mockServer.dispose(); + }); return server; } describe('resolveValueListReferences = true', () => { @@ -890,6 +896,9 @@ describe('V2', function () { mockServer.getRouter()(req, res, finalHandler(req, res)); }); server.listen(33331); + server.on('close', async () => { + await mockServer.dispose(); + }); }); afterAll((done) => { From 93ea121be7ba5770620513d865c8d3febc29365a Mon Sep 17 00:00:00 2001 From: C5226337 Date: Tue, 4 Nov 2025 11:18:08 +0200 Subject: [PATCH 11/12] fix: path conversion on windows --- packages/fe-mockserver-core/src/data/metadata.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fe-mockserver-core/src/data/metadata.ts b/packages/fe-mockserver-core/src/data/metadata.ts index c8f7dad0e..54a2ded4d 100644 --- a/packages/fe-mockserver-core/src/data/metadata.ts +++ b/packages/fe-mockserver-core/src/data/metadata.ts @@ -260,7 +260,7 @@ export class ODataMetadata { let prefix = '/'; let currentSegment = segments.shift(); while (currentSegment !== undefined) { - const next = join(prefix, currentSegment); + const next = joinPosix(prefix, currentSegment); if (!rootPath.startsWith(next)) { break; } From 45419b2992cbc81710f0fcceec81eb5e7bf2535d Mon Sep 17 00:00:00 2001 From: C5226337 Date: Tue, 4 Nov 2025 15:27:45 +0200 Subject: [PATCH 12/12] fix: config resolution --- packages/fe-mockserver-core/src/api.ts | 3 +++ .../src/configResolver.ts | 1 + .../test/configResolver.test.ts | 21 +++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/packages/fe-mockserver-core/src/api.ts b/packages/fe-mockserver-core/src/api.ts index 7a2232a79..ca04c0ecf 100644 --- a/packages/fe-mockserver-core/src/api.ts +++ b/packages/fe-mockserver-core/src/api.ts @@ -13,6 +13,7 @@ export interface Service { cdsServiceName?: string; debug?: boolean; contextBasedIsolation?: boolean; + resolveValueListReferences?: boolean; strictKeyMode?: boolean; watch?: boolean; noETag?: boolean; @@ -27,6 +28,7 @@ export interface ConfigService { mockdataRootPath?: string; mockdataPath?: string; generateMockData?: boolean; + resolveValueListReferences?: boolean; metadataCdsPath?: string; metadataPath?: string; cdsServiceName?: string; @@ -69,6 +71,7 @@ export interface BaseServerConfig { logger?: ILogger; validateETag?: boolean; contextBasedIsolation?: boolean; + resolveValueListReferences?: boolean; generateMockData?: boolean; forceNullableValuesToNull?: boolean; fileLoader?: string; diff --git a/packages/ui5-middleware-fe-mockserver/src/configResolver.ts b/packages/ui5-middleware-fe-mockserver/src/configResolver.ts index bca72e75a..4b83bf700 100644 --- a/packages/ui5-middleware-fe-mockserver/src/configResolver.ts +++ b/packages/ui5-middleware-fe-mockserver/src/configResolver.ts @@ -104,6 +104,7 @@ function processServicesConfig( generateMockData: inService.generateMockData, contextBasedIsolation: inService.contextBasedIsolation, forceNullableValuesToNull: inService.forceNullableValuesToNull, + resolveValueListReferences: inService.resolveValueListReferences, metadataProcessor: inService.metadataProcessor, i18nPath: inService.i18nPath, __captureAndSimulate: inService.__captureAndSimulate diff --git a/packages/ui5-middleware-fe-mockserver/test/configResolver.test.ts b/packages/ui5-middleware-fe-mockserver/test/configResolver.test.ts index 63053a96e..3e6e92b0f 100644 --- a/packages/ui5-middleware-fe-mockserver/test/configResolver.test.ts +++ b/packages/ui5-middleware-fe-mockserver/test/configResolver.test.ts @@ -128,6 +128,27 @@ describe('The config resolver', () => { expect(myBaseResolvedConfig2.services.length).toBe(0); }); + it('can also resolve resolveValueListReferences', () => { + const myBaseResolvedConfig = resolveConfig( + { + annotations: { + localPath: 'myAnnotation.xml', + urlPath: '/my/Annotation.xml' + }, + service: { + urlBasePath: '/my/service', + name: 'URL', + metadataCdsPath: 'metadata.cds', + mockdataRootPath: 'mockData', + resolveValueListReferences: true + } + }, + '/' + ); + + expect(myBaseResolvedConfig.services[0].resolveValueListReferences).toBe(true); + }); + it('can also apply overrides per service', () => { const myBaseResolvedConfig = resolveConfig( {