From 7ec0e8450f021687e9d537fba2475f818f551b2f Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Mon, 6 Apr 2026 18:51:40 +0300 Subject: [PATCH 01/31] feat: Extend add-new-model generator to support external services for CF projects --- packages/adp-tooling/src/btp/api.ts | 65 ++++ packages/adp-tooling/src/btp/index.ts | 1 + packages/adp-tooling/src/cf/app/html5-repo.ts | 26 +- packages/adp-tooling/src/cf/project/index.ts | 1 + .../src/cf/project/ui5-app-info.ts | 63 +++- packages/adp-tooling/src/cf/project/yaml.ts | 50 ++- packages/adp-tooling/src/cf/services/api.ts | 41 ++- packages/adp-tooling/src/cf/services/cli.ts | 49 +++ .../src/cf/services/destinations.ts | 59 +++ packages/adp-tooling/src/cf/services/index.ts | 1 + packages/adp-tooling/src/cf/services/ssh.ts | 64 ++++ .../src/prompts/add-new-model/index.ts | 278 +++++++------- packages/adp-tooling/src/prompts/index.ts | 2 +- .../src/translations/adp-tooling.i18n.json | 15 +- packages/adp-tooling/src/types.ts | 50 ++- packages/adp-tooling/src/writer/cf.ts | 35 +- .../src/writer/changes/writer-factory.ts | 9 +- .../changes/writers/new-model-writer.ts | 88 ++++- packages/adp-tooling/src/writer/editors.ts | 7 +- .../test/unit/btp/api.test.ts\342\200\216" | 128 +++++++ .../test/unit/cf/app/html5-repo.test.ts | 48 +-- .../test/unit/cf/project/ui5-app-info.test.ts | 222 ++++++++++- .../test/unit/cf/project/yaml.test.ts | 106 +++++- .../test/unit/cf/services/api.test.ts | 55 ++- .../unit/cf/services/destinations.test.ts | 132 +++++++ .../test/unit/cf/services/ssh.test.ts | 135 +++++++ .../unit/prompts/add-new-model/index.test.ts | 347 +++++++++--------- .../adp-tooling/test/unit/writer/cf.test.ts | 87 +---- .../unit/writer/changes/writers/index.test.ts | 236 +++++++++++- .../test/unit/writer/editors.test.ts | 4 +- packages/create/README.md | 3 - packages/create/package.json | 1 + packages/create/src/cli/add/new-model.ts | 46 ++- .../test/unit/cli/add/new-model.test.ts | 114 ++++-- packages/create/tsconfig.json | 3 + .../generator-adp/src/add-new-model/index.ts | 64 +++- .../test/unit/add-new-model/index.test.ts | 104 +++++- pnpm-lock.yaml | 16 +- 38 files changed, 2163 insertions(+), 592 deletions(-) create mode 100644 packages/adp-tooling/src/btp/api.ts create mode 100644 packages/adp-tooling/src/btp/index.ts create mode 100644 packages/adp-tooling/src/cf/services/destinations.ts create mode 100644 packages/adp-tooling/src/cf/services/ssh.ts create mode 100644 "packages/adp-tooling/test/unit/btp/api.test.ts\342\200\216" create mode 100644 packages/adp-tooling/test/unit/cf/services/destinations.test.ts create mode 100644 packages/adp-tooling/test/unit/cf/services/ssh.test.ts diff --git a/packages/adp-tooling/src/btp/api.ts b/packages/adp-tooling/src/btp/api.ts new file mode 100644 index 00000000000..4713e4e4571 --- /dev/null +++ b/packages/adp-tooling/src/btp/api.ts @@ -0,0 +1,65 @@ +import axios from 'axios'; + +import type { ToolsLogger } from '@sap-ux/logger'; + +import { t } from '../i18n'; +import type { Uaa, BtpDestinationConfig } from '../types'; + +/** + * Obtain an OAuth2 access token using the client credentials grant. + * + * @param uaa - UAA service credentials (clientid, clientsecret, url). + * @param logger - Optional logger. + * @returns OAuth2 access token. + */ +export async function getToken(uaa: Uaa, logger?: ToolsLogger): Promise { + const auth = Buffer.from(`${uaa.clientid}:${uaa.clientsecret}`); + const options = { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': 'Basic ' + auth.toString('base64') + } + }; + const uri = `${uaa.url}/oauth/token`; + logger?.debug(`Requesting OAuth token from ${uri}`); + try { + const response = await axios.post(uri, 'grant_type=client_credentials', options); + logger?.debug('OAuth token obtained successfully'); + return response.data['access_token']; + } catch (e) { + logger?.error(`Failed to obtain OAuth token from ${uri}: ${e.message}`); + throw new Error(t('error.failedToGetAuthKey', { error: e.message })); + } +} + +/** + * Get a single destination's configuration from the BTP Destination Configuration API. + * Note: This calls the BTP Destination Configuration API, not the BAS listDestinations API. + * + * @param uri - Destination Configuration API base URI (e.g. https://destination-configuration.cfapps.us20.hana.ondemand.com). + * @param token - OAuth2 bearer token obtained via {@link getToken}. + * @param destinationName - Name of the destination to look up. + * @param logger - Optional logger. + * @returns The destinationConfiguration object (e.g. Name, ProxyType, URL, Authentication) or undefined on failure. + */ +export async function getBtpDestinationConfig( + uri: string, + token: string, + destinationName: string, + logger?: ToolsLogger +): Promise { + const url = `${uri}/destination-configuration/v1/destinations/${encodeURIComponent(destinationName)}`; + logger?.debug(`Fetching BTP destination config for "${destinationName}" from ${url}`); + + try { + const response = await axios.get<{ destinationConfiguration?: BtpDestinationConfig }>(url, { + headers: { 'Authorization': `Bearer ${token}` } + }); + const config = response.data?.destinationConfiguration; + logger?.debug(`Destination "${destinationName}" config: ProxyType=${config?.ProxyType}`); + return config; + } catch (e) { + logger?.error(`Failed to fetch destination config for "${destinationName}": ${e.message}`); + return undefined; + } +} diff --git a/packages/adp-tooling/src/btp/index.ts b/packages/adp-tooling/src/btp/index.ts new file mode 100644 index 00000000000..b1c13e73406 --- /dev/null +++ b/packages/adp-tooling/src/btp/index.ts @@ -0,0 +1 @@ +export * from './api'; diff --git a/packages/adp-tooling/src/cf/app/html5-repo.ts b/packages/adp-tooling/src/cf/app/html5-repo.ts index e76a49b497e..bbdfe6e8619 100644 --- a/packages/adp-tooling/src/cf/app/html5-repo.ts +++ b/packages/adp-tooling/src/cf/app/html5-repo.ts @@ -5,34 +5,12 @@ import type { ToolsLogger } from '@sap-ux/logger'; import type { Manifest } from '@sap-ux/project-access'; import { t } from '../../i18n'; +import { getToken } from '../../btp/api'; import { getServiceNameByTags, getOrCreateServiceInstanceKeys, createServiceInstance } from '../services/api'; -import type { HTML5Content, ServiceInfo, Uaa, CfAppParams } from '../../types'; +import type { HTML5Content, ServiceInfo, CfAppParams } from '../../types'; const HTML5_APPS_REPO_RUNTIME = 'html5-apps-repo-runtime'; -/** - * Get the OAuth token from HTML5 repository. - * - * @param {Uaa} uaa UAA credentials - * @returns {Promise} OAuth token - */ -export async function getToken(uaa: Uaa): Promise { - const auth = Buffer.from(`${uaa.clientid}:${uaa.clientsecret}`); - const options = { - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Basic ' + auth.toString('base64') - } - }; - const uri = `${uaa.url}/oauth/token?grant_type=client_credentials`; - try { - const response = await axios.get(uri, options); - return response.data['access_token']; - } catch (e) { - throw new Error(t('error.failedToGetAuthKey', { error: e.message })); - } -} - /** * Download zip from HTML5 repository. * diff --git a/packages/adp-tooling/src/cf/project/index.ts b/packages/adp-tooling/src/cf/project/index.ts index 71de22604f9..e9b60e377ff 100644 --- a/packages/adp-tooling/src/cf/project/index.ts +++ b/packages/adp-tooling/src/cf/project/index.ts @@ -1,3 +1,4 @@ export * from './yaml'; export * from './yaml-loader'; export * from './mta'; +export * from './ui5-app-info'; diff --git a/packages/adp-tooling/src/cf/project/ui5-app-info.ts b/packages/adp-tooling/src/cf/project/ui5-app-info.ts index 4882314d53d..4b9ff725383 100644 --- a/packages/adp-tooling/src/cf/project/ui5-app-info.ts +++ b/packages/adp-tooling/src/cf/project/ui5-app-info.ts @@ -2,8 +2,31 @@ import fs from 'node:fs'; import path from 'node:path'; import type { ToolsLogger } from '@sap-ux/logger'; +import { readUi5Yaml } from '@sap-ux/project-access'; -import type { CfUi5AppInfo } from '../../types'; +import type { CfConfig, CfUi5AppInfo } from '../../types'; +import { t } from '../../i18n'; +import { getBaseAppId } from '../../base/helper'; +import { getCfUi5AppInfo, getOrCreateServiceInstanceKeys } from '../services/api'; +import { getAppHostIds } from '../app/discovery'; + +/** + * Fetches ui5AppInfo.json content and writes it to the project root. + * + * @param basePath - path to application root + * @param ui5AppInfo - ui5AppInfo.json content + * @param logger - logger instance + */ +export async function writeUi5AppInfo(basePath: string, ui5AppInfo: CfUi5AppInfo, logger?: ToolsLogger): Promise { + try { + const ui5AppInfoTargetPath = path.join(basePath, 'ui5AppInfo.json'); + fs.writeFileSync(ui5AppInfoTargetPath, JSON.stringify(ui5AppInfo, null, 2), 'utf-8'); + logger?.info(`Written ui5AppInfo.json to ${basePath}`); + } catch (error) { + logger?.error(`Failed to process ui5AppInfo.json: ${(error as Error).message}`); + throw error; + } +} /** * Extracts reusable library paths from ui5AppInfo.json if it exists. @@ -42,3 +65,41 @@ export function getReusableLibraryPaths( }; }); } + +/** + * Downloads ui5AppInfo.json from the FDC service and writes it to the project root. + * Reads the html5-apps-repo service instance name from the app-variant-bundler-build + * task in ui5.yaml, fetches service keys, then calls the FDC API. + * + * @param projectPath - path to application root + * @param cfConfig - CF configuration (token, url, space) + * @param logger - optional logger instance + */ +export async function downloadUi5AppInfo(projectPath: string, cfConfig: CfConfig, logger?: ToolsLogger): Promise { + const ui5Config = await readUi5Yaml(projectPath, 'ui5.yaml'); + const bundlerTask = ui5Config.findCustomTask<{ serviceInstanceName?: string; space?: string }>( + 'app-variant-bundler-build' + ); + const serviceInstanceName = bundlerTask?.configuration?.serviceInstanceName; + if (!serviceInstanceName) { + throw new Error(t('error.noServiceInstanceNameFound')); + } + + const spaceGuid = bundlerTask?.configuration?.space; + const serviceInfo = await getOrCreateServiceInstanceKeys( + { names: [serviceInstanceName], ...(spaceGuid ? { spaceGuids: [spaceGuid] } : {}) }, + logger + ); + if (!serviceInfo || serviceInfo.serviceKeys.length === 0) { + throw new Error(`No service keys found for service instance: ${serviceInstanceName}`); + } + + const appId = await getBaseAppId(projectPath); + const appHostIds = getAppHostIds(serviceInfo.serviceKeys); + if (appHostIds.length === 0) { + throw new Error('No app host IDs found in service keys.'); + } + + const ui5AppInfo = await getCfUi5AppInfo(appId, appHostIds, cfConfig, logger); + await writeUi5AppInfo(projectPath, ui5AppInfo, logger); +} diff --git a/packages/adp-tooling/src/cf/project/yaml.ts b/packages/adp-tooling/src/cf/project/yaml.ts index ece681d2d0d..bf1668399bd 100644 --- a/packages/adp-tooling/src/cf/project/yaml.ts +++ b/packages/adp-tooling/src/cf/project/yaml.ts @@ -17,7 +17,7 @@ import type { ServiceKeys } from '../../types'; import { AppRouterType } from '../../types'; -import { createServices } from '../services/api'; +import { createServices, createServiceInstance, getOrCreateServiceInstanceKeys } from '../services/api'; import { getProjectNameForXsSecurity, getYamlContent } from './yaml-loader'; import { getBackendUrlsWithPaths, getServiceKeyDestinations } from '../app/discovery'; import { getVariant } from '../../base/helper'; @@ -47,6 +47,54 @@ export function isMtaProject(selectedPath: string): boolean { return fs.existsSync(path.join(selectedPath, 'mta.yaml')); } +/** + * Adds a connectivity service resource to the project's mta.yaml if not already present, + * creates the CF service instance and generates a service key for it. + * Only applies to MTA projects. Required when the selected CF destination is OnPremise + * so the AppRouter can proxy requests through the Cloud Connector. + * + * @param {string} projectPath - The root path of the project. + * @param {Editor} memFs - The mem-fs editor instance. + * @param {ToolsLogger} [logger] - Optional logger. + */ +export async function addConnectivityServiceToMta( + projectPath: string, + memFs: Editor, + logger?: ToolsLogger +): Promise { + if (!isMtaProject(projectPath)) { + return; + } + + const mtaYamlPath = path.join(projectPath, 'mta.yaml'); + const yamlContent = getYamlContent(mtaYamlPath); + if (!yamlContent) { + return; + } + + const projectName = yamlContent.ID.toLowerCase(); + const connectivityResourceName = `${projectName}-connectivity`; + + if (yamlContent.resources?.some((r: MtaResource) => r.name === connectivityResourceName)) { + return; + } + + await createServiceInstance('lite', connectivityResourceName, 'connectivity', { logger }); + await getOrCreateServiceInstanceKeys({ names: [connectivityResourceName] }, logger); + + yamlContent.resources = yamlContent.resources ?? []; + yamlContent.resources.push({ + name: connectivityResourceName, + type: CF_MANAGED_SERVICE, + parameters: { + service: 'connectivity', + 'service-plan': 'lite' + } + }); + + memFs.write(mtaYamlPath, yaml.dump(yamlContent, { lineWidth: -1 })); +} + /** * Gets the SAP Cloud Service. * diff --git a/packages/adp-tooling/src/cf/services/api.ts b/packages/adp-tooling/src/cf/services/api.ts index 64332eb5b33..2d12547e922 100644 --- a/packages/adp-tooling/src/cf/services/api.ts +++ b/packages/adp-tooling/src/cf/services/api.ts @@ -20,11 +20,15 @@ import type { CfServiceInstance, MtaYaml, ServiceInfo, - CfUi5AppInfo + CfUi5AppInfo, + CfDestinationServiceCredentials, + BtpDestinationConfig } from '../../types'; +import type { Destinations } from '@sap-ux/btp-utils'; import { t } from '../../i18n'; import { getProjectNameForXsSecurity } from '../project'; import { createServiceKey, getServiceKeys, requestCfApi } from './cli'; +import { getToken } from '../../btp/api'; interface FDCResponse { results: CFApp[]; @@ -437,3 +441,38 @@ export async function getOrCreateServiceKeys( throw new Error(t('error.failedToGetOrCreateServiceKeys', { serviceInstanceName, error: e.message })); } } + +/** + * Lists all subaccount destinations from the BTP Destination Configuration API. + * This works in both VS Code and BAS, as long as valid destination service credentials are provided. + * + * @param {CfDestinationServiceCredentials} credentials - Destination service credentials (uri + uaa). + * @returns {Promise} Map of destination name to Destination object. + */ +export async function listBtpDestinations(credentials: CfDestinationServiceCredentials): Promise { + const uaa = + 'uaa' in credentials + ? credentials.uaa + : { clientid: credentials.clientid, clientsecret: credentials.clientsecret, url: credentials.url }; + const token = await getToken(uaa); + const url = `${credentials.uri}/destination-configuration/v1/subaccountDestinations`; + try { + const response = await axios.get(url, { + headers: { Authorization: `Bearer ${token}` } + }); + const configs = Array.isArray(response.data) ? response.data : []; + return configs.reduce((acc, config) => { + acc[config.Name] = { + Name: config.Name, + Host: config.URL, + Type: config.Type, + Authentication: config.Authentication, + ProxyType: config.ProxyType, + Description: config.Description ?? '' + }; + return acc; + }, {}); + } catch (e) { + throw new Error(t('error.failedToListBtpDestinations', { error: e.message })); + } +} diff --git a/packages/adp-tooling/src/cf/services/cli.ts b/packages/adp-tooling/src/cf/services/cli.ts index f4a9ee0853b..ffe27399eee 100644 --- a/packages/adp-tooling/src/cf/services/cli.ts +++ b/packages/adp-tooling/src/cf/services/cli.ts @@ -131,3 +131,52 @@ export async function requestCfApi(url: string): Promise { throw new Error(t('error.failedToRequestCFAPI', { error: e.message })); } } + +/** + * Check whether a CF app exists. + * + * @param appName - CF app name. + * @returns True if the app exists. + */ +export async function checkAppExists(appName: string): Promise { + const result = await Cli.execute(['app', appName], ENV); + return result.exitCode === 0; +} + +/** + * Push a minimal no-route CF app from a given directory. + * + * @param appName - CF app name. + * @param appPath - Local path to push. + * @param args - Additional cf push arguments. + */ +export async function pushApp(appName: string, appPath: string, args: string[] = []): Promise { + const result = await Cli.execute(['push', appName, '-p', appPath, ...args], ENV); + if (result.exitCode !== 0) { + throw new Error(t('error.cfPushFailed', { appName, error: result.stderr })); + } +} + +/** + * Enable SSH access on a CF app. + * + * @param appName - CF app name. + */ +export async function enableSsh(appName: string): Promise { + const result = await Cli.execute(['enable-ssh', appName], ENV); + if (result.exitCode !== 0) { + throw new Error(t('error.cfEnableSshFailed', { appName, error: result.stderr })); + } +} + +/** + * Restart a CF app using rolling strategy. + * + * @param appName - CF app name. + */ +export async function restartApp(appName: string): Promise { + const result = await Cli.execute(['restart', appName, '--strategy', 'rolling', '--no-wait'], ENV); + if (result.exitCode !== 0) { + throw new Error(t('error.cfRestartFailed', { appName, error: result.stderr })); + } +} diff --git a/packages/adp-tooling/src/cf/services/destinations.ts b/packages/adp-tooling/src/cf/services/destinations.ts new file mode 100644 index 00000000000..9943be7cacf --- /dev/null +++ b/packages/adp-tooling/src/cf/services/destinations.ts @@ -0,0 +1,59 @@ +import * as path from 'node:path'; + +import { isAppStudio, listDestinations } from '@sap-ux/btp-utils'; +import type { Destinations } from '@sap-ux/btp-utils'; + +import { getOrCreateServiceInstanceKeys, listBtpDestinations } from './api'; +import { getYamlContent } from '../project/yaml-loader'; +import { t } from '../../i18n'; +import type { CfDestinationServiceCredentials, MtaYaml } from '../../types'; + +/** + * Finds the name of the destination service instance declared in the MTA project's mta.yaml. + * The mta.yaml lives one level above the app project directory. + * + * @param {string} projectPath - The root path of the app project. + * @returns {string} The CF service instance name. + * @throws {Error} If the destination service instance is not found or mta.yaml cannot be read. + */ +function getDestinationServiceName(projectPath: string): string { + try { + const yamlContent = getYamlContent(path.join(path.dirname(projectPath), 'mta.yaml')); + const name = yamlContent?.resources?.find((r) => r.parameters?.service === 'destination')?.name; + if (!name) { + throw new Error(t('error.destinationServiceNotFoundInMtaYaml')); + } + return name; + } catch (e) { + throw e instanceof Error ? e : new Error(t('error.destinationServiceNotFoundInMtaYaml')); + } +} + +/** + * Returns the list of available BTP destinations for the current environment. + * + * - In SAP Business Application Studio: uses the BAS destination API (`listDestinations`). + * - In VS Code: reads the destination service credentials from the CF project's service keys + * and calls the BTP Destination Configuration API directly. + * + * Returns an empty map when the destination service instance cannot be located or its + * credentials are not yet available (e.g. the service has not been provisioned yet). + * + * @param {string} projectPath - The root path of the CF app project. + * @returns {Promise} Map of destination name to Destination object. + */ +export async function getDestinations(projectPath: string): Promise { + if (isAppStudio()) { + return listDestinations(); + } + + const destinationServiceName = getDestinationServiceName(projectPath); + + const serviceInfo = await getOrCreateServiceInstanceKeys({ names: [destinationServiceName] }); + if (!serviceInfo?.serviceKeys?.length) { + throw new Error(t('error.noServiceKeysFoundForDestination', { serviceInstanceName: destinationServiceName })); + } + + const credentials = serviceInfo.serviceKeys[0].credentials as CfDestinationServiceCredentials; + return listBtpDestinations(credentials); +} diff --git a/packages/adp-tooling/src/cf/services/index.ts b/packages/adp-tooling/src/cf/services/index.ts index 6aa2dd820f2..d6bfd8cc39b 100644 --- a/packages/adp-tooling/src/cf/services/index.ts +++ b/packages/adp-tooling/src/cf/services/index.ts @@ -1,3 +1,4 @@ export * from './api'; export * from './cli'; +export * from './destinations'; export * from './manifest'; diff --git a/packages/adp-tooling/src/cf/services/ssh.ts b/packages/adp-tooling/src/cf/services/ssh.ts new file mode 100644 index 00000000000..fcc9a1885a9 --- /dev/null +++ b/packages/adp-tooling/src/cf/services/ssh.ts @@ -0,0 +1,64 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import type { ToolsLogger } from '@sap-ux/logger'; + +import { checkAppExists, pushApp, enableSsh, restartApp } from './cli'; + +/** + * Default CF app name used for SSH tunneling to the connectivity proxy. + */ +export const DEFAULT_TUNNEL_APP_NAME = 'adp-ssh-tunnel-app'; + +/** + * Ensure a tunnel app exists in CF. If not found, deploy a minimal no-route app + * using the binary_buildpack with minimum memory so it can serve as an SSH target. + * + * @param appName - CF app name. + * @param logger - Logger instance. + */ +export async function ensureTunnelAppExists(appName: string, logger: ToolsLogger): Promise { + if (await checkAppExists(appName)) { + logger.info(`Tunnel app "${appName}" already exists.`); + return; + } + + logger.debug(`Tunnel app "${appName}" not found. Deploying minimal app...`); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adp-tunnel-')); + fs.writeFileSync(path.join(tmpDir, '.keep'), ''); + + try { + await pushApp(appName, tmpDir, [ + '--no-route', + '-m', + '64M', + '-k', + '256M', + '-b', + 'binary_buildpack', + '-c', + 'sleep infinity', + '--health-check-type', + 'process' + ]); + logger.info(`Tunnel app "${appName}" deployed successfully.`); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +/** + * Enable SSH on a CF app and restart it. + * + * @param appName - CF app name. + * @param logger - Logger instance. + */ +export async function enableSshAndRestart(appName: string, logger: ToolsLogger): Promise { + logger.info(`Enabling SSH on "${appName}"...`); + await enableSsh(appName); + + logger.info(`Restarting "${appName}"...`); + await restartApp(appName); +} diff --git a/packages/adp-tooling/src/prompts/add-new-model/index.ts b/packages/adp-tooling/src/prompts/add-new-model/index.ts index 26c080a34ec..e8632ba6a28 100644 --- a/packages/adp-tooling/src/prompts/add-new-model/index.ts +++ b/packages/adp-tooling/src/prompts/add-new-model/index.ts @@ -1,3 +1,6 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'path'; + import type { ListQuestion, InputQuestion, @@ -6,17 +9,26 @@ import type { ConfirmQuestion } from '@sap-ux/inquirer-common'; import type { UI5FlexLayer } from '@sap-ux/project-access'; +import type { Destination } from '@sap-ux/btp-utils'; +import { listDestinations } from '@sap-ux/btp-utils'; +import { Severity, type IMessageSeverity } from '@sap-devx/yeoman-ui-types'; import { t } from '../../i18n'; import { getChangesByType } from '../../base/change-utils'; +import { getDestinations } from '../../cf/services/destinations'; import { ChangeType, NamespacePrefix, + ServiceType, type NewModelAnswers, type ManifestChangeProperties, - FlexLayer + type AdpPreviewConfigWithTarget, + FlexLayer, + type XsApp, + type XsAppRoute } from '../../types'; import { isCFEnvironment } from '../../base/cf'; +import { getAdpConfig } from '../../base/helper'; import { validateEmptyString, validateEmptySpaces, @@ -27,9 +39,27 @@ import { validateJSON } from '@sap-ux/project-input-validator'; -const oDataVersions = [ - { name: '2.0', value: '2.0' }, - { name: '4.0', value: '4.0' } +/** + * Reads the routes array from the xs-app.json file in the project's webapp folder. + * Returns an empty array if the file does not exist or cannot be parsed. + * + * @param {string} projectPath - The root path of the project. + * @returns {XsAppRoute[]} The existing routes. + */ +function readXsAppRoutes(projectPath: string): XsAppRoute[] { + try { + const xsAppPath = join(projectPath, 'webapp', 'xs-app.json'); + const content = JSON.parse(readFileSync(xsAppPath, 'utf-8')) as XsApp; + return Array.isArray(content?.routes) ? content.routes : []; + } catch { + return []; + } +} + +const serviceTypeChoices = [ + { name: ServiceType.ODATA_V2, value: ServiceType.ODATA_V2 }, + { name: ServiceType.ODATA_V4, value: ServiceType.ODATA_V4 }, + { name: ServiceType.HTTP, value: ServiceType.HTTP } ]; /** @@ -95,14 +125,12 @@ function validatePromptJSON(value: string): boolean | string { * Validates the OData Service name prompt. * * @param value The value to validate. - * @param answers The answers object. * @param isCustomerBase Whether the validation is for customer usage. * @param changeFiles The list of existing change files to check against. * @returns {boolean | string} True if no duplication is found, or an error message if validation fails. */ function validatePromptODataName( value: string, - answers: NewModelAnswers, isCustomerBase: boolean, changeFiles: ManifestChangeProperties[] ): boolean | string { @@ -112,7 +140,7 @@ function validatePromptODataName( } if (isCustomerBase) { - validationResult = validateCustomerValue(value, 'prompts.oDataServiceNameLabel'); + validationResult = validateCustomerValue(value, 'prompts.modelAndDatasourceNameLabel'); if (typeof validationResult === 'string') { return validationResult; } @@ -122,100 +150,86 @@ function validatePromptODataName( return t('validators.errorDuplicatedValueOData'); } - if (answers.addAnnotationMode && value === answers.dataSourceName) { - return t('validators.errorDuplicateNamesOData'); - } - return true; } /** - * Validates the OData Annotation name prompt. + * Validates the OData Source URI prompt. * * @param value The value to validate. - * @param answers The answers object. - * @param isCustomerBase Whether the validation is for customer usage. - * @param changeFiles The list of existing change files to check against. - * @returns {boolean | string} True if no duplication is found, or an error message if validation fails. + * @returns {boolean | string} True if the URI is valid, or an error message if validation fails. */ -function validatePromptODataAnnotationsName( - value: string, - answers: NewModelAnswers, - isCustomerBase: boolean, - changeFiles: ManifestChangeProperties[] -): boolean | string { - let validationResult = validatePromptInput(value); +function validatePromptURI(value: string): boolean | string { + const validationResult = validateEmptyString(value); if (typeof validationResult === 'string') { return validationResult; } - if (isCustomerBase) { - validationResult = validateCustomerValue(value, 'prompts.oDataAnnotationDataSourceNameLabel'); - if (typeof validationResult === 'string') { - return validationResult; - } - } - - if (hasContentDuplication(value, 'dataSource', changeFiles)) { - return t('validators.errorDuplicatedValueOData'); - } - - if (value === answers.name) { - return t('validators.errorDuplicateNamesOData'); + if (!isDataSourceURI(value)) { + return t('validators.errorInvalidDataSourceURI'); } return true; } /** - * Validates the model name prompts. + * Builds the full resulting service URL from a destination URL and a service URI. + * Returns undefined if either value is absent or the URI fails basic validation. * - * @param value The value to validate. - * @param isCustomerBase Whether the validation is for customer usage. - * @param changeFiles The list of existing change files to check against. - * @returns {boolean | string} True if no duplication is found, or an error message if validation fails. + * @param {string | undefined} destinationUrl - The destination base URL. + * @param {string | undefined} serviceUri - The relative service URI from the prompt. + * @returns {string | undefined} The concatenated URL, or undefined if it cannot be formed. */ -function validatePromptModelName( - value: string, - isCustomerBase: boolean, - changeFiles: ManifestChangeProperties[] -): boolean | string { - let validationResult = validatePromptInput(value); - if (typeof validationResult === 'string') { - return validationResult; +function buildResultingServiceUrl(destinationUrl: string | undefined, serviceUri: string | undefined): string | undefined { + if (!destinationUrl || !serviceUri || validatePromptURI(serviceUri) !== true) { + return undefined; } + return destinationUrl.replace(/\/$/, '') + serviceUri; +} - if (isCustomerBase) { - validationResult = validateCustomerValue(value, 'prompts.oDataServiceModelNameLabel'); - if (typeof validationResult === 'string') { - return validationResult; - } +/** + * Returns the OData version string for use in change content based on the selected service type. + * Returns undefined for HTTP service type as it has no OData version. + * + * @param {ServiceType} serviceType - The selected service type. + * @returns {string | undefined} The OData version string ('2.0' or '4.0'), or undefined for HTTP. + */ +export function getODataVersionFromServiceType(serviceType: ServiceType): string | undefined { + if (serviceType === ServiceType.ODATA_V2) { + return '2.0'; } - - if (hasContentDuplication(value, 'model', changeFiles)) { - return t('validators.errorDuplicatedValueSapui5Model'); + if (serviceType === ServiceType.ODATA_V4) { + return '4.0'; } - - return true; + return undefined; } /** - * Validates the OData Source URI prompt. + * Resolves the backend base URL for ABAP (non-CF) projects. + * For VS Code projects the URL is read directly from the `target.url` field in ui5.yaml. + * For BAS projects the destination name is read from `target.destination` and the URL + * is resolved via the BAS destination service. * - * @param value The value to validate. - * @returns {boolean | string} True if the URI is valid, or an error message if validation fails. + * @param {string} projectPath - The root path of the project. + * @returns {Promise} The resolved base URL, or undefined if it cannot be determined. */ -function validatePromptURI(value: string): boolean | string { - const validationResult = validateEmptyString(value); - if (typeof validationResult === 'string') { - return validationResult; - } - - if (!isDataSourceURI(value)) { - return t('validators.errorInvalidDataSourceURI'); +async function getAbapServiceUrl(projectPath: string): Promise { + try { + const adpConf = await getAdpConfig(projectPath, 'ui5.yaml'); + if ('target' in adpConf) { + const target = (adpConf as AdpPreviewConfigWithTarget).target as { url?: string; destination?: string }; + if (target.url) { + return target.url; + } + if (target.destination) { + const destinations = await listDestinations(); + return destinations[target.destination]?.Host; + } + } + } catch { + // unavailable — caller will simply not show the message } - - return true; + return undefined; } /** @@ -229,68 +243,95 @@ export async function getPrompts(projectPath: string, layer: UI5FlexLayer): Prom const isCustomerBase = FlexLayer.CUSTOMER_BASE === layer; const defaultSeviceName = isCustomerBase ? NamespacePrefix.CUSTOMER : NamespacePrefix.EMPTY; const isCFEnv = await isCFEnvironment(projectPath); + const abapServiceUrl = isCFEnv ? undefined : await getAbapServiceUrl(projectPath); const changeFiles = getChangesByType(projectPath, ChangeType.ADD_NEW_MODEL, 'manifest'); + let destinationError: string | undefined; return [ { - type: 'input', - name: 'name', - message: t('prompts.oDataServiceNameLabel'), - default: defaultSeviceName, + type: 'list', + name: 'serviceType', + message: t('prompts.serviceTypeLabel'), + choices: isCFEnv ? serviceTypeChoices : serviceTypeChoices.filter((c) => c.value !== ServiceType.HTTP), store: false, - validate: (value: string, answers: NewModelAnswers) => { - return validatePromptODataName(value, answers, isCustomerBase, changeFiles); - }, + validate: validateEmptyString, guiOptions: { mandatory: true, - hint: t('prompts.oDataServiceNameTooltip') + hint: t('prompts.serviceTypeTooltip') } - } as InputQuestion, + } as ListQuestion, + { + type: 'list', + name: 'destination', + message: t('prompts.destinationLabel'), + choices: async (): Promise<{ name: string; value: Destination }[]> => { + try { + const destinations = await getDestinations(projectPath); + destinationError = undefined; + return Object.entries(destinations).map(([name, dest]) => ({ name, value: dest as Destination })); + } catch (e) { + destinationError = (e as Error).message; + return []; + } + }, + when: () => isCFEnv, + guiOptions: { + mandatory: true, + hint: t('prompts.destinationTooltip') + }, + validate: (value: Destination): boolean | string => destinationError ?? validateEmptyString(value?.Name) + } as ListQuestion, { type: 'input', name: 'uri', - message: t('prompts.oDataServiceUriLabel'), + message: t('prompts.serviceUriLabel'), guiOptions: { mandatory: true, hint: t('prompts.oDataServiceUriTooltip') }, - validate: validatePromptURI, - store: false - } as InputQuestion, - { - type: 'list', - name: 'version', - message: t('prompts.oDataServiceVersionLabel'), - choices: oDataVersions, - default: (answers: NewModelAnswers) => { - if (answers.uri?.startsWith(isCFEnv ? '/odata/v4/' : '/sap/opu/odata4/')) { - return oDataVersions[1].value; + validate: (value: string): boolean | string => { + const uriResult = validatePromptURI(value); + if (typeof uriResult === 'string') { + return uriResult; } - - return oDataVersions[0].value; + if (isCFEnv) { + const routes = readXsAppRoutes(projectPath); + if (routes.some((r) => r.target === `${value}$1`)) { + return t('validators.errorRouteAlreadyExists'); + } + } + return true; }, store: false, - validate: validateEmptyString, - guiOptions: { - mandatory: true, - hint: t('prompts.oDataServiceVersionTooltip'), - applyDefaultWhenDirty: true + additionalMessages: (serviceUri: unknown, previousAnswers?: NewModelAnswers): IMessageSeverity | undefined => { + const destinationUrl = isCFEnv ? previousAnswers?.destination?.Host : abapServiceUrl; + const resultingUrl = buildResultingServiceUrl(destinationUrl, serviceUri as string | undefined); + if (!resultingUrl) { + return undefined; + } + return { + message: t('prompts.resultingServiceUrl', { url: resultingUrl, interpolation: { escapeValue: false } }), + severity: Severity.information + }; } - } as ListQuestion, + } as InputQuestion, { type: 'input', - name: 'modelName', - message: t('prompts.oDataServiceModelNameLabel'), - guiOptions: { - mandatory: true, - hint: t('prompts.oDataServiceModelNameTooltip') - }, + name: 'modelAndDatasourceName', + message: (answers: NewModelAnswers) => + answers.serviceType === ServiceType.HTTP + ? t('prompts.datasourceNameLabel') + : t('prompts.modelAndDatasourceNameLabel'), default: defaultSeviceName, + store: false, validate: (value: string) => { - return validatePromptModelName(value, isCustomerBase, changeFiles); + return validatePromptODataName(value, isCustomerBase, changeFiles); }, - store: false + guiOptions: { + mandatory: true, + hint: t('prompts.modelAndDatasourceNameTooltip') + } } as InputQuestion, { type: 'editor', @@ -298,6 +339,7 @@ export async function getPrompts(projectPath: string, layer: UI5FlexLayer): Prom message: t('prompts.oDataServiceModelSettingsLabel'), store: false, validate: validatePromptJSON, + when: (answers: NewModelAnswers) => answers.serviceType !== ServiceType.HTTP, guiOptions: { hint: t('prompts.oDataServiceModelSettingsTooltip') } @@ -306,23 +348,9 @@ export async function getPrompts(projectPath: string, layer: UI5FlexLayer): Prom type: 'confirm', name: 'addAnnotationMode', message: 'Do you want to add annotation?', - default: false + default: false, + when: (answers: NewModelAnswers) => answers.serviceType !== ServiceType.HTTP } as ConfirmQuestion, - { - type: 'input', - name: 'dataSourceName', - message: t('prompts.oDataAnnotationDataSourceNameLabel'), - validate: (value: string, answers: NewModelAnswers) => { - return validatePromptODataAnnotationsName(value, answers, isCustomerBase, changeFiles); - }, - default: defaultSeviceName, - store: false, - guiOptions: { - mandatory: true, - hint: t('prompts.oDataAnnotationDataSourceNameTooltip') - }, - when: (answers: NewModelAnswers) => answers.addAnnotationMode - } as InputQuestion, { type: 'input', name: 'dataSourceURI', diff --git a/packages/adp-tooling/src/prompts/index.ts b/packages/adp-tooling/src/prompts/index.ts index 1635025011b..bbbd28dba33 100644 --- a/packages/adp-tooling/src/prompts/index.ts +++ b/packages/adp-tooling/src/prompts/index.ts @@ -1,5 +1,5 @@ export { getPrompts as getPromptsForChangeDataSource } from './change-data-source'; export { getPrompts as getPromptsForAddComponentUsages } from './add-component-usages'; -export { getPrompts as getPromptsForNewModel } from './add-new-model'; +export { getPrompts as getPromptsForNewModel, getODataVersionFromServiceType } from './add-new-model'; export { getPrompts as getPromptsForChangeInbound } from './change-inbound'; export { getPrompts as getPromptsForAddAnnotationsToOData } from './add-annotations-to-odata'; diff --git a/packages/adp-tooling/src/translations/adp-tooling.i18n.json b/packages/adp-tooling/src/translations/adp-tooling.i18n.json index d69195bcbab..f2c44a80ad9 100644 --- a/packages/adp-tooling/src/translations/adp-tooling.i18n.json +++ b/packages/adp-tooling/src/translations/adp-tooling.i18n.json @@ -13,10 +13,19 @@ "filePathLabel": "Annotation File path", "filePathTooltip": "Select the annotation file from your workspace", "addAnnotationOdataSourceTooltip": "Select the OData service you want to add the annotation file to", + "destinationLabel": "Destination", + "destinationTooltip": "Select destination for desired service", + "serviceTypeLabel": "Service Type", + "serviceTypeTooltip": "Select the type of service you want to add", "oDataServiceNameLabel": "OData Service Name", "oDataServiceNameTooltip": "Enter a name for the OData service you want to add", "oDataServiceUriLabel": "OData Service URI", "oDataServiceUriTooltip": "Enter the URI for the OData service you want to add. The URI must start and end with '/' and must not contain any whitespaces or parameters", + "serviceUriLabel": "Service URI", + "resultingServiceUrl": "Resulting Service URL: {{url}}", + "modelAndDatasourceNameLabel": "Model and Datasource Name", + "modelAndDatasourceNameTooltip": "Enter a name for the datasource and model. The same name will be used for both. Note: for HTTP service type, no model will be created.", + "datasourceNameLabel": "Datasource Name", "oDataServiceVersionLabel": "OData Version", "oDataServiceVersionTooltip": "Select the version of OData of the service you want to add", "oDataServiceModelNameLabel": "OData Service SAPUI5 Model Name", @@ -68,6 +77,7 @@ "errorInputInvalidValuePrefix": "{{value}} must start with '{{prefix}}'.", "errorCustomerEmptyValue": "{{value}} must contain at least one character in addition to '{{prefix}}'.", "errorInvalidDataSourceURI": "Invalid URI. The URI must start and end with '/' and contain no spaces.", + "errorRouteAlreadyExists": "A route with the same Service URI already exists in xs-app.json. Use a different URI.", "appDoesNotSupportManifest": "The selected application is not supported by SAPUI5 Adaptation Project because it does not have a `manifest.json` file. Please select a different application.", "ui5VersionNotReachableError": "The URL of the SAPUI5 version you have selected is not reachable. The URL must be made accessible through the cloud connector and the destination configuration so it can be consumed within the SAPUI5 adaptation project and its SAPUI5 Adaptation Editor.", "ui5VersionOutdatedError": "The SAPUI5 version you have selected is not compatible with the SAPUI5 Adaptation Editor. Please select a different version.", @@ -113,7 +123,10 @@ "noServiceInstanceNameFound": "No serviceInstanceName found in the app-variant-bundler-build configuration", "noServiceKeysFoundForInstance": "No service keys found for service instance: {{serviceInstanceName}}", "couldNotFetchServiceKeys": "Cannot fetch the service keys. Error: {{error}}", - "metadataFetchingNotSupportedForCF": "Metadata fetching is not supported for Cloud Foundry projects." + "metadataFetchingNotSupportedForCF": "Metadata fetching is not supported for Cloud Foundry projects.", + "failedToListBtpDestinations": "Failed to list BTP destinations. Error: {{error}}", + "destinationServiceNotFoundInMtaYaml": "Destination service instance not found in mta.yaml. Ensure a resource with 'service: destination' is declared.", + "noServiceKeysFoundForDestination": "No service keys found for destination service instance '{{serviceInstanceName}}'. Ensure the service is provisioned and try again." }, "choices": { "true": "true", diff --git a/packages/adp-tooling/src/types.ts b/packages/adp-tooling/src/types.ts index 454b8eb90fe..5a7df6b3f4f 100644 --- a/packages/adp-tooling/src/types.ts +++ b/packages/adp-tooling/src/types.ts @@ -536,6 +536,14 @@ export const enum ChangeType { CHANGE_INBOUND = 'appdescr_app_changeInbound' } +export const ServiceType = { + ODATA_V2: 'OData v2', + ODATA_V4: 'OData v4', + HTTP: 'HTTP' +} as const; + +export type ServiceType = (typeof ServiceType)[keyof typeof ServiceType]; + /** * A mapping of ChangeType values to their respective change names. */ @@ -648,15 +656,21 @@ export type AddComponentUsageAnswers = AddComponentUsageAnswersBase & export interface NewModelDataBase { variant: DescriptorVariant; + /** Whether the project is deployed on Cloud Foundry. Affects URI construction and model settings. */ + isCloudFoundry?: boolean; + /** Name of the BTP destination. Required when isCloudFoundry is true to write the xs-app.json route. */ + destinationName?: string; + /** True when the selected CF destination is OnPremise (ProxyType: 'OnPremise'). Triggers connectivity service in mta.yaml. */ + isOnPremiseDestination?: boolean; service: { /** Name of the OData service. */ name: string; /** URI of the OData service. */ uri: string; - /** Name of the OData service model. */ - modelName: string; - /** Version of OData used. */ - version: string; + /** Name of the OData service model. Optional — absent for HTTP service type. */ + modelName?: string; + /** Version of OData used. Undefined for HTTP service type. */ + version?: string; /** Settings for the OData service model. */ modelSettings?: string; }; @@ -676,23 +690,20 @@ export interface NewModelDataWithAnnotations extends NewModelDataBase { export type NewModelData = NewModelDataBase | NewModelDataWithAnnotations; export interface NewModelAnswersBase { - /** Name of the OData service. */ - name: string; + /** Selected BTP destination. Only relevant for CF projects. */ + destination?: Destination; + /** Type of service to add. */ + serviceType: ServiceType; + /** Name used for both the OData service datasource and the SAPUI5 model. */ + modelAndDatasourceName: string; /** URI of the OData service. */ uri: string; - /** Name of the OData service model. */ - modelName: string; - /** Version of OData used. */ - version: string; /** Settings for the OData service model. */ modelSettings: string; - /** Name of the OData annotation data source. */ } export interface NewModelAnswersWithAnnotations extends NewModelAnswersBase { addAnnotationMode: true; - /** Name of the OData annotation data source. */ - dataSourceName: string; /** Optional URI of the OData annotation data source. */ dataSourceURI?: string; /** Optional settings for the OData annotation. */ @@ -895,6 +906,19 @@ export interface Uaa { url: string; } +export type CfDestinationServiceCredentials = + | { uri: string; uaa: Uaa } + | ({ uri: string } & Uaa); + +export interface BtpDestinationConfig { + Name: string; + Type: string; + URL: string; + Authentication: string; + ProxyType: string; + Description?: string; +} + export interface CfAppParams { appName: string; appVersion: string; diff --git a/packages/adp-tooling/src/writer/cf.ts b/packages/adp-tooling/src/writer/cf.ts index edaf943dc5a..aea607f4527 100644 --- a/packages/adp-tooling/src/writer/cf.ts +++ b/packages/adp-tooling/src/writer/cf.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs'; import { join } from 'node:path'; import { create as createStorage } from 'mem-fs'; import { create, type Editor } from 'mem-fs-editor'; @@ -8,20 +7,18 @@ import { readUi5Yaml } from '@sap-ux/project-access'; import { adjustMtaYaml, - getAppHostIds, getOrCreateServiceInstanceKeys, addServeStaticMiddleware, addBackendProxyMiddleware, - getCfUi5AppInfo, getProjectNameForXsSecurity } from '../cf'; import { getApplicationType } from '../source'; import { fillDescriptorContent } from './manifest'; -import type { CfAdpWriterConfig, Content, CfUi5AppInfo, CfConfig } from '../types'; +import type { CfAdpWriterConfig, Content, CfConfig } from '../types'; import { getCfVariant, writeCfTemplates, writeCfUI5Yaml } from './project-utils'; import { getI18nDescription, getI18nModels, writeI18nModels } from './i18n'; -import { getBaseAppId } from '../base/helper'; import { runBuild } from '../base/project-builder'; +import { downloadUi5AppInfo } from '../cf/project/ui5-app-info'; /** * Writes the CF adp-project template to the mem-fs-editor instance. @@ -102,24 +99,6 @@ function setDefaults(config: CfAdpWriterConfig): CfAdpWriterConfig { return configWithDefaults; } -/** - * Fetch ui5AppInfo.json and write it to the project root. - * - * @param basePath - path to application root - * @param ui5AppInfo - ui5AppInfo.json content - * @param logger - logger instance - */ -export async function writeUi5AppInfo(basePath: string, ui5AppInfo: CfUi5AppInfo, logger?: ToolsLogger): Promise { - try { - const ui5AppInfoTargetPath = join(basePath, 'ui5AppInfo.json'); - fs.writeFileSync(ui5AppInfoTargetPath, JSON.stringify(ui5AppInfo, null, 2), 'utf-8'); - logger?.info(`Written ui5AppInfo.json to ${basePath}`); - } catch (error) { - logger?.error(`Failed to process ui5AppInfo.json: ${(error as Error).message}`); - throw error; - } -} - /** * Generate CF configuration for an adaptation project. * @@ -161,15 +140,7 @@ export async function generateCfConfig( throw new Error(`No service keys found for service instance: ${serviceInstanceName}`); } - const appId = await getBaseAppId(basePath); - const appHostIds = getAppHostIds(serviceInfo.serviceKeys); - const ui5AppInfo: CfUi5AppInfo = await getCfUi5AppInfo(appId, appHostIds, cfConfig, logger); - - if (appHostIds.length === 0) { - throw new Error('No app host IDs found in service keys.'); - } - - await writeUi5AppInfo(basePath, ui5AppInfo, logger); + await downloadUi5AppInfo(basePath, cfConfig, logger); await addServeStaticMiddleware(basePath, ui5Config, logger); await runBuild(basePath, { ADP_BUILDER_MODE: 'preview' }); addBackendProxyMiddleware(basePath, ui5Config, serviceInfo.serviceKeys, logger); diff --git a/packages/adp-tooling/src/writer/changes/writer-factory.ts b/packages/adp-tooling/src/writer/changes/writer-factory.ts index 7f7616dec02..b8194473261 100644 --- a/packages/adp-tooling/src/writer/changes/writer-factory.ts +++ b/packages/adp-tooling/src/writer/changes/writer-factory.ts @@ -1,4 +1,5 @@ import type { Editor } from 'mem-fs-editor'; +import type { ToolsLogger } from '@sap-ux/logger'; import { ChangeType } from '../../types'; import type { Writer, IWriterData } from '../../types'; @@ -24,6 +25,7 @@ export class WriterFactory { * @param fs - The filesystem editor instance. * @param projectPath - The path to the project for which the writer is created. * @param templatesPath - The path to the templates used for generating changes. + * @param logger - Optional logger instance passed to writers that support it. * @returns An instance of the writer associated with the specified generator type. * @throws If the specified generator type is not supported. */ @@ -31,7 +33,8 @@ export class WriterFactory { type: T, fs: Editor, projectPath: string, - templatesPath?: string + templatesPath?: string, + logger?: ToolsLogger ): IWriterData { const WriterClass = this.writers.get(type); if (!WriterClass) { @@ -42,6 +45,10 @@ export class WriterFactory { return new (WriterClass as typeof AnnotationsWriter)(fs, projectPath, templatesPath) as IWriterData; } + if (type === ChangeType.ADD_NEW_MODEL) { + return new (WriterClass as typeof NewModelWriter)(fs, projectPath, logger) as IWriterData; + } + return new WriterClass(fs, projectPath); } } diff --git a/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts b/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts index fbc4cfa85e0..e04f76abb79 100644 --- a/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts +++ b/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts @@ -1,14 +1,26 @@ +import { join } from 'node:path'; + import type { Editor } from 'mem-fs-editor'; +import { ToolsLogger } from '@sap-ux/logger'; import { ChangeType } from '../../../types'; import type { IWriter, NewModelData, DataSourceItem } from '../../../types'; import { parseStringToObject, getChange, writeChangeToFolder } from '../../../base/change-utils'; +import { addConnectivityServiceToMta } from '../../../cf/project/yaml'; +import { ensureTunnelAppExists, DEFAULT_TUNNEL_APP_NAME } from '../../../cf/services/ssh'; + +const CF_MODEL_SETTINGS = { + operationMode: 'Server', + autoExpandSelect: true, + earlyRequests: true +} as const; type NewModelContent = { model: { [key: string]: { settings?: object; dataSource: string; + preload?: boolean; }; }; dataSource: { @@ -23,10 +35,12 @@ export class NewModelWriter implements IWriter { /** * @param {Editor} fs - The filesystem editor instance. * @param {string} projectPath - The root path of the project. + * @param {ToolsLogger} [logger] - Optional logger instance. */ constructor( private readonly fs: Editor, - private readonly projectPath: string + private readonly projectPath: string, + private readonly logger?: ToolsLogger ) {} /** @@ -36,31 +50,43 @@ export class NewModelWriter implements IWriter { * @returns {object} The constructed content object for the new model change. */ private constructContent(data: NewModelData): object { - const { service } = data; + const { service, isCloudFoundry } = data; + + const uri = isCloudFoundry ? `/${service.name.replace(/\./g, '/')}${service.uri}` : service.uri; + + const dataSourceEntry: DataSourceItem = { + uri, + type: 'OData', + settings: {} + }; + + if (service.version) { + dataSourceEntry.settings.odataVersion = service.version; + } + const content: NewModelContent = { dataSource: { - [service.name]: { - uri: service.uri, - type: 'OData', - settings: { - odataVersion: service.version - } - } + [service.name]: dataSourceEntry }, - model: { - [service.modelName]: { - dataSource: service.name - } - } + model: {} }; - if (service.modelSettings && service.modelSettings.length !== 0) { - content.model[service.modelName].settings = parseStringToObject(service.modelSettings); + if (service.modelName) { + content.model[service.modelName] = { dataSource: service.name }; + + if (isCloudFoundry) { + content.model[service.modelName].preload = true; + content.model[service.modelName].settings = { ...CF_MODEL_SETTINGS }; + } else if (service.modelSettings && service.modelSettings.length !== 0) { + content.model[service.modelName].settings = parseStringToObject(service.modelSettings); + } } if ('annotation' in data) { const { annotation } = data; - content.dataSource[service.name].settings.annotations = [`${annotation.dataSourceName}`]; + (content.dataSource[service.name].settings as Record).annotations = [ + `${annotation.dataSourceName}` + ]; content.dataSource[annotation.dataSourceName] = { uri: annotation.dataSourceURI, type: 'ODataAnnotation' @@ -86,5 +112,33 @@ export class NewModelWriter implements IWriter { const change = getChange(data.variant, timestamp, content, ChangeType.ADD_NEW_MODEL); await writeChangeToFolder(this.projectPath, change, this.fs); + + if (data.isCloudFoundry) { + this.writeXsAppRoute(data); + } + + if (data.isCloudFoundry && data.isOnPremiseDestination) { + await addConnectivityServiceToMta(this.projectPath, this.fs); + await ensureTunnelAppExists(DEFAULT_TUNNEL_APP_NAME, this.logger ?? new ToolsLogger()); + } + } + + /** + * Creates or updates the xs-app.json in the webapp folder with a new AppRouter route + * for the added OData service. + * + * @param {NewModelData} data - The new model data containing service name, URI and destination name. + */ + private writeXsAppRoute(data: NewModelData): void { + const xsAppPath = join(this.projectPath, 'webapp', 'xs-app.json'); + const source = `^/${data.service.name.replace(/\./g, '/')}${data.service.uri}(.*)`; + const newRoute = { + source, + target: `${data.service.uri}$1`, + destination: data.destinationName + }; + const existing = this.fs.readJSON(xsAppPath, { routes: [] }) as { routes: object[] }; + existing.routes.push(newRoute); + this.fs.writeJSON(xsAppPath, existing); } } diff --git a/packages/adp-tooling/src/writer/editors.ts b/packages/adp-tooling/src/writer/editors.ts index 2f330d30212..5acfd11ad8d 100644 --- a/packages/adp-tooling/src/writer/editors.ts +++ b/packages/adp-tooling/src/writer/editors.ts @@ -1,5 +1,6 @@ import { create as createStorage } from 'mem-fs'; import { create, type Editor } from 'mem-fs-editor'; +import type { ToolsLogger } from '@sap-ux/logger'; import type { GeneratorData, ChangeType } from '../types'; import { WriterFactory } from './changes/writer-factory'; @@ -16,6 +17,7 @@ import { WriterFactory } from './changes/writer-factory'; * @param {GeneratorData} data - The data specific to the type of generator, containing information necessary for making changes. * @param {Editor | null} [fs] - The `mem-fs-editor` instance used for file operations. * @param {string} templatesPath - The path to the templates used for generating changes. + * @param {ToolsLogger} [logger] - Optional logger instance passed to the writer. * @returns {Promise} A promise that resolves to the mem-fs editor instance used for making changes, allowing for further operations or committing changes to disk. * @template T - A type parameter extending `ChangeType`, ensuring the function handles a defined set of generator types. */ @@ -24,13 +26,14 @@ export async function generateChange( type: T, data: GeneratorData, fs: Editor | null = null, - templatesPath?: string + templatesPath?: string, + logger?: ToolsLogger ): Promise { if (!fs) { fs = create(createStorage()); } - const writer = WriterFactory.createWriter(type, fs, projectPath, templatesPath); + const writer = WriterFactory.createWriter(type, fs, projectPath, templatesPath, logger); await writer.write(data); diff --git "a/packages/adp-tooling/test/unit/btp/api.test.ts\342\200\216" "b/packages/adp-tooling/test/unit/btp/api.test.ts\342\200\216" new file mode 100644 index 00000000000..add5115687b --- /dev/null +++ "b/packages/adp-tooling/test/unit/btp/api.test.ts\342\200\216" @@ -0,0 +1,128 @@ +import axios from 'axios'; + +import type { ToolsLogger } from '@sap-ux/logger'; + +import { getToken, getBtpDestinationConfig } from '../../../src/btp/api'; +import { initI18n, t } from '../../../src/i18n'; +import type { Uaa } from '../../../src/types'; + +jest.mock('axios'); +const mockAxios = axios as jest.Mocked; + +describe('btp/api', () => { + const mockLogger = { + debug: jest.fn(), + log: jest.fn(), + error: jest.fn() + } as unknown as ToolsLogger; + + beforeAll(async () => { + await initI18n(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getToken', () => { + const mockUaa: Uaa = { + clientid: 'test-client-id', + clientsecret: 'test-client-secret', + url: '/test-uaa' + }; + + test('should successfully get OAuth token', async () => { + const mockResponse = { + data: { + access_token: 'test-access-token' + } + }; + mockAxios.post.mockResolvedValue(mockResponse); + + const result = await getToken(mockUaa); + + expect(result).toBe('test-access-token'); + expect(mockAxios.post).toHaveBeenCalledWith('/test-uaa/oauth/token', 'grant_type=client_credentials', { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': 'Basic ' + Buffer.from('test-client-id:test-client-secret').toString('base64') + } + }); + }); + + test('should throw error when token request fails', async () => { + const error = new Error('Network error'); + mockAxios.post.mockRejectedValue(error); + + await expect(getToken(mockUaa)).rejects.toThrow(t('error.failedToGetAuthKey', { error: 'Network error' })); + }); + + test('should handle missing access_token in response', async () => { + const mockResponse = { + data: {} + }; + mockAxios.post.mockResolvedValue(mockResponse); + + const result = await getToken(mockUaa); + + expect(result).toBeUndefined(); + }); + }); + + describe('getBtpDestinationConfig', () => { + const mockUri = 'https://destination-configuration.test.xx.hana.ondemand.com'; + const mockToken = 'test-bearer-token'; + const mockDestinationName = 'my-destination'; + + test('should return destination configuration on success', async () => { + const mockConfig = { + Name: 'my-destination', + ProxyType: 'OnPremise', + URL: '/backend.example', + Authentication: 'PrincipalPropagation' + }; + mockAxios.get.mockResolvedValue({ + data: { destinationConfiguration: mockConfig } + }); + + const result = await getBtpDestinationConfig(mockUri, mockToken, mockDestinationName, mockLogger); + + expect(result).toEqual(mockConfig); + expect(mockAxios.get).toHaveBeenCalledWith( + `${mockUri}/destination-configuration/v1/destinations/${mockDestinationName}`, + { headers: { Authorization: `Bearer ${mockToken}` } } + ); + }); + + test('should return undefined when request fails', async () => { + mockAxios.get.mockRejectedValue(new Error('Network error')); + + const result = await getBtpDestinationConfig(mockUri, mockToken, mockDestinationName, mockLogger); + + expect(result).toBeUndefined(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to fetch destination config') + ); + }); + + test('should return undefined when destinationConfiguration is missing from response', async () => { + mockAxios.get.mockResolvedValue({ data: {} }); + + const result = await getBtpDestinationConfig(mockUri, mockToken, mockDestinationName, mockLogger); + + expect(result).toBeUndefined(); + }); + + test('should encode destination name in URL', async () => { + const specialName = 'dest with spaces'; + mockAxios.get.mockResolvedValue({ data: { destinationConfiguration: { Name: specialName } } }); + + await getBtpDestinationConfig(mockUri, mockToken, specialName, mockLogger); + + expect(mockAxios.get).toHaveBeenCalledWith( + `${mockUri}/destination-configuration/v1/destinations/${encodeURIComponent(specialName)}`, + expect.any(Object) + ); + }); + }); +}); \ No newline at end of file diff --git a/packages/adp-tooling/test/unit/cf/app/html5-repo.test.ts b/packages/adp-tooling/test/unit/cf/app/html5-repo.test.ts index 15b3e031b95..67134457ab3 100644 --- a/packages/adp-tooling/test/unit/cf/app/html5-repo.test.ts +++ b/packages/adp-tooling/test/unit/cf/app/html5-repo.test.ts @@ -11,7 +11,8 @@ import { createServiceInstance, getOrCreateServiceInstanceKeys } from '../../../../src/cf/services/api'; -import { downloadAppContent, downloadZip, getHtml5RepoCredentials, getToken } from '../../../../src/cf/app/html5-repo'; +import { downloadAppContent, downloadZip, getHtml5RepoCredentials } from '../../../../src/cf/app/html5-repo'; +import { getToken } from '../../../../src/btp/api'; jest.mock('axios'); jest.mock('adm-zip'); @@ -96,22 +97,26 @@ describe('HTML5 Repository', () => { access_token: 'test-access-token' } }; - mockAxios.get.mockResolvedValue(mockResponse); + mockAxios.post.mockResolvedValue(mockResponse); const result = await getToken(mockUaa); expect(result).toBe('test-access-token'); - expect(mockAxios.get).toHaveBeenCalledWith('/test-uaa/oauth/token?grant_type=client_credentials', { - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Basic ' + Buffer.from('test-client-id:test-client-secret').toString('base64') + expect(mockAxios.post).toHaveBeenCalledWith( + '/test-uaa/oauth/token', + 'grant_type=client_credentials', + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': 'Basic ' + Buffer.from('test-client-id:test-client-secret').toString('base64') + } } - }); + ); }); test('should throw error when token request fails', async () => { const error = new Error('Network error'); - mockAxios.get.mockRejectedValue(error); + mockAxios.post.mockRejectedValue(error); await expect(getToken(mockUaa)).rejects.toThrow(t('error.failedToGetAuthKey', { error: 'Network error' })); }); @@ -120,7 +125,7 @@ describe('HTML5 Repository', () => { const mockResponse = { data: {} }; - mockAxios.get.mockResolvedValue(mockResponse); + mockAxios.post.mockResolvedValue(mockResponse); const result = await getToken(mockUaa); @@ -231,15 +236,14 @@ describe('HTML5 Repository', () => { beforeEach(() => { mockGetOrCreateServiceInstanceKeys.mockResolvedValue(mockServiceKeys); - mockAxios.get - .mockResolvedValueOnce({ - data: { - access_token: 'test-token' - } - }) - .mockResolvedValueOnce({ - data: Buffer.from('test-zip-content') - }); + mockAxios.post.mockResolvedValueOnce({ + data: { + access_token: 'test-token' + } + }); + mockAxios.get.mockResolvedValueOnce({ + data: Buffer.from('test-zip-content') + }); const mockAdmZipInstance = { getEntries: jest.fn().mockReturnValue(mockZipEntries) @@ -279,7 +283,7 @@ describe('HTML5 Repository', () => { test('should throw error when zip parsing fails', async () => { jest.clearAllMocks(); mockGetOrCreateServiceInstanceKeys.mockResolvedValue(mockServiceKeys); - mockAxios.get.mockResolvedValueOnce({ + mockAxios.post.mockResolvedValueOnce({ data: { access_token: 'test-token' } @@ -304,7 +308,7 @@ describe('HTML5 Repository', () => { test('should throw error when zip has no entries', async () => { jest.clearAllMocks(); mockGetOrCreateServiceInstanceKeys.mockResolvedValue(mockServiceKeys); - mockAxios.get.mockResolvedValueOnce({ + mockAxios.post.mockResolvedValueOnce({ data: { access_token: 'test-token' } @@ -330,7 +334,7 @@ describe('HTML5 Repository', () => { test('should throw error when manifest.json not found', async () => { jest.clearAllMocks(); mockGetOrCreateServiceInstanceKeys.mockResolvedValue(mockServiceKeys); - mockAxios.get.mockResolvedValueOnce({ + mockAxios.post.mockResolvedValueOnce({ data: { access_token: 'test-token' } @@ -361,7 +365,7 @@ describe('HTML5 Repository', () => { test('should throw error when manifest.json parsing fails', async () => { jest.clearAllMocks(); mockGetOrCreateServiceInstanceKeys.mockResolvedValue(mockServiceKeys); - mockAxios.get.mockResolvedValueOnce({ + mockAxios.post.mockResolvedValueOnce({ data: { access_token: 'test-token' } diff --git a/packages/adp-tooling/test/unit/cf/project/ui5-app-info.test.ts b/packages/adp-tooling/test/unit/cf/project/ui5-app-info.test.ts index e6af499341f..4fb8539ef32 100644 --- a/packages/adp-tooling/test/unit/cf/project/ui5-app-info.test.ts +++ b/packages/adp-tooling/test/unit/cf/project/ui5-app-info.test.ts @@ -1,16 +1,41 @@ -import { existsSync, readFileSync } from 'node:fs'; +import fs, { existsSync, readFileSync } from 'node:fs'; + +import { join } from 'node:path'; +import { mkdirSync, rmSync } from 'node:fs'; import type { ToolsLogger } from '@sap-ux/logger'; +import { readUi5Yaml } from '@sap-ux/project-access'; -import { getReusableLibraryPaths } from '../../../../src/cf/project/ui5-app-info'; +import { getReusableLibraryPaths, downloadUi5AppInfo, writeUi5AppInfo } from '../../../../src/cf/project/ui5-app-info'; +import { getOrCreateServiceInstanceKeys, getCfUi5AppInfo } from '../../../../src/cf/services/api'; +import { getAppHostIds } from '../../../../src/cf/app/discovery'; +import { getBaseAppId } from '../../../../src/base/helper'; +import type { CfConfig, ServiceInfo, CfUi5AppInfo } from '../../../../src/types'; -jest.mock('fs', () => ({ - existsSync: jest.fn(), - readFileSync: jest.fn() +jest.mock('@sap-ux/project-access', () => ({ + readUi5Yaml: jest.fn() })); -const mockExistsSync = existsSync as jest.MockedFunction; -const mockReadFileSync = readFileSync as jest.MockedFunction; +jest.mock('../../../../src/cf/services/api', () => ({ + getOrCreateServiceInstanceKeys: jest.fn(), + getCfUi5AppInfo: jest.fn() +})); + +jest.mock('../../../../src/cf/app/discovery', () => ({ + getAppHostIds: jest.fn() +})); + +jest.mock('../../../../src/base/helper', () => ({ + getBaseAppId: jest.fn() +})); + +const mockReadUi5Yaml = readUi5Yaml as jest.MockedFunction; +const mockGetOrCreateServiceInstanceKeys = getOrCreateServiceInstanceKeys as jest.MockedFunction< + typeof getOrCreateServiceInstanceKeys +>; +const mockGetCfUi5AppInfo = getCfUi5AppInfo as jest.MockedFunction; +const mockGetAppHostIds = getAppHostIds as jest.MockedFunction; +const mockGetBaseAppId = getBaseAppId as jest.MockedFunction; describe('getReusableLibraryPaths', () => { const basePath = '/test/project'; @@ -18,8 +43,16 @@ describe('getReusableLibraryPaths', () => { warn: jest.fn() } as unknown as ToolsLogger; + let existsSyncSpy: jest.SpyInstance; + let readFileSyncSpy: jest.SpyInstance; + beforeEach(() => { - jest.clearAllMocks(); + existsSyncSpy = jest.spyOn(fs, 'existsSync'); + readFileSyncSpy = jest.spyOn(fs, 'readFileSync'); + }); + + afterEach(() => { + jest.restoreAllMocks(); }); test('should extract reusable library paths from ui5AppInfo.json', () => { @@ -40,8 +73,8 @@ describe('getReusableLibraryPaths', () => { } }; - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue(JSON.stringify({ 'test-app': ui5AppInfo })); + existsSyncSpy.mockReturnValue(true); + readFileSyncSpy.mockReturnValue(JSON.stringify({ 'test-app': ui5AppInfo })); const result = getReusableLibraryPaths(basePath, mockLogger); @@ -61,7 +94,7 @@ describe('getReusableLibraryPaths', () => { }); test('should return empty array when ui5AppInfo.json does not exist', () => { - mockExistsSync.mockReturnValue(false); + existsSyncSpy.mockReturnValue(false); const result = getReusableLibraryPaths(basePath, mockLogger); @@ -80,8 +113,8 @@ describe('getReusableLibraryPaths', () => { } }; - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue(JSON.stringify({ 'test-app': ui5AppInfo })); + existsSyncSpy.mockReturnValue(true); + readFileSyncSpy.mockReturnValue(JSON.stringify({ 'test-app': ui5AppInfo })); const result = getReusableLibraryPaths(basePath, mockLogger); @@ -114,8 +147,8 @@ describe('getReusableLibraryPaths', () => { } }; - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue(JSON.stringify({ 'test-app': ui5AppInfo })); + existsSyncSpy.mockReturnValue(true); + readFileSyncSpy.mockReturnValue(JSON.stringify({ 'test-app': ui5AppInfo })); const result = getReusableLibraryPaths(basePath, mockLogger); @@ -129,11 +162,166 @@ describe('getReusableLibraryPaths', () => { }); test('should handle empty asyncHints or libs array', () => { - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue(JSON.stringify({ 'test-app': {} })); + existsSyncSpy.mockReturnValue(true); + readFileSyncSpy.mockReturnValue(JSON.stringify({ 'test-app': {} })); const result = getReusableLibraryPaths(basePath, mockLogger); expect(result).toEqual([]); }); }); + +describe('writeUi5AppInfo', () => { + const outputDir = join(__dirname, '../../../fixtures/test-output/write-ui5-app-info'); + const mockLogger = { + info: jest.fn(), + error: jest.fn() + } as unknown as ToolsLogger; + + const mockUi5AppInfo: CfUi5AppInfo = { + asyncHints: { + libs: [{ name: 'sap.m' }, { name: 'sap.ui.core' }] + } + }; + + beforeAll(() => { + mkdirSync(outputDir, { recursive: true }); + }); + + afterAll(() => { + rmSync(outputDir, { recursive: true, force: true }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should write ui5AppInfo.json to project root', async () => { + // Use the real implementation, not the module-level mock + const { writeUi5AppInfo: realWriteUi5AppInfo } = jest.requireActual( + '../../../../src/cf/project/ui5-app-info' + ); + + await realWriteUi5AppInfo(outputDir, mockUi5AppInfo, mockLogger); + + const filePath = join(outputDir, 'ui5AppInfo.json'); + expect(existsSync(filePath)).toBe(true); + + const content = JSON.parse(readFileSync(filePath, 'utf-8') as string); + expect(content).toEqual(mockUi5AppInfo); + expect(mockLogger.info).toHaveBeenCalledWith(`Written ui5AppInfo.json to ${outputDir}`); + }); + + test('should throw error when write fails', async () => { + const { writeUi5AppInfo: realWriteUi5AppInfo } = jest.requireActual( + '../../../../src/cf/project/ui5-app-info' + ); + const invalidPath = '/invalid/path/that/does/not/exist'; + + await expect(realWriteUi5AppInfo(invalidPath, mockUi5AppInfo, mockLogger)).rejects.toThrow(); + expect(mockLogger.error).toHaveBeenCalled(); + }); +}); + +describe('downloadUi5AppInfo', () => { + const projectPath = '/test/project'; + const mockLogger = { info: jest.fn(), error: jest.fn() } as unknown as ToolsLogger; + const cfConfig = { url: 'cf.example.com', token: 'token', space: { GUID: 'space-guid' } } as unknown as CfConfig; + const mockUi5AppInfo = { asyncHints: { libs: [] } } as unknown as CfUi5AppInfo; + + const mockFindCustomTask = jest.fn(); + const mockUi5Config = { findCustomTask: mockFindCustomTask }; + + let writeFileSyncSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + writeFileSyncSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); + mockReadUi5Yaml.mockResolvedValue(mockUi5Config as any); + mockGetOrCreateServiceInstanceKeys.mockResolvedValue({ + serviceKeys: [{ credentials: { 'html5-apps-repo': { app_host_id: 'host-id-1' } } }] + } as unknown as ServiceInfo); + mockGetAppHostIds.mockReturnValue(['host-id-1']); + mockGetBaseAppId.mockResolvedValue('customer.base.app'); + mockGetCfUi5AppInfo.mockResolvedValue(mockUi5AppInfo); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should download and write ui5AppInfo.json successfully', async () => { + mockFindCustomTask.mockReturnValue({ configuration: { serviceInstanceName: 'my-html5-repo' } }); + + await downloadUi5AppInfo(projectPath, cfConfig, mockLogger); + + expect(mockReadUi5Yaml).toHaveBeenCalledWith(projectPath, 'ui5.yaml'); + expect(mockGetOrCreateServiceInstanceKeys).toHaveBeenCalledWith({ names: ['my-html5-repo'] }, mockLogger); + expect(mockGetBaseAppId).toHaveBeenCalledWith(projectPath); + expect(mockGetAppHostIds).toHaveBeenCalledWith([{ credentials: { 'html5-apps-repo': { app_host_id: 'host-id-1' } } }]); + expect(mockGetCfUi5AppInfo).toHaveBeenCalledWith('customer.base.app', ['host-id-1'], cfConfig, mockLogger); + expect(writeFileSyncSpy).toHaveBeenCalledWith( + join(projectPath, 'ui5AppInfo.json'), + JSON.stringify(mockUi5AppInfo, null, 2), + 'utf-8' + ); + }); + + test('should pass spaceGuids when space is present in bundler task config', async () => { + mockFindCustomTask.mockReturnValue({ + configuration: { serviceInstanceName: 'my-html5-repo', space: 'my-space-guid' } + }); + + await downloadUi5AppInfo(projectPath, cfConfig, mockLogger); + + expect(mockGetOrCreateServiceInstanceKeys).toHaveBeenCalledWith( + { names: ['my-html5-repo'], spaceGuids: ['my-space-guid'] }, + mockLogger + ); + }); + + test('should throw when serviceInstanceName is missing from bundler task', async () => { + mockFindCustomTask.mockReturnValue({ configuration: {} }); + + await expect(downloadUi5AppInfo(projectPath, cfConfig)).rejects.toThrow(); + + expect(mockGetOrCreateServiceInstanceKeys).not.toHaveBeenCalled(); + }); + + test('should throw when bundler task is not found in ui5.yaml', async () => { + mockFindCustomTask.mockReturnValue(undefined); + + await expect(downloadUi5AppInfo(projectPath, cfConfig)).rejects.toThrow(); + }); + + test('should throw when no service keys are found', async () => { + mockFindCustomTask.mockReturnValue({ configuration: { serviceInstanceName: 'my-html5-repo' } }); + mockGetOrCreateServiceInstanceKeys.mockResolvedValue(null); + + await expect(downloadUi5AppInfo(projectPath, cfConfig, mockLogger)).rejects.toThrow( + 'No service keys found for service instance: my-html5-repo' + ); + + expect(mockGetCfUi5AppInfo).not.toHaveBeenCalled(); + }); + + test('should throw when service info has empty serviceKeys array', async () => { + mockFindCustomTask.mockReturnValue({ configuration: { serviceInstanceName: 'my-html5-repo' } }); + mockGetOrCreateServiceInstanceKeys.mockResolvedValue({ serviceKeys: [] } as unknown as ServiceInfo); + + await expect(downloadUi5AppInfo(projectPath, cfConfig, mockLogger)).rejects.toThrow( + 'No service keys found for service instance: my-html5-repo' + ); + }); + + test('should throw when no app host IDs are found in service keys', async () => { + mockFindCustomTask.mockReturnValue({ configuration: { serviceInstanceName: 'my-html5-repo' } }); + mockGetAppHostIds.mockReturnValue([]); + + await expect(downloadUi5AppInfo(projectPath, cfConfig, mockLogger)).rejects.toThrow( + 'No app host IDs found in service keys.' + ); + + expect(mockGetCfUi5AppInfo).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/adp-tooling/test/unit/cf/project/yaml.test.ts b/packages/adp-tooling/test/unit/cf/project/yaml.test.ts index 71e12855a74..e3ec1990068 100644 --- a/packages/adp-tooling/test/unit/cf/project/yaml.test.ts +++ b/packages/adp-tooling/test/unit/cf/project/yaml.test.ts @@ -12,11 +12,12 @@ import { getAppParamsFromUI5Yaml, adjustMtaYaml, addServeStaticMiddleware, - addBackendProxyMiddleware + addBackendProxyMiddleware, + addConnectivityServiceToMta } from '../../../../src/cf/project/yaml'; import { AppRouterType } from '../../../../src/types'; import type { MtaYaml, CfUI5Yaml, ServiceKeys } from '../../../../src/types'; -import { createServices } from '../../../../src/cf/services/api'; +import { createServices, createServiceInstance, getOrCreateServiceInstanceKeys } from '../../../../src/cf/services/api'; import { getProjectNameForXsSecurity, getYamlContent } from '../../../../src/cf/project/yaml-loader'; import { getBackendUrlsWithPaths } from '../../../../src/cf/app/discovery'; @@ -26,7 +27,9 @@ jest.mock('fs', () => ({ })); jest.mock('../../../../src/cf/services/api', () => ({ - createServices: jest.fn() + createServices: jest.fn(), + createServiceInstance: jest.fn(), + getOrCreateServiceInstanceKeys: jest.fn() })); jest.mock('../../../../src/cf/project/yaml-loader', () => ({ @@ -43,11 +46,17 @@ jest.mock('../../../../src/base/helper', () => ({ getVariant: jest.fn() })); +jest.mock('@sap-ux/project-access', () => ({ + readUi5Yaml: jest.fn() +})); + import { getVariant } from '../../../../src/base/helper'; const mockExistsSync = existsSync as jest.MockedFunction; const mockReadFileSync = readFileSync as jest.MockedFunction; const mockCreateServices = createServices as jest.MockedFunction; +const mockCreateServiceInstance = createServiceInstance as jest.MockedFunction; +const mockGetOrCreateServiceInstanceKeys = getOrCreateServiceInstanceKeys as jest.MockedFunction; const mockGetYamlContent = getYamlContent as jest.MockedFunction; const mockGetProjectNameForXsSecurity = getProjectNameForXsSecurity as jest.MockedFunction< typeof getProjectNameForXsSecurity @@ -1138,4 +1147,95 @@ describe('YAML Project Functions', () => { expect(mockUi5Config.addCustomMiddleware).not.toHaveBeenCalled(); }); }); + + describe('addConnectivityServiceToMta', () => { + const projectPath = '/test/project'; + const mtaYamlPath = join(projectPath, 'mta.yaml'); + const mockMtaYaml: MtaYaml = { + '_schema-version': '3.2.0', + ID: 'MyProject', + version: '1.0.0', + modules: [], + resources: [] + }; + + let mockMemFs: { write: jest.Mock }; + + beforeEach(() => { + mockMemFs = { write: jest.fn() }; + }); + + test('should add connectivity resource when mta.yaml exists and resource is not yet present', async () => { + mockExistsSync.mockReturnValue(true); + mockGetYamlContent.mockReturnValue({ ...mockMtaYaml, resources: [] }); + + await addConnectivityServiceToMta(projectPath, mockMemFs as unknown as Editor); + + expect(mockMemFs.write).toHaveBeenCalledWith( + mtaYamlPath, + expect.stringContaining('myproject-connectivity') + ); + expect(mockMemFs.write).toHaveBeenCalledWith( + mtaYamlPath, + expect.stringContaining('connectivity') + ); + expect(mockMemFs.write).toHaveBeenCalledWith( + mtaYamlPath, + expect.stringContaining('lite') + ); + expect(mockCreateServiceInstance).toHaveBeenCalledWith('lite', 'myproject-connectivity', 'connectivity', expect.any(Object)); + expect(mockGetOrCreateServiceInstanceKeys).toHaveBeenCalledWith({ names: ['myproject-connectivity'] }, undefined); + }); + + test('should do nothing when project has no mta.yaml', async () => { + mockExistsSync.mockReturnValue(false); + + await addConnectivityServiceToMta(projectPath, mockMemFs as unknown as Editor); + + expect(mockMemFs.write).not.toHaveBeenCalled(); + expect(mockCreateServiceInstance).not.toHaveBeenCalled(); + expect(mockGetOrCreateServiceInstanceKeys).not.toHaveBeenCalled(); + }); + + test('should do nothing when yaml content cannot be read', async () => { + mockExistsSync.mockReturnValue(true); + mockGetYamlContent.mockReturnValue(null); + + await addConnectivityServiceToMta(projectPath, mockMemFs as unknown as Editor); + + expect(mockMemFs.write).not.toHaveBeenCalled(); + expect(mockCreateServiceInstance).not.toHaveBeenCalled(); + expect(mockGetOrCreateServiceInstanceKeys).not.toHaveBeenCalled(); + }); + + test('should do nothing when connectivity resource already exists (idempotent)', async () => { + mockExistsSync.mockReturnValue(true); + mockGetYamlContent.mockReturnValue({ + ...mockMtaYaml, + resources: [ + { + name: 'myproject-connectivity', + type: 'org.cloudfoundry.managed-service', + parameters: { service: 'connectivity', 'service-plan': 'lite' } + } + ] + }); + + await addConnectivityServiceToMta(projectPath, mockMemFs as unknown as Editor); + + expect(mockMemFs.write).not.toHaveBeenCalled(); + expect(mockCreateServiceInstance).not.toHaveBeenCalled(); + expect(mockGetOrCreateServiceInstanceKeys).not.toHaveBeenCalled(); + }); + + test('should not write mta.yaml when createServiceInstance fails', async () => { + mockExistsSync.mockReturnValue(true); + mockGetYamlContent.mockReturnValue({ ...mockMtaYaml, resources: [] }); + mockCreateServiceInstance.mockRejectedValueOnce(new Error('CF error')); + + await expect(addConnectivityServiceToMta(projectPath, mockMemFs as unknown as Editor)).rejects.toThrow('CF error'); + + expect(mockMemFs.write).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/adp-tooling/test/unit/cf/services/api.test.ts b/packages/adp-tooling/test/unit/cf/services/api.test.ts index 2921efc68aa..d4067309e54 100644 --- a/packages/adp-tooling/test/unit/cf/services/api.test.ts +++ b/packages/adp-tooling/test/unit/cf/services/api.test.ts @@ -14,7 +14,8 @@ import { createServiceInstance, getServiceNameByTags, createServices, - getOrCreateServiceInstanceKeys + getOrCreateServiceInstanceKeys, + listBtpDestinations } from '../../../../src/cf/services/api'; import { initI18n, t } from '../../../../src/i18n'; import { isLoggedInCf } from '../../../../src/cf/core/auth'; @@ -964,4 +965,56 @@ describe('CF Services API', () => { ); }); }); + + describe('listBtpDestinations', () => { + const mockCredentials = { + uri: 'https://destination.cfapps.example.com', + uaa: { clientid: 'client-id', clientsecret: 'client-secret', url: 'https://auth.example.com' } + }; + + const mockBtpConfigs = [ + { Name: 'DEST_ONE', Type: 'HTTP', URL: 'https://one.example.com', Authentication: 'NoAuthentication', ProxyType: 'Internet', Description: 'First dest' }, + { Name: 'DEST_TWO', Type: 'HTTP', URL: 'https://two.example.com', Authentication: 'BasicAuthentication', ProxyType: 'OnPremise' } + ]; + + beforeEach(() => { + jest.spyOn(axios, 'post').mockResolvedValueOnce({ data: { access_token: 'mock-token' } }); + }); + + it('should return a Destinations map built from the BTP API response', async () => { + jest.spyOn(axios, 'get').mockResolvedValueOnce({ data: mockBtpConfigs }); + + const result = await listBtpDestinations(mockCredentials); + + expect(result).toEqual({ + DEST_ONE: { Name: 'DEST_ONE', Host: 'https://one.example.com', Type: 'HTTP', Authentication: 'NoAuthentication', ProxyType: 'Internet', Description: 'First dest' }, + DEST_TWO: { Name: 'DEST_TWO', Host: 'https://two.example.com', Type: 'HTTP', Authentication: 'BasicAuthentication', ProxyType: 'OnPremise', Description: '' } + }); + }); + + it('should handle flat credentials (no nested uaa object)', async () => { + const flatCredentials = { + uri: 'https://destination.cfapps.example.com', + clientid: 'client-id', + clientsecret: 'client-secret', + url: 'https://auth.example.com' + }; + jest.spyOn(axios, 'get').mockResolvedValueOnce({ data: mockBtpConfigs }); + + const result = await listBtpDestinations(flatCredentials); + + expect(result).toEqual({ + DEST_ONE: { Name: 'DEST_ONE', Host: 'https://one.example.com', Type: 'HTTP', Authentication: 'NoAuthentication', ProxyType: 'Internet', Description: 'First dest' }, + DEST_TWO: { Name: 'DEST_TWO', Host: 'https://two.example.com', Type: 'HTTP', Authentication: 'BasicAuthentication', ProxyType: 'OnPremise', Description: '' } + }); + }); + + it('should throw when the BTP destination API call fails', async () => { + jest.spyOn(axios, 'get').mockRejectedValueOnce(new Error('Network error')); + + await expect(listBtpDestinations(mockCredentials)).rejects.toThrow( + t('error.failedToListBtpDestinations', { error: 'Network error' }) + ); + }); + }); }); diff --git a/packages/adp-tooling/test/unit/cf/services/destinations.test.ts b/packages/adp-tooling/test/unit/cf/services/destinations.test.ts new file mode 100644 index 00000000000..850b3b21e7c --- /dev/null +++ b/packages/adp-tooling/test/unit/cf/services/destinations.test.ts @@ -0,0 +1,132 @@ +import { getDestinations } from '../../../../src/cf/services/destinations'; +import { isAppStudio, listDestinations } from '@sap-ux/btp-utils'; +import { getOrCreateServiceInstanceKeys, listBtpDestinations } from '../../../../src/cf/services/api'; +import { getYamlContent } from '../../../../src/cf/project/yaml-loader'; +import { initI18n, t } from '../../../../src/i18n'; + +jest.mock('@sap-ux/btp-utils', () => ({ + ...jest.requireActual('@sap-ux/btp-utils'), + isAppStudio: jest.fn(), + listDestinations: jest.fn() +})); + +jest.mock('../../../../src/cf/services/api', () => ({ + getOrCreateServiceInstanceKeys: jest.fn(), + listBtpDestinations: jest.fn() +})); + +jest.mock('../../../../src/cf/project/yaml-loader', () => ({ + getYamlContent: jest.fn() +})); + +const isAppStudioMock = isAppStudio as jest.Mock; +const listDestinationsMock = listDestinations as jest.Mock; +const getOrCreateServiceInstanceKeysMock = getOrCreateServiceInstanceKeys as jest.Mock; +const listBtpDestinationsMock = listBtpDestinations as jest.Mock; +const getYamlContentMock = getYamlContent as jest.Mock; + +const mockProjectPath = '/path/to/project'; + +const mockMtaYaml = { + ID: 'test-project', + '_schema-version': '3.3.0', + version: '0.0.1', + resources: [ + { name: 'test-project-destination', type: 'org.cloudfoundry.managed-service', parameters: { service: 'destination', 'service-plan': 'lite' } }, + { name: 'test-project-uaa', type: 'org.cloudfoundry.managed-service', parameters: { service: 'xsuaa', 'service-plan': 'application' } } + ] +}; + +const mockDestinations = { + MY_DEST: { Name: 'MY_DEST', Host: 'https://dest.example.com', Type: 'HTTP', Authentication: 'NoAuthentication', ProxyType: 'Internet', Description: 'My destination' } +}; + +const mockCredentials = { uri: 'https://destination.cfapps.example.com', uaa: { clientid: 'client-id', clientsecret: 'client-secret', url: 'https://auth.example.com' } }; + +const mockServiceInfo = { + serviceKeys: [{ credentials: mockCredentials }], + serviceInstance: { name: 'test-project-destination', guid: 'some-guid' } +}; + +describe('getDestinations', () => { + beforeAll(async () => { + await initI18n(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call listDestinations when running in BAS', async () => { + isAppStudioMock.mockReturnValue(true); + listDestinationsMock.mockResolvedValue(mockDestinations); + + const result = await getDestinations(mockProjectPath); + + expect(listDestinationsMock).toHaveBeenCalledTimes(1); + expect(getOrCreateServiceInstanceKeysMock).not.toHaveBeenCalled(); + expect(result).toBe(mockDestinations); + }); + + it('should call listBtpDestinations with destination service credentials when running in VS Code', async () => { + isAppStudioMock.mockReturnValue(false); + getYamlContentMock.mockReturnValue(mockMtaYaml); + getOrCreateServiceInstanceKeysMock.mockResolvedValue(mockServiceInfo); + listBtpDestinationsMock.mockResolvedValue(mockDestinations); + + const result = await getDestinations(mockProjectPath); + + expect(listDestinationsMock).not.toHaveBeenCalled(); + expect(getYamlContentMock).toHaveBeenCalledWith('/path/to/mta.yaml'); + expect(getOrCreateServiceInstanceKeysMock).toHaveBeenCalledWith({ names: ['test-project-destination'] }); + expect(listBtpDestinationsMock).toHaveBeenCalledWith(mockCredentials); + expect(result).toBe(mockDestinations); + }); + + it('should throw an error when no destination service is found in mta.yaml', async () => { + isAppStudioMock.mockReturnValue(false); + getYamlContentMock.mockReturnValue({ + ...mockMtaYaml, + resources: [{ name: 'test-project-uaa', type: 'org.cloudfoundry.managed-service', parameters: { service: 'xsuaa', 'service-plan': 'application' } }] + }); + + await expect(getDestinations(mockProjectPath)).rejects.toThrow(t('error.destinationServiceNotFoundInMtaYaml')); + + expect(getOrCreateServiceInstanceKeysMock).not.toHaveBeenCalled(); + }); + + it('should throw an error when mta.yaml cannot be read', async () => { + isAppStudioMock.mockReturnValue(false); + getYamlContentMock.mockImplementation(() => { + throw new Error('File not found'); + }); + + await expect(getDestinations(mockProjectPath)).rejects.toThrow('File not found'); + + expect(getOrCreateServiceInstanceKeysMock).not.toHaveBeenCalled(); + }); + + it('should throw an error when no service keys are available', async () => { + isAppStudioMock.mockReturnValue(false); + getYamlContentMock.mockReturnValue(mockMtaYaml); + getOrCreateServiceInstanceKeysMock.mockResolvedValue({ serviceKeys: [], serviceInstance: { name: 'test-project-destination', guid: 'some-guid' } }); + + await expect(getDestinations(mockProjectPath)).rejects.toThrow( + t('error.noServiceKeysFoundForDestination', { serviceInstanceName: 'test-project-destination' }) + ); + + expect(listBtpDestinationsMock).not.toHaveBeenCalled(); + }); + + it('should throw an error when getOrCreateServiceInstanceKeys returns null', async () => { + isAppStudioMock.mockReturnValue(false); + getYamlContentMock.mockReturnValue(mockMtaYaml); + getOrCreateServiceInstanceKeysMock.mockResolvedValue(null); + + await expect(getDestinations(mockProjectPath)).rejects.toThrow( + t('error.noServiceKeysFoundForDestination', { serviceInstanceName: 'test-project-destination' }) + ); + + expect(listBtpDestinationsMock).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/packages/adp-tooling/test/unit/cf/services/ssh.test.ts b/packages/adp-tooling/test/unit/cf/services/ssh.test.ts new file mode 100644 index 00000000000..b95b9f7692f --- /dev/null +++ b/packages/adp-tooling/test/unit/cf/services/ssh.test.ts @@ -0,0 +1,135 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import * as CFToolsCli from '@sap/cf-tools/out/src/cli'; + +import type { ToolsLogger } from '@sap-ux/logger'; + +import { ensureTunnelAppExists, enableSshAndRestart } from '../../../../src/cf/services/ssh'; + +const mockTmpDir = path.join('/tmp', 'adp-tunnel-mock'); + +jest.mock('@sap/cf-tools/out/src/cli', () => ({ + Cli: { + execute: jest.fn() + } +})); + +jest.mock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + mkdtempSync: jest.fn(), + writeFileSync: jest.fn(), + rmSync: jest.fn() +})); + +const mockRmSync = fs.rmSync as jest.Mock; +const mockMkdtempSync = fs.mkdtempSync as jest.Mock; +const mockWriteFileSync = fs.writeFileSync as jest.Mock; +const mockCFToolsCliExecute = CFToolsCli.Cli.execute as jest.MockedFunction; + +describe('SSH Services', () => { + const mockLogger = { + info: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn() + } as unknown as ToolsLogger; + + beforeEach(() => { + jest.clearAllMocks(); + mockMkdtempSync.mockReturnValue(mockTmpDir); + }); + + describe('ensureTunnelAppExists', () => { + test('should skip deploy when app already exists', async () => { + mockCFToolsCliExecute.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + + await ensureTunnelAppExists('my-tunnel', mockLogger); + + expect(mockCFToolsCliExecute).toHaveBeenCalledTimes(1); + expect(mockCFToolsCliExecute).toHaveBeenCalledWith( + ['app', 'my-tunnel'], + expect.objectContaining({ env: { CF_COLOR: 'false' } }) + ); + expect(mockMkdtempSync).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith('Tunnel app "my-tunnel" already exists.'); + }); + + test('should deploy minimal app when not found', async () => { + mockCFToolsCliExecute + .mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: 'not found' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); + + await ensureTunnelAppExists('my-tunnel', mockLogger); + + expect(mockCFToolsCliExecute).toHaveBeenCalledTimes(2); + expect(mockMkdtempSync).toHaveBeenCalled(); + expect(mockWriteFileSync).toHaveBeenCalledWith(path.join(mockTmpDir, '.keep'), ''); + expect(mockCFToolsCliExecute).toHaveBeenCalledWith( + [ + 'push', + 'my-tunnel', + '-p', + mockTmpDir, + '--no-route', + '-m', + '64M', + '-k', + '256M', + '-b', + 'binary_buildpack', + '-c', + 'sleep infinity', + '--health-check-type', + 'process' + ], + expect.objectContaining({ env: { CF_COLOR: 'false' } }) + ); + expect(mockLogger.info).toHaveBeenCalledWith('Tunnel app "my-tunnel" deployed successfully.'); + }); + + test('should clean up temp directory even when push fails', async () => { + mockCFToolsCliExecute + .mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: 'not found' }) + .mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: 'push failed' }); + + await expect(ensureTunnelAppExists('my-tunnel', mockLogger)).rejects.toThrow(); + + expect(mockRmSync).toHaveBeenCalledWith(mockTmpDir, { + recursive: true, + force: true + }); + }); + }); + + describe('enableSshAndRestart', () => { + test('should enable SSH and restart the app', async () => { + mockCFToolsCliExecute.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + + await enableSshAndRestart('my-tunnel', mockLogger); + + expect(mockCFToolsCliExecute).toHaveBeenCalledTimes(2); + expect(mockCFToolsCliExecute).toHaveBeenCalledWith( + ['enable-ssh', 'my-tunnel'], + expect.objectContaining({ env: { CF_COLOR: 'false' } }) + ); + expect(mockCFToolsCliExecute).toHaveBeenCalledWith( + ['restart', 'my-tunnel', '--strategy', 'rolling', '--no-wait'], + expect.objectContaining({ env: { CF_COLOR: 'false' } }) + ); + }); + + test('should throw when enable-ssh fails', async () => { + mockCFToolsCliExecute.mockResolvedValue({ exitCode: 1, stdout: '', stderr: 'ssh failed' }); + + await expect(enableSshAndRestart('my-tunnel', mockLogger)).rejects.toThrow(); + }); + + test('should throw when restart fails', async () => { + mockCFToolsCliExecute + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: 'restart failed' }); + + await expect(enableSshAndRestart('my-tunnel', mockLogger)).rejects.toThrow(); + }); + }); +}); diff --git a/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts b/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts index a15c894ff09..454c1033018 100644 --- a/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts +++ b/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts @@ -1,13 +1,22 @@ import * as i18n from '../../../../src/i18n'; import type { NewModelAnswers } from '../../../../src'; import { isCFEnvironment } from '../../../../src/base/cf'; +import { getAdpConfig } from '../../../../src/base/helper'; import { getPrompts } from '../../../../src/prompts/add-new-model'; import * as validators from '@sap-ux/project-input-validator'; import { getChangesByType } from '../../../../src/base/change-utils'; +import { listDestinations } from '@sap-ux/btp-utils'; +import { getDestinations } from '../../../../src/cf/services/destinations'; +import { Severity } from '@sap-devx/yeoman-ui-types'; +import { readFileSync } from 'node:fs'; const getChangesByTypeMock = getChangesByType as jest.Mock; - const isCFEnvironmentMock = isCFEnvironment as jest.Mock; +const getAdpConfigMock = getAdpConfig as jest.Mock; +const listDestinationsMock = listDestinations as jest.Mock; +const getDestinationsMock = getDestinations as jest.Mock; + +const readFileSyncMock = readFileSync as jest.Mock; jest.mock('../../../../src/base/change-utils.ts', () => ({ ...jest.requireActual('../../../../src/base/change-utils.ts'), @@ -18,6 +27,25 @@ jest.mock('../../../../src/base/cf.ts', () => ({ isCFEnvironment: jest.fn() })); +jest.mock('../../../../src/base/helper.ts', () => ({ + ...jest.requireActual('../../../../src/base/helper.ts'), + getAdpConfig: jest.fn() +})); + +jest.mock('@sap-ux/btp-utils', () => ({ + ...jest.requireActual('@sap-ux/btp-utils'), + listDestinations: jest.fn() +})); + +jest.mock('../../../../src/cf/services/destinations', () => ({ + getDestinations: jest.fn() +})); + +jest.mock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + readFileSync: jest.fn() +})); + jest.mock('@sap-ux/project-input-validator', () => ({ ...jest.requireActual('@sap-ux/project-input-validator'), hasContentDuplication: jest.fn().mockReturnValue(false), @@ -37,22 +65,29 @@ describe('getPrompts', () => { beforeEach(() => { getChangesByTypeMock.mockReturnValue([]); + isCFEnvironmentMock.mockResolvedValue(false); + getAdpConfigMock.mockRejectedValue(new Error('ui5.yaml not found')); + listDestinationsMock.mockResolvedValue({}); + getDestinationsMock.mockResolvedValue({}); + readFileSyncMock.mockClear(); + readFileSyncMock.mockReturnValue('{"routes": []}'); }); - it('should generate prompts with default settings for non-customer layers', async () => { - isCFEnvironmentMock.mockReturnValue(false); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('should generate prompts with default settings for non-customer layers', async () => { const vendorPrompts = await getPrompts(mockPath, 'VENDOR'); expect(vendorPrompts.length).toBeGreaterThan(0); - expect(vendorPrompts[0].default).toBe(''); - expect(vendorPrompts.some((prompt) => prompt.name === 'version')).toBeTruthy(); + expect(vendorPrompts.some((prompt) => prompt.name === 'serviceType')).toBeTruthy(); }); it('should adjust defaults based on customer layer', async () => { const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); - expect(prompts[0].default).toBe('customer.'); + expect(prompts.find((p) => p.name === 'modelAndDatasourceName')?.default).toBe('customer.'); }); it('should return true when validating service name prompt', async () => { @@ -60,10 +95,10 @@ describe('getPrompts', () => { const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); - const validation = prompts.find((p) => p.name === 'name')?.validate; + const validation = prompts.find((p) => p.name === 'modelAndDatasourceName')?.validate; expect(typeof validation).toBe('function'); - expect(validation?.('customer.testName', { dataSourceName: 'otherName' } as NewModelAnswers)).toBe(true); + expect(validation?.('customer.testName')).toBe(true); expect(hasContentDuplicationSpy).toHaveBeenCalledWith('customer.testName', 'dataSource', []); }); @@ -72,22 +107,22 @@ describe('getPrompts', () => { const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); - const validation = prompts.find((p) => p.name === 'name')?.validate; + const validation = prompts.find((p) => p.name === 'modelAndDatasourceName')?.validate; expect(typeof validation).toBe('function'); - expect(validation?.('testName', { dataSourceName: 'otherName' } as NewModelAnswers)).toBe( - "OData Service Name must start with 'customer.'." + expect(validation?.('testName')).toBe( + "Model and Datasource Name must start with 'customer.'." ); }); it('should return error message when validating service name prompt and name is only "customer."', async () => { const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); - const validation = prompts.find((p) => p.name === 'name')?.validate; + const validation = prompts.find((p) => p.name === 'modelAndDatasourceName')?.validate; expect(typeof validation).toBe('function'); - expect(validation?.('customer.', { dataSourceName: 'otherName' } as NewModelAnswers)).toBe( - "OData Service Name must contain at least one character in addition to 'customer.'." + expect(validation?.('customer.')).toBe( + "Model and Datasource Name must contain at least one character in addition to 'customer.'." ); }); @@ -95,10 +130,10 @@ describe('getPrompts', () => { jest.spyOn(validators, 'validateSpecialChars').mockReturnValueOnce('general.invalidValueForSpecialChars'); const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); - const validation = prompts.find((p) => p.name === 'name')?.validate; + const validation = prompts.find((p) => p.name === 'modelAndDatasourceName')?.validate; expect(typeof validation).toBe('function'); - expect(validation?.('customer.testName@', { dataSourceName: 'otherName' } as NewModelAnswers)).toBe( + expect(validation?.('customer.testName@')).toBe( 'general.invalidValueForSpecialChars' ); }); @@ -108,34 +143,14 @@ describe('getPrompts', () => { const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); - const validation = prompts.find((p) => p.name === 'name')?.validate; + const validation = prompts.find((p) => p.name === 'modelAndDatasourceName')?.validate; expect(typeof validation).toBe('function'); - expect( - validation?.('customer.testName', { - dataSourceName: 'otherName' - } as NewModelAnswers) - ).toBe( + expect(validation?.('customer.testName')).toBe( 'An OData annotation or service with the same name was already added to the project. Rename and try again.' ); }); - it('should return error message when validating service name prompt has name duplication', async () => { - const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); - - const validation = prompts.find((p) => p.name === 'name')?.validate; - - expect(typeof validation).toBe('function'); - expect( - validation?.('customer.testName', { - addAnnotationMode: true, - dataSourceName: 'customer.testName' - } as NewModelAnswers) - ).toBe( - 'An OData Service Name must be different from an OData Annotation Data Source Name. Rename and try again.' - ); - }); - it('should return true when validating service uri prompt', async () => { const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); @@ -167,106 +182,69 @@ describe('getPrompts', () => { expect(validation?.('/sap/opu /odata4/')).toBe(i18n.t('validators.errorInvalidDataSourceURI')); }); - it('should return default value for odata version when uri answer is present', async () => { - isCFEnvironmentMock.mockReturnValueOnce(true).mockReturnValueOnce(false); - - const result = await getPrompts(mockPath, 'CUSTOMER_BASE'); - - const dafaultFn = result.find((prompt) => prompt.name === 'version')?.default; - - expect(typeof dafaultFn).toBe('function'); - expect(dafaultFn({ uri: '/odata/v4/example' })).toBe('4.0'); - }); - - it('should return default value for odata version when uri answer is not present', async () => { - isCFEnvironmentMock.mockReturnValueOnce(true).mockReturnValueOnce(false); - - const result = await getPrompts(mockPath, 'CUSTOMER_BASE'); - - const dafaultFn = result.find((prompt) => prompt.name === 'version')?.default; - - expect(typeof dafaultFn).toBe('function'); - expect(dafaultFn({ uri: undefined })).toBe('2.0'); - }); - - it('should return default value for odata version based on uri answer in CF environment', async () => { - isCFEnvironmentMock.mockReturnValueOnce(true).mockReturnValueOnce(false); - - const result = await getPrompts(mockPath, 'CUSTOMER_BASE'); - - const dafaultFn = result.find((prompt) => prompt.name === 'version')?.default; - - expect(typeof dafaultFn).toBe('function'); - expect(dafaultFn({ uri: '/odata/v4/' })).toBe('4.0'); - }); - - it('should return default value for odata version based on uri answer not in CF environment', async () => { - isCFEnvironmentMock.mockReturnValueOnce(false).mockReturnValueOnce(false); - - const result = await getPrompts(mockPath, 'CUSTOMER_BASE'); - - const dafaultFn = result.find((prompt) => prompt.name === 'version')?.default; - - expect(typeof dafaultFn).toBe('function'); - expect(dafaultFn({ uri: '/sap/opu/odata4/' })).toBe('4.0'); - }); - - it('should return true when validating model name prompt', async () => { - const hasContentDuplicationSpy = jest.spyOn(validators, 'hasContentDuplication'); + it('should return information message with resulting service URL for ABAP VS Code project (url in ui5.yaml)', async () => { + getAdpConfigMock.mockResolvedValue({ target: { url: 'https://abap.example.com' } }); const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const additionalMessages = prompts.find((p) => p.name === 'uri')?.additionalMessages as Function; - const validation = prompts.find((p) => p.name === 'modelName')?.validate; - - expect(typeof validation).toBe('function'); - expect(validation?.('customer.testName')).toBe(true); - expect(hasContentDuplicationSpy).toHaveBeenCalledWith('customer.testName', 'model', []); + expect(typeof additionalMessages).toBe('function'); + const result = await additionalMessages('/sap/odata/v4/', undefined); + expect(result).toEqual({ + message: i18n.t('prompts.resultingServiceUrl', { url: 'https://abap.example.com/sap/odata/v4/', interpolation: { escapeValue: false } }), + severity: Severity.information + }); }); - it('should return error message when validating model name prompt without "customer." prefix', async () => { - jest.spyOn(validators, 'hasCustomerPrefix').mockReturnValueOnce(false); + it('should return information message with resulting service URL for ABAP BAS project (destination in ui5.yaml)', async () => { + getAdpConfigMock.mockResolvedValue({ target: { destination: 'MY_DEST' } }); + listDestinationsMock.mockResolvedValue({ MY_DEST: { Host: 'https://bas.dest.example.com', Name: 'MY_DEST' } }); const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const additionalMessages = prompts.find((p) => p.name === 'uri')?.additionalMessages as Function; - const validation = prompts.find((p) => p.name === 'modelName')?.validate; - - expect(typeof validation).toBe('function'); - expect(validation?.('testName')).toBe("OData Service SAPUI5 Model Name must start with 'customer.'."); + expect(typeof additionalMessages).toBe('function'); + const result = await additionalMessages('/sap/odata/v4/', undefined); + expect(result).toEqual({ + message: i18n.t('prompts.resultingServiceUrl', { url: 'https://bas.dest.example.com/sap/odata/v4/', interpolation: { escapeValue: false } }), + severity: Severity.information + }); }); - it('should return error message when validating model name contains only "customer."', async () => { - const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + it('should return information message with resulting service URL for CF project using selected destination', async () => { + isCFEnvironmentMock.mockResolvedValue(true); - const validation = prompts.find((p) => p.name === 'modelName')?.validate; + const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const additionalMessages = prompts.find((p) => p.name === 'uri')?.additionalMessages as Function; - expect(typeof validation).toBe('function'); - expect(validation?.('customer.')).toBe( - "OData Service SAPUI5 Model Name must contain at least one character in addition to 'customer.'." - ); + expect(typeof additionalMessages).toBe('function'); + const previousAnswers = { destination: { Host: 'https://cf.dest.example.com', Name: 'CF_DEST' } } as unknown as NewModelAnswers; + const result = await additionalMessages('/sap/odata/v4/', previousAnswers); + expect(result).toEqual({ + message: i18n.t('prompts.resultingServiceUrl', { url: 'https://cf.dest.example.com/sap/odata/v4/', interpolation: { escapeValue: false } }), + severity: Severity.information + }); }); - it('should return error message when validating model name prompt and has special characters', async () => { - jest.spyOn(validators, 'validateSpecialChars').mockReturnValueOnce('general.invalidValueForSpecialChars'); + it('should return undefined from additionalMessages when uri is invalid', async () => { + jest.spyOn(validators, 'isDataSourceURI').mockReturnValueOnce(false); + getAdpConfigMock.mockResolvedValue({ target: { url: 'https://abap.example.com' } }); const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const additionalMessages = prompts.find((p) => p.name === 'uri')?.additionalMessages as Function; - const validation = prompts.find((p) => p.name === 'modelName')?.validate; - - expect(typeof validation).toBe('function'); - expect(validation?.('customer.testName@')).toBe('general.invalidValueForSpecialChars'); + expect(typeof additionalMessages).toBe('function'); + const result = await additionalMessages('not-a-valid-uri', undefined); + expect(result).toBeUndefined(); }); - it('should return error message when validating model name prompt has content duplication', async () => { - jest.spyOn(validators, 'hasContentDuplication').mockReturnValueOnce(true); - + it('should return undefined from additionalMessages when no destination URL is available', async () => { const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const additionalMessages = prompts.find((p) => p.name === 'uri')?.additionalMessages as Function; - const validation = prompts.find((p) => p.name === 'modelName')?.validate; - - expect(typeof validation).toBe('function'); - expect(validation?.('customer.testName')).toBe( - 'An SAPUI5 model with the same name was already added to the project. Rename and try again.' - ); + expect(typeof additionalMessages).toBe('function'); + const result = await additionalMessages('/sap/odata/v4/', undefined); + expect(result).toBeUndefined(); }); it('should return true when validating model settings prompt', async () => { @@ -300,118 +278,125 @@ describe('getPrompts', () => { expect(validation?.('{"key": "value"}')).toBe('general.invalidJSON'); }); - it('should return error message when validating data source name prompt without "customer." prefix', async () => { - jest.spyOn(validators, 'hasCustomerPrefix').mockReturnValueOnce(false); - + it('should return true when validating data source uri prompt', async () => { const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); - const validation = prompts.find((p) => p.name === 'dataSourceName')?.validate; + + const validation = prompts.find((p) => p.name === 'dataSourceURI')?.validate; expect(typeof validation).toBe('function'); - expect(validation?.('testName', { name: 'testName' } as NewModelAnswers)).toBe( - "OData Annotation Data Source Name must start with 'customer.'." - ); + expect(validation?.('/sap/opu/odata4Ann/')).toBe(true); }); - it('should return error message when validating data source name prompt with only "customer." prefix', async () => { - const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + it('should return error message when data source uri is not valid uri', async () => { + jest.spyOn(validators, 'isDataSourceURI').mockReturnValueOnce(false); - const validation = prompts.find((p) => p.name === 'dataSourceName')?.validate; + const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const validation = prompts.find((p) => p.name === 'dataSourceURI')?.validate; expect(typeof validation).toBe('function'); - expect(validation?.('customer.', { name: 'customer.testName' } as NewModelAnswers)).toBe( - "OData Annotation Data Source Name must contain at least one character in addition to 'customer.'." - ); + expect(validation?.('/sap/opu /odata4Ann/')).toBe(i18n.t('validators.errorInvalidDataSourceURI')); }); - it('should return true when validating data source name prompt', async () => { - const hasContentDuplicationSpy = jest.spyOn(validators, 'hasContentDuplication'); - + it('should return true when validating annotation settings prompt', async () => { const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); - const validation = prompts.find((p) => p.name === 'dataSourceName')?.validate; + + const validation = prompts.find((p) => p.name === 'annotationSettings')?.validate; expect(typeof validation).toBe('function'); - expect(hasContentDuplicationSpy).toHaveBeenCalledWith('customer.testName', 'dataSource', []); - expect(validation?.('customer.testName', { name: 'otherName' } as NewModelAnswers)).toBe(true); + expect(validation?.('"key": "value"')).toBe(true); }); - it('should return error message when validating data source name prompt and has special characters', async () => { - jest.spyOn(validators, 'validateSpecialChars').mockReturnValueOnce('general.invalidValueForSpecialChars'); - + it('should display the dataSourceURI and annotationSettings prompts when addAnnotationMode is true', async () => { const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const answers = { addAnnotationMode: true } as NewModelAnswers; - const validation = prompts.find((p) => p.name === 'dataSourceName')?.validate; + const dataSourceURIPromptWhen = prompts.find((p) => p.name === 'dataSourceURI')?.when as Function; + const annotationSettingsPromptWhen = prompts.find((p) => p.name === 'annotationSettings')?.when as Function; - expect(typeof validation).toBe('function'); - expect(validation?.('customer.testName@', { name: 'otherName' } as NewModelAnswers)).toBe( - 'general.invalidValueForSpecialChars' - ); + expect(typeof dataSourceURIPromptWhen).toBe('function'); + expect(typeof annotationSettingsPromptWhen).toBe('function'); + expect(dataSourceURIPromptWhen(answers)).toBe(true); + expect(annotationSettingsPromptWhen(answers)).toBe(true); }); - it('should return error message when validating data source name prompt has content duplication', async () => { - jest.spyOn(validators, 'hasContentDuplication').mockReturnValueOnce(true); - + it('should show "Datasource Name" label for modelAndDatasourceName prompt when service type is HTTP', async () => { const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const messageFn = prompts.find((p) => p.name === 'modelAndDatasourceName')?.message as Function; - const validation = prompts.find((p) => p.name === 'dataSourceName')?.validate; + expect(typeof messageFn).toBe('function'); + expect(messageFn({ serviceType: 'HTTP' })).toBe(i18n.t('prompts.datasourceNameLabel')); + }); - expect(typeof validation).toBe('function'); - expect(validation?.('customer.testName', { name: 'otherName' } as NewModelAnswers)).toBe( - 'An OData annotation or service with the same name was already added to the project. Rename and try again.' - ); + it('should show "Model and Datasource Name" label for modelAndDatasourceName prompt when service type is OData', async () => { + const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const messageFn = prompts.find((p) => p.name === 'modelAndDatasourceName')?.message as Function; + + expect(typeof messageFn).toBe('function'); + expect(messageFn({ serviceType: 'OData v2' })).toBe(i18n.t('prompts.modelAndDatasourceNameLabel')); }); - it('should return error message when validating data source name prompt has name duplication', async () => { + it('should hide modelSettings and addAnnotationMode prompts when service type is HTTP', async () => { const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const httpAnswers = { serviceType: 'HTTP' } as unknown as NewModelAnswers; - const validation = prompts.find((p) => p.name === 'dataSourceName')?.validate; + const modelSettingsWhen = prompts.find((p) => p.name === 'modelSettings')?.when as Function; + const addAnnotationModeWhen = prompts.find((p) => p.name === 'addAnnotationMode')?.when as Function; - expect(typeof validation).toBe('function'); - expect(validation?.('customer.testName', { name: 'customer.testName' } as NewModelAnswers)).toBe( - 'An OData Service Name must be different from an OData Annotation Data Source Name. Rename and try again.' - ); + expect(typeof modelSettingsWhen).toBe('function'); + expect(typeof addAnnotationModeWhen).toBe('function'); + expect(modelSettingsWhen(httpAnswers)).toBe(false); + expect(addAnnotationModeWhen(httpAnswers)).toBe(false); }); - it('should return true when validating data source uri prompt', async () => { + it('should show modelSettings and addAnnotationMode prompts when service type is OData', async () => { const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const odataAnswers = { serviceType: 'OData v2' } as unknown as NewModelAnswers; - const validation = prompts.find((p) => p.name === 'dataSourceURI')?.validate; + const modelSettingsWhen = prompts.find((p) => p.name === 'modelSettings')?.when as Function; + const addAnnotationModeWhen = prompts.find((p) => p.name === 'addAnnotationMode')?.when as Function; - expect(typeof validation).toBe('function'); - expect(validation?.('/sap/opu/odata4Ann/')).toBe(true); + expect(typeof modelSettingsWhen).toBe('function'); + expect(typeof addAnnotationModeWhen).toBe('function'); + expect(modelSettingsWhen(odataAnswers)).toBe(true); + expect(addAnnotationModeWhen(odataAnswers)).toBe(true); }); - it('should return error message when data source uri is not valid uri', async () => { - jest.spyOn(validators, 'isDataSourceURI').mockReturnValueOnce(false); + it('should return error when xs-app.json already has a route with the same target for CF project', async () => { + isCFEnvironmentMock.mockResolvedValue(true); + readFileSyncMock.mockReturnValueOnce( + JSON.stringify({ routes: [{ source: '^some/route/(.*)', target: '/sap/opu/odata/v4/$1', destination: 'DEST' }] }) as any + ); const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); - const validation = prompts.find((p) => p.name === 'dataSourceURI')?.validate; + const validation = prompts.find((p) => p.name === 'uri')?.validate; expect(typeof validation).toBe('function'); - expect(validation?.('/sap/opu /odata4Ann/')).toBe(i18n.t('validators.errorInvalidDataSourceURI')); + expect(validation?.('/sap/opu/odata/v4/')).toBe(i18n.t('validators.errorRouteAlreadyExists')); }); - it('should return true when validating annotation settings prompt', async () => { - const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + it('should return true when xs-app.json has no matching route for CF project', async () => { + isCFEnvironmentMock.mockResolvedValue(true); + readFileSyncMock.mockReturnValueOnce( + JSON.stringify({ routes: [{ source: '^other/route/(.*)', target: '/other/route/$1', destination: 'DEST' }] }) as any + ); - const validation = prompts.find((p) => p.name === 'annotationSettings')?.validate; + const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const validation = prompts.find((p) => p.name === 'uri')?.validate; expect(typeof validation).toBe('function'); - expect(validation?.('"key": "value"')).toBe(true); + expect(validation?.('/sap/opu/odata/v4/')).toBe(true); }); - it('should display the dataSourceName, dataSourceURI, and annotationSettings prompts when addAnnotationMode is true', async () => { + it('should not check xs-app.json for duplicate routes in a non-CF project', async () => { const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); - const answers = { addAnnotationMode: true } as NewModelAnswers; + const validation = prompts.find((p) => p.name === 'uri')?.validate; - const dataSourceNamePromptWhen = prompts.find((p) => p.name === 'dataSourceName')?.when as Function; - const dataSourceURIPromptWhen = prompts.find((p) => p.name === 'dataSourceURI')?.when as Function; - const annotationSettingsPromptWhen = prompts.find((p) => p.name === 'annotationSettings')?.when as Function; + expect(typeof validation).toBe('function'); + validation?.('/sap/opu/odata/v4/'); - expect(typeof dataSourceNamePromptWhen).toBe('function'); - expect(typeof dataSourceURIPromptWhen).toBe('function'); - expect(typeof annotationSettingsPromptWhen).toBe('function'); - expect(dataSourceNamePromptWhen(answers)).toBe(true); - expect(dataSourceURIPromptWhen(answers)).toBe(true); - expect(annotationSettingsPromptWhen(answers)).toBe(true); + expect(readFileSyncMock).not.toHaveBeenCalledWith( + expect.stringContaining('xs-app.json'), + expect.anything() + ); }); }); diff --git a/packages/adp-tooling/test/unit/writer/cf.test.ts b/packages/adp-tooling/test/unit/writer/cf.test.ts index 929e979b60d..ed6bce3b7c2 100644 --- a/packages/adp-tooling/test/unit/writer/cf.test.ts +++ b/packages/adp-tooling/test/unit/writer/cf.test.ts @@ -2,30 +2,31 @@ import { join } from 'node:path'; import { rimraf } from 'rimraf'; import { create } from 'mem-fs-editor'; import { create as createStorage } from 'mem-fs'; -import { writeFileSync, mkdirSync, readFileSync, existsSync } from 'node:fs'; +import { writeFileSync, mkdirSync } from 'node:fs'; import type { ToolsLogger } from '@sap-ux/logger'; import type { Manifest } from '@sap-ux/project-access'; -import { generateCf, writeUi5AppInfo, generateCfConfig } from '../../../src/writer/cf'; -import { AppRouterType, FlexLayer, type CfAdpWriterConfig, type CfUi5AppInfo, type CfConfig } from '../../../src/types'; +import { generateCf, generateCfConfig } from '../../../src/writer/cf'; +import { AppRouterType, FlexLayer, type CfAdpWriterConfig, type CfConfig } from '../../../src/types'; import { - getAppHostIds, getOrCreateServiceInstanceKeys, addServeStaticMiddleware, addBackendProxyMiddleware, - getCfUi5AppInfo, getProjectNameForXsSecurity } from '../../../src/cf'; -import { getBaseAppId } from '../../../src/base/helper'; import { runBuild } from '../../../src/base/project-builder'; import { readUi5Yaml } from '@sap-ux/project-access'; +import { downloadUi5AppInfo } from '../../../src/cf/project/ui5-app-info'; + jest.mock('../../../src/cf'); -jest.mock('../../../src/base/helper'); jest.mock('../../../src/base/project-builder'); jest.mock('@sap-ux/project-access'); +jest.mock('../../../src/cf/project/ui5-app-info', () => ({ + ...jest.requireActual('../../../src/cf/project/ui5-app-info'), + downloadUi5AppInfo: jest.fn() +})); -const mockGetAppHostIds = getAppHostIds as jest.MockedFunction; const mockGetOrCreateServiceInstanceKeys = getOrCreateServiceInstanceKeys as jest.MockedFunction< typeof getOrCreateServiceInstanceKeys >; @@ -33,13 +34,12 @@ const mockAddServeStaticMiddleware = addServeStaticMiddleware as jest.MockedFunc const mockAddBackendProxyMiddleware = addBackendProxyMiddleware as jest.MockedFunction< typeof addBackendProxyMiddleware >; -const mockGetCfUi5AppInfo = getCfUi5AppInfo as jest.MockedFunction; -const mockGetBaseAppId = getBaseAppId as jest.MockedFunction; const mockRunBuild = runBuild as jest.MockedFunction; const mockReadUi5Yaml = readUi5Yaml as jest.MockedFunction; const mockGetProjectNameForXsSecurity = getProjectNameForXsSecurity as jest.MockedFunction< typeof getProjectNameForXsSecurity >; +const mockDownloadUi5AppInfo = downloadUi5AppInfo as jest.MockedFunction; const mockServiceKeys = [ { @@ -198,44 +198,6 @@ describe('CF Writer', () => { }); }); - describe('writeUi5AppInfo', () => { - const mockLogger = { - info: jest.fn(), - error: jest.fn() - } as unknown as ToolsLogger; - - const mockUi5AppInfo: CfUi5AppInfo = { - asyncHints: { - libs: [{ name: 'sap.m' }, { name: 'sap.ui.core' }] - } - }; - - afterEach(() => { - jest.clearAllMocks(); - }); - - test('should write ui5AppInfo.json to project root', async () => { - const projectDir = join(outputDir, 'ui5-app-info'); - mkdirSync(projectDir, { recursive: true }); - - await writeUi5AppInfo(projectDir, mockUi5AppInfo, mockLogger); - - const filePath = join(projectDir, 'ui5AppInfo.json'); - expect(existsSync(filePath)).toBe(true); - - const content = JSON.parse(readFileSync(filePath, 'utf-8')); - expect(content).toEqual(mockUi5AppInfo); - expect(mockLogger.info).toHaveBeenCalledWith(`Written ui5AppInfo.json to ${projectDir}`); - }); - - test('should throw error when write fails', async () => { - const invalidPath = '/invalid/path/that/does/not/exist'; - - await expect(writeUi5AppInfo(invalidPath, mockUi5AppInfo, mockLogger)).rejects.toThrow(); - expect(mockLogger.error).toHaveBeenCalled(); - }); - }); - describe('generateCfConfig', () => { const mockLogger = { info: jest.fn(), @@ -249,12 +211,6 @@ describe('CF Writer', () => { token: 'test-token' }; - const mockUi5AppInfo: CfUi5AppInfo = { - asyncHints: { - libs: [{ name: 'sap.m' }, { name: 'sap.ui.core' }] - } - }; - const mockUi5Config = { findCustomTask: jest.fn(), toString: jest.fn().mockReturnValue('ui5-config-content') @@ -266,9 +222,7 @@ describe('CF Writer', () => { serviceKeys: mockServiceKeys, serviceInstance: { guid: 'service-guid', name: 'service-name' } }); - mockGetAppHostIds.mockReturnValue(['host-123']); - mockGetBaseAppId.mockResolvedValue('test-app-id'); - mockGetCfUi5AppInfo.mockResolvedValue(mockUi5AppInfo); + mockDownloadUi5AppInfo.mockResolvedValue(undefined); mockAddServeStaticMiddleware.mockResolvedValue(undefined); mockAddBackendProxyMiddleware.mockReturnValue(undefined); mockRunBuild.mockResolvedValue(undefined); @@ -292,9 +246,7 @@ describe('CF Writer', () => { { names: ['test-service'], spaceGuids: ['space-guid'] }, mockLogger ); - expect(mockGetAppHostIds).toHaveBeenCalledWith(mockServiceKeys); - expect(mockGetBaseAppId).toHaveBeenCalledWith(projectDir); - expect(mockGetCfUi5AppInfo).toHaveBeenCalledWith('test-app-id', ['host-123'], mockCfConfig, mockLogger); + expect(mockDownloadUi5AppInfo).toHaveBeenCalledWith(projectDir, mockCfConfig, mockLogger); expect(mockAddServeStaticMiddleware).toHaveBeenCalledWith(projectDir, mockUi5Config, mockLogger); expect(mockRunBuild).toHaveBeenCalledWith(projectDir, { ADP_BUILDER_MODE: 'preview' }); expect(mockAddBackendProxyMiddleware).toHaveBeenCalledWith( @@ -331,20 +283,5 @@ describe('CF Writer', () => { 'No service keys found for service instance: test-service' ); }); - - test('should throw error when no app host IDs found', async () => { - const projectDir = join(outputDir, 'cf-config-no-hosts'); - mkdirSync(projectDir, { recursive: true }); - - mockUi5Config.findCustomTask.mockReturnValue({ - configuration: { serviceInstanceName: 'test-service' } - }); - - mockGetAppHostIds.mockReturnValue([]); - - await expect(generateCfConfig(projectDir, 'ui5.yaml', mockCfConfig, mockLogger)).rejects.toThrow( - 'No app host IDs found in service keys.' - ); - }); }); }); diff --git a/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts b/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts index 41563c22c12..ea97924bc79 100644 --- a/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts +++ b/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts @@ -7,6 +7,7 @@ import { writeChangeToFile, getChange } from '../../../../../src/base/change-utils'; +import { addConnectivityServiceToMta } from '../../../../../src/cf/project/yaml'; import type { AnnotationsData, ComponentUsagesDataBase, @@ -34,11 +35,16 @@ jest.mock('../../../../../src/base/change-utils', () => ({ writeChangeToFile: jest.fn() })); +jest.mock('../../../../../src/cf/project/yaml', () => ({ + addConnectivityServiceToMta: jest.fn() +})); + const writeAnnotationChangeMock = writeAnnotationChange as jest.Mock; const getChangeMock = getChange as jest.Mock; const writeChangeToFolderMock = writeChangeToFolder as jest.Mock; const findChangeWithInboundIdMock = findChangeWithInboundId as jest.Mock; const writeChangeToFileMock = writeChangeToFile as jest.Mock; +const addConnectivityServiceToMtaMock = addConnectivityServiceToMta as jest.Mock; const mockProjectPath = '/mock/project/path'; const mockTemplatePath = '/mock/template/path'; @@ -216,10 +222,17 @@ describe('ComponentUsagesWriter', () => { describe('NewModelWriter', () => { let writer: NewModelWriter; + const readJSONMock = jest.fn(); + const writeJSONMock = jest.fn(); beforeEach(() => { - writer = new NewModelWriter({} as Editor, mockProjectPath); jest.clearAllMocks(); + readJSONMock.mockReturnValue({ routes: [] }); + addConnectivityServiceToMtaMock.mockResolvedValue(undefined); + writer = new NewModelWriter( + { readJSON: readJSONMock, writeJSON: writeJSONMock } as unknown as Editor, + mockProjectPath + ); }); it('should correctly construct content and write new model change', async () => { @@ -228,7 +241,7 @@ describe('NewModelWriter', () => { service: { name: 'ODataService', uri: '/sap/opu/odata/custom', - modelName: 'ODataModel', + modelName: 'ODataService', version: '4.0', modelSettings: '"someSetting": "someValue"' }, @@ -263,7 +276,7 @@ describe('NewModelWriter', () => { } }, 'model': { - 'ODataModel': { + 'ODataService': { 'dataSource': mockData.service.name, 'settings': { 'someSetting': 'someValue' @@ -276,6 +289,223 @@ describe('NewModelWriter', () => { expect(writeChangeToFolderMock).toHaveBeenCalledWith(mockProjectPath, expect.any(Object), expect.any(Object)); }); + + it('should omit the model block when modelName is undefined (HTTP service type)', async () => { + const mockData: NewModelData = { + variant: {} as DescriptorVariant, + service: { + name: 'HttpService', + uri: '/api/http/service/', + modelName: undefined, + version: undefined + } + }; + + await writer.write(mockData); + + expect(getChangeMock).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + { + 'dataSource': { + 'HttpService': { + 'uri': mockData.service.uri, + 'type': 'OData', + 'settings': {} + } + }, + 'model': {} + }, + ChangeType.ADD_NEW_MODEL + ); + }); + + it('should construct CF-specific content with derived URI, preload and fixed model settings', async () => { + const mockData: NewModelData = { + variant: {} as DescriptorVariant, + isCloudFoundry: true, + destinationName: 'MY_CF_DEST', + service: { + name: 'customer.MyService', + uri: '/sap/opu/odata/v4/', + modelName: 'customer.MyService', + version: '4.0' + } + }; + + await writer.write(mockData); + + expect(getChangeMock).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + { + 'dataSource': { + 'customer.MyService': { + 'uri': '/customer/MyService/sap/opu/odata/v4/', + 'type': 'OData', + 'settings': { + 'odataVersion': '4.0' + } + } + }, + 'model': { + 'customer.MyService': { + 'dataSource': 'customer.MyService', + 'preload': true, + 'settings': { + 'operationMode': 'Server', + 'autoExpandSelect': true, + 'earlyRequests': true + } + } + } + }, + ChangeType.ADD_NEW_MODEL + ); + }); + + it('should create xs-app.json with a new route for a CF project when xs-app.json does not exist', async () => { + const mockData: NewModelData = { + variant: {} as DescriptorVariant, + isCloudFoundry: true, + destinationName: 'MY_CF_DEST', + service: { + name: 'customer.MyService', + uri: '/sap/opu/odata/v4/', + modelName: 'customer.MyService', + version: '4.0' + } + }; + + await writer.write(mockData); + + expect(readJSONMock).toHaveBeenCalledWith( + `${mockProjectPath}/webapp/xs-app.json`, + { routes: [] } + ); + expect(writeJSONMock).toHaveBeenCalledWith( + `${mockProjectPath}/webapp/xs-app.json`, + { + routes: [ + { + source: '^/customer/MyService/sap/opu/odata/v4/(.*)', + target: '/sap/opu/odata/v4/$1', + destination: 'MY_CF_DEST' + } + ] + } + ); + }); + + it('should append a route to existing xs-app.json routes for a CF project', async () => { + readJSONMock.mockReturnValue({ + routes: [{ source: '^existing/route/(.*)', target: '/existing/$1', destination: 'OTHER_DEST' }] + }); + + const mockData: NewModelData = { + variant: {} as DescriptorVariant, + isCloudFoundry: true, + destinationName: 'MY_CF_DEST', + service: { + name: 'customer.NewService', + uri: '/sap/opu/odata/v2/', + modelName: 'customer.NewService', + version: '2.0' + } + }; + + await writer.write(mockData); + + expect(writeJSONMock).toHaveBeenCalledWith( + `${mockProjectPath}/webapp/xs-app.json`, + { + routes: [ + { source: '^existing/route/(.*)', target: '/existing/$1', destination: 'OTHER_DEST' }, + { + source: '^/customer/NewService/sap/opu/odata/v2/(.*)', + target: '/sap/opu/odata/v2/$1', + destination: 'MY_CF_DEST' + } + ] + } + ); + }); + + it('should not write xs-app.json for a non-CF project', async () => { + const mockData: NewModelData = { + variant: {} as DescriptorVariant, + service: { + name: 'ODataService', + uri: '/sap/opu/odata/custom/', + modelName: 'ODataService', + version: '2.0' + } + }; + + await writer.write(mockData); + + expect(readJSONMock).not.toHaveBeenCalled(); + expect(writeJSONMock).not.toHaveBeenCalled(); + }); + + it('should call addConnectivityServiceToMta when isCloudFoundry and isOnPremiseDestination', async () => { + const mockData: NewModelData = { + variant: {} as DescriptorVariant, + isCloudFoundry: true, + isOnPremiseDestination: true, + destinationName: 'MY_CF_DEST', + service: { + name: 'customer.MyService', + uri: '/sap/opu/odata/v2/', + modelName: 'customer.MyService', + version: '2.0' + } + }; + + await writer.write(mockData); + + expect(addConnectivityServiceToMtaMock).toHaveBeenCalledWith( + mockProjectPath, + expect.any(Object) + ); + }); + + it('should not call addConnectivityServiceToMta when isCloudFoundry is false', async () => { + const mockData: NewModelData = { + variant: {} as DescriptorVariant, + isCloudFoundry: false, + isOnPremiseDestination: true, + service: { + name: 'ODataService', + uri: '/sap/opu/odata/v2/', + modelName: 'ODataService', + version: '2.0' + } + }; + + await writer.write(mockData); + + expect(addConnectivityServiceToMtaMock).not.toHaveBeenCalled(); + }); + + it('should not call addConnectivityServiceToMta when isOnPremiseDestination is false', async () => { + const mockData: NewModelData = { + variant: {} as DescriptorVariant, + isCloudFoundry: true, + isOnPremiseDestination: false, + destinationName: 'MY_CF_DEST', + service: { + name: 'customer.MyService', + uri: '/sap/opu/odata/v2/', + modelName: 'customer.MyService', + version: '2.0' + } + }; + + await writer.write(mockData); + + expect(addConnectivityServiceToMtaMock).not.toHaveBeenCalled(); + }); }); describe('DataSourceWriter', () => { diff --git a/packages/adp-tooling/test/unit/writer/editors.test.ts b/packages/adp-tooling/test/unit/writer/editors.test.ts index 742fa0aaa14..fa74753d2ce 100644 --- a/packages/adp-tooling/test/unit/writer/editors.test.ts +++ b/packages/adp-tooling/test/unit/writer/editors.test.ts @@ -33,7 +33,8 @@ describe('generateChange', () => { ChangeType.ADD_ANNOTATIONS_TO_ODATA, expect.anything(), '/path/to/project', - '/path/to/templates' + '/path/to/templates', + undefined ); expect(writeSpy).toHaveBeenCalledWith({ variant: {}, annotation: {} }); @@ -57,6 +58,7 @@ describe('generateChange', () => { ChangeType.ADD_ANNOTATIONS_TO_ODATA, {}, '/path/to/project', + undefined, undefined ); diff --git a/packages/create/README.md b/packages/create/README.md index df257856264..48261cf7c54 100644 --- a/packages/create/README.md +++ b/packages/create/README.md @@ -168,9 +168,6 @@ Options: Add a new OData service and SAPUI5 model to an existing adaptation project. - -This command is not supported for Cloud Foundry projects. - Example: `npx --yes @sap-ux/create@latest add model` diff --git a/packages/create/package.json b/packages/create/package.json index cfe60c19e51..d6e76af02cf 100644 --- a/packages/create/package.json +++ b/packages/create/package.json @@ -36,6 +36,7 @@ "@sap-ux/abap-deploy-config-inquirer": "workspace:*", "@sap-ux/abap-deploy-config-writer": "workspace:*", "@sap-ux/adp-tooling": "workspace:*", + "@sap-ux/btp-utils": "workspace:*", "@sap-ux/app-config-writer": "workspace:*", "@sap-ux/cap-config-writer": "workspace:*", "@sap-ux/logger": "workspace:*", diff --git a/packages/create/src/cli/add/new-model.ts b/packages/create/src/cli/add/new-model.ts index 8a3c5987ba4..7232182f184 100644 --- a/packages/create/src/cli/add/new-model.ts +++ b/packages/create/src/cli/add/new-model.ts @@ -1,7 +1,16 @@ import type { Command } from 'commander'; import type { DescriptorVariant, NewModelAnswers, NewModelData } from '@sap-ux/adp-tooling'; -import { generateChange, ChangeType, getPromptsForNewModel, getVariant, isCFEnvironment } from '@sap-ux/adp-tooling'; +import { + generateChange, + ChangeType, + getPromptsForNewModel, + getVariant, + isCFEnvironment, + getODataVersionFromServiceType, + ServiceType +} from '@sap-ux/adp-tooling'; +import { isOnPremiseDestination } from '@sap-ux/btp-utils'; import { promptYUIQuestions } from '../../common'; import { getLogger, traceChanges } from '../../tracing'; @@ -16,7 +25,6 @@ export function addNewModelCommand(cmd: Command): void { cmd.command('model [path]') .description( `Add a new OData service and SAPUI5 model to an existing adaptation project.\n - This command is not supported for Cloud Foundry projects.\n Example: \`npx --yes @sap-ux/create@latest add model\`` ) @@ -40,9 +48,6 @@ async function addNewModel(basePath: string, simulate: boolean): Promise { } await validateAdpAppType(basePath); - if (await isCFEnvironment(basePath)) { - throw new Error('This command is not supported for Cloud Foundry projects.'); - } const variant = await getVariant(basePath); @@ -51,7 +56,10 @@ async function addNewModel(basePath: string, simulate: boolean): Promise { const fs = await generateChange( basePath, ChangeType.ADD_NEW_MODEL, - createNewModelData(variant, answers) + await createNewModelData(basePath, variant, answers), + null, + undefined, + logger ); if (!simulate) { @@ -68,27 +76,37 @@ async function addNewModel(basePath: string, simulate: boolean): Promise { /** * Returns the writer data for the new model change. * + * @param {string} basePath - The path to the adaptation project. * @param {DescriptorVariant} variant - The variant of the adaptation project. * @param {NewModelAnswers} answers - The answers to the prompts. - * @returns {NewModelData} The writer data for the new model change. + * @returns {Promise} The writer data for the new model change. */ -function createNewModelData(variant: DescriptorVariant, answers: NewModelAnswers): NewModelData { - const { name, uri, modelName, version, modelSettings, addAnnotationMode } = answers; +async function createNewModelData( + basePath: string, + variant: DescriptorVariant, + answers: NewModelAnswers +): Promise { + const { modelAndDatasourceName, uri, serviceType, modelSettings, addAnnotationMode } = answers; + const isCloudFoundry = await isCFEnvironment(basePath); return { variant, + isCloudFoundry, + destinationName: isCloudFoundry ? answers.destination?.Name : undefined, + isOnPremiseDestination: + isCloudFoundry && answers.destination ? isOnPremiseDestination(answers.destination) : undefined, service: { - name, + name: modelAndDatasourceName, uri, - modelName, - version, + modelName: serviceType !== ServiceType.HTTP ? modelAndDatasourceName : undefined, + version: getODataVersionFromServiceType(serviceType), modelSettings }, ...(addAnnotationMode && { annotation: { - dataSourceName: answers.dataSourceName, + dataSourceName: `${modelAndDatasourceName}.annotation`, dataSourceURI: answers.dataSourceURI, settings: answers.annotationSettings } }) }; -} +} \ No newline at end of file diff --git a/packages/create/test/unit/cli/add/new-model.test.ts b/packages/create/test/unit/cli/add/new-model.test.ts index bf9b5a9a766..cede3b44065 100644 --- a/packages/create/test/unit/cli/add/new-model.test.ts +++ b/packages/create/test/unit/cli/add/new-model.test.ts @@ -6,6 +6,8 @@ import { readFileSync } from 'node:fs'; import type { ToolsLogger } from '@sap-ux/logger'; import * as projectAccess from '@sap-ux/project-access'; import { generateChange, getPromptsForNewModel } from '@sap-ux/adp-tooling'; +import * as adpTooling from '@sap-ux/adp-tooling'; +import * as btpUtils from '@sap-ux/btp-utils'; import * as common from '../../../../src/common'; import * as tracer from '../../../../src/tracing/trace'; @@ -23,41 +25,32 @@ const generateChangeMock = generateChange as jest.Mock; const getPromptsForNewModelMock = getPromptsForNewModel as jest.Mock; const mockAnswers = { - name: 'OData_ServiceName', + modelAndDatasourceName: 'customer.OData_ServiceName', uri: '/sap/opu/odata/some-name', - version: '4.0', - modelName: 'OData_ServiceModelName', + serviceType: 'OData v2', modelSettings: '"key": "value"' }; const mockAnswersWithAnnotation = { - name: 'OData_ServiceName', + modelAndDatasourceName: 'customer.OData_ServiceName', uri: '/sap/opu/odata/some-name', - version: '4.0', - modelName: 'OData_ServiceModelName', + serviceType: 'OData v2', modelSettings: '"key": "value"', addAnnotationMode: true, - dataSourceName: 'OData_AnnotationName', dataSourceURI: '/sap/opu/odata/annotation/', annotationSettings: '"key2":"value2"' }; -const mockService = { - name: 'OData_ServiceName', +const mockCFAnswers = { + destination: { Host: 'https://cf.dest.example.com', Name: 'CF_DEST' }, + modelAndDatasourceName: 'customer.OData_ServiceName', uri: '/sap/opu/odata/some-name', - version: '4.0', - modelName: 'OData_ServiceModelName', + serviceType: 'OData v2', modelSettings: '"key": "value"' }; -const mockAnnotation = { - dataSourceName: 'OData_AnnotationName', - dataSourceURI: '/sap/opu/odata/annotation/', - settings: '"key2":"value2"' -}; - -jest.mock('fs', () => ({ - ...jest.requireActual('fs'), +jest.mock('node:fs', () => ({ + ...jest.requireActual('node:fs'), readFileSync: jest.fn() })); @@ -68,7 +61,13 @@ jest.mock('@sap-ux/adp-tooling', () => ({ generateChange: jest.fn().mockResolvedValue({ commit: jest.fn().mockImplementation((cb) => cb()) } as Partial as Editor), - getPromptsForNewModel: jest.fn() + getPromptsForNewModel: jest.fn(), + isCFEnvironment: jest.fn() +})); + +jest.mock('@sap-ux/btp-utils', () => ({ + ...jest.requireActual('@sap-ux/btp-utils'), + isOnPremiseDestination: jest.fn() })); const getArgv = (...arg: string[]) => ['', '', 'model', ...arg]; @@ -87,6 +86,8 @@ describe('add/model', () => { jest.spyOn(common, 'promptYUIQuestions').mockResolvedValue(mockAnswers); jest.spyOn(logger, 'getLogger').mockImplementation(() => loggerMock); jest.spyOn(projectAccess, 'getAppType').mockResolvedValue('Fiori Adaptation'); + jest.spyOn(adpTooling, 'isCFEnvironment').mockResolvedValue(false); + jest.spyOn(btpUtils, 'isOnPremiseDestination').mockReturnValue(false); readFileSyncMock.mockReturnValue(JSON.stringify(descriptorVariant)); traceSpy = jest.spyOn(tracer, 'traceChanges').mockResolvedValue(); }); @@ -103,8 +104,17 @@ describe('add/model', () => { expect(loggerMock.debug).not.toHaveBeenCalled(); expect(traceSpy).not.toHaveBeenCalled(); expect(generateChangeMock).toHaveBeenCalledWith(expect.anything(), 'appdescr_ui5_addNewModel', { - service: mockAnswers, - variant: descriptorVariant + variant: descriptorVariant, + isCloudFoundry: false, + destinationName: undefined, + isOnPremiseDestination: undefined, + service: { + name: 'customer.OData_ServiceName', + uri: '/sap/opu/odata/some-name', + modelName: 'customer.OData_ServiceName', + version: '2.0', + modelSettings: '"key": "value"' + } }); }); @@ -118,9 +128,48 @@ describe('add/model', () => { expect(loggerMock.debug).not.toHaveBeenCalled(); expect(traceSpy).not.toHaveBeenCalled(); expect(generateChangeMock).toHaveBeenCalledWith(expect.anything(), 'appdescr_ui5_addNewModel', { - service: mockService, - annotation: mockAnnotation, - variant: descriptorVariant + variant: descriptorVariant, + isCloudFoundry: false, + destinationName: undefined, + isOnPremiseDestination: undefined, + service: { + name: 'customer.OData_ServiceName', + uri: '/sap/opu/odata/some-name', + modelName: 'customer.OData_ServiceName', + version: '2.0', + modelSettings: '"key": "value"' + }, + annotation: { + dataSourceName: 'customer.OData_ServiceName.annotation', + dataSourceURI: '/sap/opu/odata/annotation/', + settings: '"key2":"value2"' + } + }); + }); + + test('should generate change with correct data for CF project', async () => { + jest.spyOn(adpTooling, 'isCFEnvironment').mockResolvedValue(true); + jest.spyOn(btpUtils, 'isOnPremiseDestination').mockReturnValue(true); + jest.spyOn(common, 'promptYUIQuestions').mockResolvedValue(mockCFAnswers); + + const command = new Command('model'); + addNewModelCommand(command); + await command.parseAsync(getArgv(appRoot)); + + expect(loggerMock.debug).not.toHaveBeenCalled(); + expect(traceSpy).not.toHaveBeenCalled(); + expect(generateChangeMock).toHaveBeenCalledWith(expect.anything(), 'appdescr_ui5_addNewModel', { + variant: descriptorVariant, + isCloudFoundry: true, + destinationName: 'CF_DEST', + isOnPremiseDestination: true, + service: { + name: 'customer.OData_ServiceName', + uri: '/sap/opu/odata/some-name', + modelName: 'customer.OData_ServiceName', + version: '2.0', + modelSettings: '"key": "value"' + } }); }); @@ -132,8 +181,17 @@ describe('add/model', () => { expect(loggerMock.debug).not.toHaveBeenCalled(); expect(traceSpy).toHaveBeenCalled(); expect(generateChangeMock).toHaveBeenCalledWith(expect.anything(), 'appdescr_ui5_addNewModel', { - service: mockAnswers, - variant: descriptorVariant + variant: descriptorVariant, + isCloudFoundry: false, + destinationName: undefined, + isOnPremiseDestination: undefined, + service: { + name: 'customer.OData_ServiceName', + uri: '/sap/opu/odata/some-name', + modelName: 'customer.OData_ServiceName', + version: '2.0', + modelSettings: '"key": "value"' + } }); }); @@ -149,4 +207,4 @@ describe('add/model', () => { expect(traceSpy).not.toHaveBeenCalled(); expect(generateChangeMock).not.toHaveBeenCalled(); }); -}); +}); \ No newline at end of file diff --git a/packages/create/tsconfig.json b/packages/create/tsconfig.json index 97abef2e0eb..0387f1e61b6 100644 --- a/packages/create/tsconfig.json +++ b/packages/create/tsconfig.json @@ -24,6 +24,9 @@ { "path": "../axios-extension" }, + { + "path": "../btp-utils" + }, { "path": "../cap-config-writer" }, diff --git a/packages/generator-adp/src/add-new-model/index.ts b/packages/generator-adp/src/add-new-model/index.ts index 9f4c7e57bfa..f6a611a5d7c 100644 --- a/packages/generator-adp/src/add-new-model/index.ts +++ b/packages/generator-adp/src/add-new-model/index.ts @@ -1,10 +1,24 @@ import { MessageType, Prompts } from '@sap-devx/yeoman-ui-types'; -import type { NewModelAnswers, NewModelData, DescriptorVariant } from '@sap-ux/adp-tooling'; -import { generateChange, ChangeType, getPromptsForNewModel, getVariant } from '@sap-ux/adp-tooling'; +import type { NewModelAnswers, NewModelData, DescriptorVariant, CfConfig } from '@sap-ux/adp-tooling'; +import { + generateChange, + ChangeType, + getPromptsForNewModel, + getVariant, + getODataVersionFromServiceType, + ServiceType, + isCFEnvironment, + runBuild, + isLoggedInCf, + loadCfConfig, + downloadUi5AppInfo +} from '@sap-ux/adp-tooling'; +import { isOnPremiseDestination } from '@sap-ux/btp-utils'; import { GeneratorTypes } from '../types'; import { initI18n, t } from '../utils/i18n'; import type { GeneratorOpts } from '../utils/opts'; +import { installDependencies } from '../utils/deps'; import SubGeneratorBase from '../base/sub-gen-base'; /** @@ -19,6 +33,10 @@ class AddNewModelGenerator extends SubGeneratorBase { * The variant. */ private variant: DescriptorVariant; + /** + * The CF configuration, set when running in a CF environment. + */ + private cfConfig: CfConfig | undefined; /** * The project path. */ @@ -41,6 +59,14 @@ class AddNewModelGenerator extends SubGeneratorBase { await initI18n(); try { + if (await isCFEnvironment(this.projectPath)) { + this.cfConfig = loadCfConfig(this.logger); + const loggedIn = await isLoggedInCf(this.cfConfig, this.logger); + if (!loggedIn) { + throw new Error(t('error.cfNotLoggedIn')); + } + } + this._registerPrompts( new Prompts([ { name: t('yuiNavSteps.addNewModelName'), description: t('yuiNavSteps.addNewModelDescr') } @@ -69,35 +95,49 @@ class AddNewModelGenerator extends SubGeneratorBase { await generateChange( this.projectPath, ChangeType.ADD_NEW_MODEL, - this._createNewModelData(), - this.fs + await this._createNewModelData(), + this.fs, + undefined, + this.logger ); this.logger.log('Change written to changes folder'); } - end(): void { + async end(): Promise { this.logger.log('Successfully created change!'); + if (await isCFEnvironment(this.projectPath)) { + await installDependencies(this.projectPath); + await downloadUi5AppInfo(this.projectPath, this.cfConfig!, this.logger); + await runBuild(this.projectPath, { ADP_BUILDER_MODE: 'preview' }); + } } /** * Creates the new model data. * - * @returns {NewModelData} The new model data. + * @returns {Promise} The new model data. */ - private _createNewModelData(): NewModelData { - const { name, uri, modelName, version, modelSettings, addAnnotationMode } = this.answers; + private async _createNewModelData(): Promise { + const { modelAndDatasourceName, uri, serviceType, modelSettings, addAnnotationMode } = this.answers; + const isCloudFoundry = await isCFEnvironment(this.projectPath); return { variant: this.variant, + isCloudFoundry, + destinationName: isCloudFoundry ? this.answers.destination?.Name : undefined, + isOnPremiseDestination: + isCloudFoundry && this.answers.destination + ? isOnPremiseDestination(this.answers.destination) + : undefined, service: { - name, + name: modelAndDatasourceName, uri, - modelName, - version, + modelName: serviceType !== ServiceType.HTTP ? modelAndDatasourceName : undefined, + version: getODataVersionFromServiceType(serviceType), modelSettings }, ...(addAnnotationMode && { annotation: { - dataSourceName: this.answers.dataSourceName, + dataSourceName: `${modelAndDatasourceName}.annotation`, dataSourceURI: this.answers.dataSourceURI, settings: this.answers.annotationSettings } diff --git a/packages/generator-adp/test/unit/add-new-model/index.test.ts b/packages/generator-adp/test/unit/add-new-model/index.test.ts index 5dd4546c9fe..e92d32cbc3c 100644 --- a/packages/generator-adp/test/unit/add-new-model/index.test.ts +++ b/packages/generator-adp/test/unit/add-new-model/index.test.ts @@ -2,7 +2,16 @@ import fs from 'node:fs'; import { join, resolve } from 'node:path'; import yeomanTest from 'yeoman-test'; -import { ChangeType, generateChange, getVariant } from '@sap-ux/adp-tooling'; +import { + ChangeType, + generateChange, + getVariant, + isCFEnvironment, + runBuild, + isLoggedInCf, + loadCfConfig, + downloadUi5AppInfo +} from '@sap-ux/adp-tooling'; import type { NewModelAnswers, DescriptorVariant } from '@sap-ux/adp-tooling'; import newModelGen from '../../../src/add-new-model'; @@ -10,11 +19,28 @@ import newModelGen from '../../../src/add-new-model'; jest.mock('@sap-ux/adp-tooling', () => ({ ...jest.requireActual('@sap-ux/adp-tooling'), generateChange: jest.fn(), - getVariant: jest.fn() + getVariant: jest.fn(), + isCFEnvironment: jest.fn(), + runBuild: jest.fn(), + isLoggedInCf: jest.fn(), + loadCfConfig: jest.fn(), + downloadUi5AppInfo: jest.fn() })); +jest.mock('../../../src/utils/deps', () => ({ + installDependencies: jest.fn() +})); + +import { installDependencies } from '../../../src/utils/deps'; + const generateChangeMock = generateChange as jest.MockedFunction; const getVariantMock = getVariant as jest.MockedFunction; +const isCFEnvironmentMock = isCFEnvironment as jest.MockedFunction; +const runBuildMock = runBuild as jest.MockedFunction; +const isLoggedInCfMock = isLoggedInCf as jest.MockedFunction; +const loadCfConfigMock = loadCfConfig as jest.MockedFunction; +const downloadUi5AppInfoMock = downloadUi5AppInfo as jest.MockedFunction; +const installDependenciesMock = installDependencies as jest.MockedFunction; const variant = { reference: 'customer.adp.variant', @@ -24,10 +50,9 @@ const variant = { } as DescriptorVariant; const answers: NewModelAnswers & { errorMessagePrompt: string } = { - name: 'OData_ServiceName', + modelAndDatasourceName: 'OData_ServiceName', uri: '/sap/opu/odata/some-name', - modelName: 'OData_ServiceModelName', - version: '4.0', + serviceType: 'OData v2' as NewModelAnswers['serviceType'], modelSettings: '{}', addAnnotationMode: false, errorMessagePrompt: 'failed' @@ -38,6 +63,17 @@ const tmpDir = resolve(__dirname, 'test-output'); const originalCwd: string = process.cwd(); // Generation changes the cwd, this breaks sonar report so we restore later describe('AddNewModelGenerator', () => { + const mockCfConfig = { url: 'cf.example.com', token: 'token' }; + + beforeEach(() => { + isCFEnvironmentMock.mockResolvedValue(false); + isLoggedInCfMock.mockResolvedValue(true); + loadCfConfigMock.mockReturnValue(mockCfConfig as any); + runBuildMock.mockResolvedValue(undefined); + downloadUi5AppInfoMock.mockResolvedValue(undefined); + installDependenciesMock.mockResolvedValue(undefined); + }); + afterEach(() => { jest.clearAllMocks(); }); @@ -61,11 +97,12 @@ describe('AddNewModelGenerator', () => { tmpDir, ChangeType.ADD_NEW_MODEL, expect.objectContaining({ + isCloudFoundry: false, service: { - name: answers.name, + name: answers.modelAndDatasourceName, uri: answers.uri, - modelName: answers.modelName, - version: answers.version, + modelName: answers.modelAndDatasourceName, + version: '2.0', modelSettings: answers.modelSettings } }), @@ -73,6 +110,25 @@ describe('AddNewModelGenerator', () => { ); }); + it('passes isCloudFoundry: true and destinationName for CF projects', async () => { + getVariantMock.mockResolvedValue(variant); + isCFEnvironmentMock.mockResolvedValue(true); + + const runContext = yeomanTest + .create(newModelGen, { resolved: generatorPath }, { cwd: tmpDir }) + .withOptions({ data: { path: tmpDir } }) + .withPrompts(answers); + + await expect(runContext.run()).resolves.not.toThrow(); + + expect(generateChangeMock).toHaveBeenCalledWith( + tmpDir, + ChangeType.ADD_NEW_MODEL, + expect.objectContaining({ isCloudFoundry: true }), + expect.anything() + ); + }); + it('invokes handleRuntimeCrash when getVariant fails during initializing', async () => { getVariantMock.mockRejectedValueOnce(new Error('variant fail')); @@ -96,4 +152,36 @@ describe('AddNewModelGenerator', () => { writingSpy.mockRestore(); handleCrashSpy.mockRestore(); }); + + it('stores cfConfig once and calls downloadUi5AppInfo + runBuild with ADP_BUILDER_MODE=preview in end()', async () => { + getVariantMock.mockResolvedValue(variant); + isCFEnvironmentMock.mockResolvedValue(true); + + const runContext = yeomanTest + .create(newModelGen, { resolved: generatorPath }, { cwd: tmpDir }) + .withOptions({ data: { path: tmpDir } }) + .withPrompts(answers); + + await expect(runContext.run()).resolves.not.toThrow(); + + expect(loadCfConfigMock).toHaveBeenCalledTimes(1); + expect(installDependenciesMock).toHaveBeenCalledWith(tmpDir); + expect(downloadUi5AppInfoMock).toHaveBeenCalledWith(tmpDir, mockCfConfig, expect.anything()); + expect(runBuildMock).toHaveBeenCalledWith(tmpDir, { ADP_BUILDER_MODE: 'preview' }); + }); + + it('does not call downloadUi5AppInfo or runBuild for non-CF projects', async () => { + getVariantMock.mockResolvedValue(variant); + isCFEnvironmentMock.mockResolvedValue(false); + + const runContext = yeomanTest + .create(newModelGen, { resolved: generatorPath }, { cwd: tmpDir }) + .withOptions({ data: { path: tmpDir } }) + .withPrompts(answers); + + await expect(runContext.run()).resolves.not.toThrow(); + + expect(downloadUi5AppInfoMock).not.toHaveBeenCalled(); + expect(runBuildMock).not.toHaveBeenCalled(); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7cb7d38c181..7e6d20b48e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,7 +79,7 @@ importers: version: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.1) eslint-plugin-import: specifier: 2.32.0 - version: 2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1) + version: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1) eslint-plugin-jsdoc: specifier: 62.8.1 version: 62.8.1(eslint@9.39.1) @@ -1501,6 +1501,9 @@ importers: '@sap-ux/axios-extension': specifier: workspace:* version: link:../axios-extension + '@sap-ux/btp-utils': + specifier: workspace:* + version: link:../btp-utils '@sap-ux/cap-config-writer': specifier: workspace:* version: link:../cap-config-writer @@ -27485,14 +27488,15 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1): dependencies: debug: 3.2.7 optionalDependencies: + '@typescript-eslint/parser': 8.57.2(eslint@9.39.1)(typescript@5.9.3) eslint: 9.39.1 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.1) @@ -27505,7 +27509,7 @@ snapshots: eslint: 9.39.1 estraverse: 5.3.0 - eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -27516,7 +27520,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -27527,6 +27531,8 @@ snapshots: semver: 6.3.1 string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.57.2(eslint@9.39.1)(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack From 72da9ea787c37f60d19cade36bcff54f13b4221d Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Tue, 7 Apr 2026 17:04:06 +0300 Subject: [PATCH 02/31] fix: various fixes --- packages/adp-tooling/src/cf/project/yaml.ts | 3 +- .../src/cf/services/destinations.ts | 16 +---- .../src/prompts/add-new-model/index.ts | 68 +++++++++++++++---- .../src/translations/adp-tooling.i18n.json | 3 +- packages/adp-tooling/src/types.ts | 2 + .../changes/writers/new-model-writer.ts | 22 +++--- .../test/unit/cf/project/yaml.test.ts | 4 ++ .../unit/cf/services/destinations.test.ts | 30 +------- .../unit/prompts/add-new-model/index.test.ts | 15 ++++ .../unit/writer/changes/writers/index.test.ts | 24 ++++--- packages/create/src/cli/add/new-model.ts | 3 +- .../test/unit/cli/add/new-model.test.ts | 4 ++ .../generator-adp/src/add-new-model/index.ts | 14 ++-- .../src/translations/generator-adp.i18n.json | 4 +- 14 files changed, 128 insertions(+), 84 deletions(-) diff --git a/packages/adp-tooling/src/cf/project/yaml.ts b/packages/adp-tooling/src/cf/project/yaml.ts index bf1668399bd..691d08e0eed 100644 --- a/packages/adp-tooling/src/cf/project/yaml.ts +++ b/packages/adp-tooling/src/cf/project/yaml.ts @@ -88,7 +88,8 @@ export async function addConnectivityServiceToMta( type: CF_MANAGED_SERVICE, parameters: { service: 'connectivity', - 'service-plan': 'lite' + 'service-plan': 'lite', + 'service-name': connectivityResourceName } }); diff --git a/packages/adp-tooling/src/cf/services/destinations.ts b/packages/adp-tooling/src/cf/services/destinations.ts index 9943be7cacf..fb48e0e9361 100644 --- a/packages/adp-tooling/src/cf/services/destinations.ts +++ b/packages/adp-tooling/src/cf/services/destinations.ts @@ -1,6 +1,5 @@ import * as path from 'node:path'; -import { isAppStudio, listDestinations } from '@sap-ux/btp-utils'; import type { Destinations } from '@sap-ux/btp-utils'; import { getOrCreateServiceInstanceKeys, listBtpDestinations } from './api'; @@ -30,23 +29,14 @@ function getDestinationServiceName(projectPath: string): string { } /** - * Returns the list of available BTP destinations for the current environment. - * - * - In SAP Business Application Studio: uses the BAS destination API (`listDestinations`). - * - In VS Code: reads the destination service credentials from the CF project's service keys - * and calls the BTP Destination Configuration API directly. - * - * Returns an empty map when the destination service instance cannot be located or its - * credentials are not yet available (e.g. the service has not been provisioned yet). + * Returns the list of available BTP destinations from the logged-in CF subaccount. + * Reads the destination service credentials from the CF project's service keys + * and calls the BTP Destination Configuration API directly. * * @param {string} projectPath - The root path of the CF app project. * @returns {Promise} Map of destination name to Destination object. */ export async function getDestinations(projectPath: string): Promise { - if (isAppStudio()) { - return listDestinations(); - } - const destinationServiceName = getDestinationServiceName(projectPath); const serviceInfo = await getOrCreateServiceInstanceKeys({ names: [destinationServiceName] }); diff --git a/packages/adp-tooling/src/prompts/add-new-model/index.ts b/packages/adp-tooling/src/prompts/add-new-model/index.ts index e8632ba6a28..9f45cf5e3be 100644 --- a/packages/adp-tooling/src/prompts/add-new-model/index.ts +++ b/packages/adp-tooling/src/prompts/add-new-model/index.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'node:fs'; -import { join } from 'path'; +import { join } from 'node:path'; import type { ListQuestion, @@ -12,6 +12,7 @@ import type { UI5FlexLayer } from '@sap-ux/project-access'; import type { Destination } from '@sap-ux/btp-utils'; import { listDestinations } from '@sap-ux/btp-utils'; import { Severity, type IMessageSeverity } from '@sap-devx/yeoman-ui-types'; +import type { ToolsLogger } from '@sap-ux/logger'; import { t } from '../../i18n'; import { getChangesByType } from '../../base/change-utils'; @@ -180,7 +181,10 @@ function validatePromptURI(value: string): boolean | string { * @param {string | undefined} serviceUri - The relative service URI from the prompt. * @returns {string | undefined} The concatenated URL, or undefined if it cannot be formed. */ -function buildResultingServiceUrl(destinationUrl: string | undefined, serviceUri: string | undefined): string | undefined { +function buildResultingServiceUrl( + destinationUrl: string | undefined, + serviceUri: string | undefined +): string | undefined { if (!destinationUrl || !serviceUri || validatePromptURI(serviceUri) !== true) { return undefined; } @@ -232,14 +236,44 @@ async function getAbapServiceUrl(projectPath: string): Promise} The destination choices and an optional error message. + */ +async function getDestinationChoices( + projectPath: string, + logger?: ToolsLogger +): Promise<{ choices: { name: string; value: Destination }[]; error?: string }> { + try { + const destinations = await getDestinations(projectPath); + const choices = Object.entries(destinations).map(([name, dest]) => ({ + name, + value: dest as Destination + })); + return { choices }; + } catch (e) { + logger?.error((e as Error).message); + return { choices: [], error: t('error.errorFetchingDestinations') }; + } +} + /** * Gets the prompts for adding the new model. * * @param {string} projectPath - The root path of the project. * @param {UI5FlexLayer} layer - UI5 Flex layer. + * @param {ToolsLogger} [logger] - Optional logger. * @returns {YUIQuestion[]} The questions/prompts. */ -export async function getPrompts(projectPath: string, layer: UI5FlexLayer): Promise[]> { +export async function getPrompts( + projectPath: string, + layer: UI5FlexLayer, + logger?: ToolsLogger +): Promise[]> { const isCustomerBase = FlexLayer.CUSTOMER_BASE === layer; const defaultSeviceName = isCustomerBase ? NamespacePrefix.CUSTOMER : NamespacePrefix.EMPTY; const isCFEnv = await isCFEnvironment(projectPath); @@ -247,6 +281,13 @@ export async function getPrompts(projectPath: string, layer: UI5FlexLayer): Prom const changeFiles = getChangesByType(projectPath, ChangeType.ADD_NEW_MODEL, 'manifest'); let destinationError: string | undefined; + let destinationChoices: { name: string; value: Destination }[] | undefined; + + if (isCFEnv) { + const result = await getDestinationChoices(projectPath, logger); + destinationChoices = result.choices; + destinationError = result.error; + } return [ { @@ -265,16 +306,7 @@ export async function getPrompts(projectPath: string, layer: UI5FlexLayer): Prom type: 'list', name: 'destination', message: t('prompts.destinationLabel'), - choices: async (): Promise<{ name: string; value: Destination }[]> => { - try { - const destinations = await getDestinations(projectPath); - destinationError = undefined; - return Object.entries(destinations).map(([name, dest]) => ({ name, value: dest as Destination })); - } catch (e) { - destinationError = (e as Error).message; - return []; - } - }, + choices: (): { name: string; value: Destination }[] => destinationChoices ?? [], when: () => isCFEnv, guiOptions: { mandatory: true, @@ -304,14 +336,20 @@ export async function getPrompts(projectPath: string, layer: UI5FlexLayer): Prom return true; }, store: false, - additionalMessages: (serviceUri: unknown, previousAnswers?: NewModelAnswers): IMessageSeverity | undefined => { + additionalMessages: ( + serviceUri: unknown, + previousAnswers?: NewModelAnswers + ): IMessageSeverity | undefined => { const destinationUrl = isCFEnv ? previousAnswers?.destination?.Host : abapServiceUrl; const resultingUrl = buildResultingServiceUrl(destinationUrl, serviceUri as string | undefined); if (!resultingUrl) { return undefined; } return { - message: t('prompts.resultingServiceUrl', { url: resultingUrl, interpolation: { escapeValue: false } }), + message: t('prompts.resultingServiceUrl', { + url: resultingUrl, + interpolation: { escapeValue: false } + }), severity: Severity.information }; } diff --git a/packages/adp-tooling/src/translations/adp-tooling.i18n.json b/packages/adp-tooling/src/translations/adp-tooling.i18n.json index f2c44a80ad9..71b1622590c 100644 --- a/packages/adp-tooling/src/translations/adp-tooling.i18n.json +++ b/packages/adp-tooling/src/translations/adp-tooling.i18n.json @@ -126,7 +126,8 @@ "metadataFetchingNotSupportedForCF": "Metadata fetching is not supported for Cloud Foundry projects.", "failedToListBtpDestinations": "Failed to list BTP destinations. Error: {{error}}", "destinationServiceNotFoundInMtaYaml": "Destination service instance not found in mta.yaml. Ensure a resource with 'service: destination' is declared.", - "noServiceKeysFoundForDestination": "No service keys found for destination service instance '{{serviceInstanceName}}'. Ensure the service is provisioned and try again." + "noServiceKeysFoundForDestination": "No service keys found for destination service instance '{{serviceInstanceName}}'. Ensure the service is provisioned and try again.", + "errorFetchingDestinations": "Error fetching destinations. Check log for details." }, "choices": { "true": "true", diff --git a/packages/adp-tooling/src/types.ts b/packages/adp-tooling/src/types.ts index 5a7df6b3f4f..b2f5eae0d4b 100644 --- a/packages/adp-tooling/src/types.ts +++ b/packages/adp-tooling/src/types.ts @@ -656,6 +656,8 @@ export type AddComponentUsageAnswers = AddComponentUsageAnswersBase & export interface NewModelDataBase { variant: DescriptorVariant; + /** The type of service being added. Determines dataSource type and whether a model entry is created. */ + serviceType: ServiceType; /** Whether the project is deployed on Cloud Foundry. Affects URI construction and model settings. */ isCloudFoundry?: boolean; /** Name of the BTP destination. Required when isCloudFoundry is true to write the xs-app.json route. */ diff --git a/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts b/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts index e04f76abb79..a154fa46220 100644 --- a/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts +++ b/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts @@ -1,9 +1,9 @@ -import { join } from 'node:path'; +import { join, dirname } from 'node:path'; import type { Editor } from 'mem-fs-editor'; import { ToolsLogger } from '@sap-ux/logger'; -import { ChangeType } from '../../../types'; +import { ChangeType, ServiceType } from '../../../types'; import type { IWriter, NewModelData, DataSourceItem } from '../../../types'; import { parseStringToObject, getChange, writeChangeToFolder } from '../../../base/change-utils'; import { addConnectivityServiceToMta } from '../../../cf/project/yaml'; @@ -16,7 +16,7 @@ const CF_MODEL_SETTINGS = { } as const; type NewModelContent = { - model: { + model?: { [key: string]: { settings?: object; dataSource: string; @@ -50,13 +50,14 @@ export class NewModelWriter implements IWriter { * @returns {object} The constructed content object for the new model change. */ private constructContent(data: NewModelData): object { - const { service, isCloudFoundry } = data; + const { service, isCloudFoundry, serviceType } = data; + const isHttp = serviceType === ServiceType.HTTP; const uri = isCloudFoundry ? `/${service.name.replace(/\./g, '/')}${service.uri}` : service.uri; const dataSourceEntry: DataSourceItem = { uri, - type: 'OData', + type: isHttp ? 'http' : 'OData', settings: {} }; @@ -67,12 +68,11 @@ export class NewModelWriter implements IWriter { const content: NewModelContent = { dataSource: { [service.name]: dataSourceEntry - }, - model: {} + } }; - if (service.modelName) { - content.model[service.modelName] = { dataSource: service.name }; + if (!isHttp && service.modelName) { + content.model = { [service.modelName]: { dataSource: service.name } }; if (isCloudFoundry) { content.model[service.modelName].preload = true; @@ -117,8 +117,8 @@ export class NewModelWriter implements IWriter { this.writeXsAppRoute(data); } - if (data.isCloudFoundry && data.isOnPremiseDestination) { - await addConnectivityServiceToMta(this.projectPath, this.fs); + if (data.isOnPremiseDestination) { + await addConnectivityServiceToMta(dirname(this.projectPath), this.fs); await ensureTunnelAppExists(DEFAULT_TUNNEL_APP_NAME, this.logger ?? new ToolsLogger()); } } diff --git a/packages/adp-tooling/test/unit/cf/project/yaml.test.ts b/packages/adp-tooling/test/unit/cf/project/yaml.test.ts index e3ec1990068..33a77752848 100644 --- a/packages/adp-tooling/test/unit/cf/project/yaml.test.ts +++ b/packages/adp-tooling/test/unit/cf/project/yaml.test.ts @@ -1183,6 +1183,10 @@ describe('YAML Project Functions', () => { mtaYamlPath, expect.stringContaining('lite') ); + expect(mockMemFs.write).toHaveBeenCalledWith( + mtaYamlPath, + expect.stringContaining('service-name: myproject-connectivity') + ); expect(mockCreateServiceInstance).toHaveBeenCalledWith('lite', 'myproject-connectivity', 'connectivity', expect.any(Object)); expect(mockGetOrCreateServiceInstanceKeys).toHaveBeenCalledWith({ names: ['myproject-connectivity'] }, undefined); }); diff --git a/packages/adp-tooling/test/unit/cf/services/destinations.test.ts b/packages/adp-tooling/test/unit/cf/services/destinations.test.ts index 850b3b21e7c..615538ec0fa 100644 --- a/packages/adp-tooling/test/unit/cf/services/destinations.test.ts +++ b/packages/adp-tooling/test/unit/cf/services/destinations.test.ts @@ -1,14 +1,9 @@ import { getDestinations } from '../../../../src/cf/services/destinations'; -import { isAppStudio, listDestinations } from '@sap-ux/btp-utils'; import { getOrCreateServiceInstanceKeys, listBtpDestinations } from '../../../../src/cf/services/api'; import { getYamlContent } from '../../../../src/cf/project/yaml-loader'; import { initI18n, t } from '../../../../src/i18n'; -jest.mock('@sap-ux/btp-utils', () => ({ - ...jest.requireActual('@sap-ux/btp-utils'), - isAppStudio: jest.fn(), - listDestinations: jest.fn() -})); +jest.mock('@sap-ux/btp-utils'); jest.mock('../../../../src/cf/services/api', () => ({ getOrCreateServiceInstanceKeys: jest.fn(), @@ -19,8 +14,6 @@ jest.mock('../../../../src/cf/project/yaml-loader', () => ({ getYamlContent: jest.fn() })); -const isAppStudioMock = isAppStudio as jest.Mock; -const listDestinationsMock = listDestinations as jest.Mock; const getOrCreateServiceInstanceKeysMock = getOrCreateServiceInstanceKeys as jest.Mock; const listBtpDestinationsMock = listBtpDestinations as jest.Mock; const getYamlContentMock = getYamlContent as jest.Mock; @@ -57,26 +50,13 @@ describe('getDestinations', () => { jest.clearAllMocks(); }); - it('should call listDestinations when running in BAS', async () => { - isAppStudioMock.mockReturnValue(true); - listDestinationsMock.mockResolvedValue(mockDestinations); - - const result = await getDestinations(mockProjectPath); - - expect(listDestinationsMock).toHaveBeenCalledTimes(1); - expect(getOrCreateServiceInstanceKeysMock).not.toHaveBeenCalled(); - expect(result).toBe(mockDestinations); - }); - - it('should call listBtpDestinations with destination service credentials when running in VS Code', async () => { - isAppStudioMock.mockReturnValue(false); + it('should fetch destinations from the logged-in CF subaccount using service keys', async () => { getYamlContentMock.mockReturnValue(mockMtaYaml); getOrCreateServiceInstanceKeysMock.mockResolvedValue(mockServiceInfo); listBtpDestinationsMock.mockResolvedValue(mockDestinations); const result = await getDestinations(mockProjectPath); - expect(listDestinationsMock).not.toHaveBeenCalled(); expect(getYamlContentMock).toHaveBeenCalledWith('/path/to/mta.yaml'); expect(getOrCreateServiceInstanceKeysMock).toHaveBeenCalledWith({ names: ['test-project-destination'] }); expect(listBtpDestinationsMock).toHaveBeenCalledWith(mockCredentials); @@ -84,7 +64,6 @@ describe('getDestinations', () => { }); it('should throw an error when no destination service is found in mta.yaml', async () => { - isAppStudioMock.mockReturnValue(false); getYamlContentMock.mockReturnValue({ ...mockMtaYaml, resources: [{ name: 'test-project-uaa', type: 'org.cloudfoundry.managed-service', parameters: { service: 'xsuaa', 'service-plan': 'application' } }] @@ -96,7 +75,6 @@ describe('getDestinations', () => { }); it('should throw an error when mta.yaml cannot be read', async () => { - isAppStudioMock.mockReturnValue(false); getYamlContentMock.mockImplementation(() => { throw new Error('File not found'); }); @@ -107,7 +85,6 @@ describe('getDestinations', () => { }); it('should throw an error when no service keys are available', async () => { - isAppStudioMock.mockReturnValue(false); getYamlContentMock.mockReturnValue(mockMtaYaml); getOrCreateServiceInstanceKeysMock.mockResolvedValue({ serviceKeys: [], serviceInstance: { name: 'test-project-destination', guid: 'some-guid' } }); @@ -119,7 +96,6 @@ describe('getDestinations', () => { }); it('should throw an error when getOrCreateServiceInstanceKeys returns null', async () => { - isAppStudioMock.mockReturnValue(false); getYamlContentMock.mockReturnValue(mockMtaYaml); getOrCreateServiceInstanceKeysMock.mockResolvedValue(null); @@ -129,4 +105,4 @@ describe('getDestinations', () => { expect(listBtpDestinationsMock).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts b/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts index 454c1033018..05866e5e1b9 100644 --- a/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts +++ b/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts @@ -9,6 +9,7 @@ import { listDestinations } from '@sap-ux/btp-utils'; import { getDestinations } from '../../../../src/cf/services/destinations'; import { Severity } from '@sap-devx/yeoman-ui-types'; import { readFileSync } from 'node:fs'; +import type { ToolsLogger } from '@sap-ux/logger'; const getChangesByTypeMock = getChangesByType as jest.Mock; const isCFEnvironmentMock = isCFEnvironment as jest.Mock; @@ -399,4 +400,18 @@ describe('getPrompts', () => { expect.anything() ); }); + + it('should log the error and set a generic UI error message when fetching destinations fails in CF', async () => { + isCFEnvironmentMock.mockResolvedValue(true); + getDestinationsMock.mockRejectedValue(new Error('Network error')); + + const logger = { error: jest.fn() } as Partial as ToolsLogger; + const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE', logger); + + const destinationPrompt = prompts.find((p) => p.name === 'destination'); + const validate = destinationPrompt?.validate as Function; + + expect(logger.error).toHaveBeenCalledWith('Network error'); + expect(validate?.(undefined)).toBe(i18n.t('error.errorFetchingDestinations')); + }); }); diff --git a/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts b/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts index ea97924bc79..6624e100a7e 100644 --- a/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts +++ b/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts @@ -14,6 +14,7 @@ import type { ComponentUsagesDataWithLibrary, DataSourceData, NewModelData, + NewModelDataWithAnnotations, InboundData, DescriptorVariant } from '../../../../../src'; @@ -24,7 +25,7 @@ import { InboundWriter, NewModelWriter } from '../../../../../src/writer/changes/writers'; -import { ChangeType } from '../../../../../src'; +import { ChangeType, ServiceType } from '../../../../../src'; jest.mock('../../../../../src/base/change-utils', () => ({ ...jest.requireActual('../../../../../src/base/change-utils'), @@ -236,8 +237,9 @@ describe('NewModelWriter', () => { }); it('should correctly construct content and write new model change', async () => { - const mockData: NewModelData = { + const mockData: NewModelDataWithAnnotations = { variant: {} as DescriptorVariant, + serviceType: ServiceType.ODATA_V4, service: { name: 'ODataService', uri: '/sap/opu/odata/custom', @@ -293,6 +295,7 @@ describe('NewModelWriter', () => { it('should omit the model block when modelName is undefined (HTTP service type)', async () => { const mockData: NewModelData = { variant: {} as DescriptorVariant, + serviceType: ServiceType.HTTP, service: { name: 'HttpService', uri: '/api/http/service/', @@ -310,11 +313,10 @@ describe('NewModelWriter', () => { 'dataSource': { 'HttpService': { 'uri': mockData.service.uri, - 'type': 'OData', + 'type': 'http', 'settings': {} } - }, - 'model': {} + } }, ChangeType.ADD_NEW_MODEL ); @@ -323,6 +325,7 @@ describe('NewModelWriter', () => { it('should construct CF-specific content with derived URI, preload and fixed model settings', async () => { const mockData: NewModelData = { variant: {} as DescriptorVariant, + serviceType: ServiceType.ODATA_V4, isCloudFoundry: true, destinationName: 'MY_CF_DEST', service: { @@ -367,6 +370,7 @@ describe('NewModelWriter', () => { it('should create xs-app.json with a new route for a CF project when xs-app.json does not exist', async () => { const mockData: NewModelData = { variant: {} as DescriptorVariant, + serviceType: ServiceType.ODATA_V4, isCloudFoundry: true, destinationName: 'MY_CF_DEST', service: { @@ -404,6 +408,7 @@ describe('NewModelWriter', () => { const mockData: NewModelData = { variant: {} as DescriptorVariant, + serviceType: ServiceType.ODATA_V2, isCloudFoundry: true, destinationName: 'MY_CF_DEST', service: { @@ -434,6 +439,7 @@ describe('NewModelWriter', () => { it('should not write xs-app.json for a non-CF project', async () => { const mockData: NewModelData = { variant: {} as DescriptorVariant, + serviceType: ServiceType.ODATA_V2, service: { name: 'ODataService', uri: '/sap/opu/odata/custom/', @@ -451,6 +457,7 @@ describe('NewModelWriter', () => { it('should call addConnectivityServiceToMta when isCloudFoundry and isOnPremiseDestination', async () => { const mockData: NewModelData = { variant: {} as DescriptorVariant, + serviceType: ServiceType.ODATA_V2, isCloudFoundry: true, isOnPremiseDestination: true, destinationName: 'MY_CF_DEST', @@ -465,16 +472,16 @@ describe('NewModelWriter', () => { await writer.write(mockData); expect(addConnectivityServiceToMtaMock).toHaveBeenCalledWith( - mockProjectPath, + '/mock/project', expect.any(Object) ); }); - it('should not call addConnectivityServiceToMta when isCloudFoundry is false', async () => { + it('should not call addConnectivityServiceToMta when not in CF (isOnPremiseDestination absent)', async () => { const mockData: NewModelData = { variant: {} as DescriptorVariant, + serviceType: ServiceType.ODATA_V2, isCloudFoundry: false, - isOnPremiseDestination: true, service: { name: 'ODataService', uri: '/sap/opu/odata/v2/', @@ -491,6 +498,7 @@ describe('NewModelWriter', () => { it('should not call addConnectivityServiceToMta when isOnPremiseDestination is false', async () => { const mockData: NewModelData = { variant: {} as DescriptorVariant, + serviceType: ServiceType.ODATA_V2, isCloudFoundry: true, isOnPremiseDestination: false, destinationName: 'MY_CF_DEST', diff --git a/packages/create/src/cli/add/new-model.ts b/packages/create/src/cli/add/new-model.ts index 7232182f184..bb74b31836c 100644 --- a/packages/create/src/cli/add/new-model.ts +++ b/packages/create/src/cli/add/new-model.ts @@ -51,7 +51,7 @@ async function addNewModel(basePath: string, simulate: boolean): Promise { const variant = await getVariant(basePath); - const answers = await promptYUIQuestions(await getPromptsForNewModel(basePath, variant.layer), false); + const answers = await promptYUIQuestions(await getPromptsForNewModel(basePath, variant.layer, logger), false); const fs = await generateChange( basePath, @@ -90,6 +90,7 @@ async function createNewModelData( const isCloudFoundry = await isCFEnvironment(basePath); return { variant, + serviceType, isCloudFoundry, destinationName: isCloudFoundry ? answers.destination?.Name : undefined, isOnPremiseDestination: diff --git a/packages/create/test/unit/cli/add/new-model.test.ts b/packages/create/test/unit/cli/add/new-model.test.ts index cede3b44065..24767911b05 100644 --- a/packages/create/test/unit/cli/add/new-model.test.ts +++ b/packages/create/test/unit/cli/add/new-model.test.ts @@ -105,6 +105,7 @@ describe('add/model', () => { expect(traceSpy).not.toHaveBeenCalled(); expect(generateChangeMock).toHaveBeenCalledWith(expect.anything(), 'appdescr_ui5_addNewModel', { variant: descriptorVariant, + serviceType: 'OData v2', isCloudFoundry: false, destinationName: undefined, isOnPremiseDestination: undefined, @@ -129,6 +130,7 @@ describe('add/model', () => { expect(traceSpy).not.toHaveBeenCalled(); expect(generateChangeMock).toHaveBeenCalledWith(expect.anything(), 'appdescr_ui5_addNewModel', { variant: descriptorVariant, + serviceType: 'OData v2', isCloudFoundry: false, destinationName: undefined, isOnPremiseDestination: undefined, @@ -160,6 +162,7 @@ describe('add/model', () => { expect(traceSpy).not.toHaveBeenCalled(); expect(generateChangeMock).toHaveBeenCalledWith(expect.anything(), 'appdescr_ui5_addNewModel', { variant: descriptorVariant, + serviceType: 'OData v2', isCloudFoundry: true, destinationName: 'CF_DEST', isOnPremiseDestination: true, @@ -182,6 +185,7 @@ describe('add/model', () => { expect(traceSpy).toHaveBeenCalled(); expect(generateChangeMock).toHaveBeenCalledWith(expect.anything(), 'appdescr_ui5_addNewModel', { variant: descriptorVariant, + serviceType: 'OData v2', isCloudFoundry: false, destinationName: undefined, isOnPremiseDestination: undefined, diff --git a/packages/generator-adp/src/add-new-model/index.ts b/packages/generator-adp/src/add-new-model/index.ts index f6a611a5d7c..b6c586349ef 100644 --- a/packages/generator-adp/src/add-new-model/index.ts +++ b/packages/generator-adp/src/add-new-model/index.ts @@ -14,6 +14,7 @@ import { downloadUi5AppInfo } from '@sap-ux/adp-tooling'; import { isOnPremiseDestination } from '@sap-ux/btp-utils'; +import { setYeomanEnvConflicterForce } from '@sap-ux/fiori-generator-shared'; import { GeneratorTypes } from '../types'; import { initI18n, t } from '../utils/i18n'; @@ -57,6 +58,7 @@ class AddNewModelGenerator extends SubGeneratorBase { async initializing(): Promise { await initI18n(); + setYeomanEnvConflicterForce(this.env, true); try { if (await isCFEnvironment(this.projectPath)) { @@ -87,7 +89,9 @@ class AddNewModelGenerator extends SubGeneratorBase { return; } - this.answers = await this.prompt(await getPromptsForNewModel(this.projectPath, this.variant.layer)); + this.answers = await this.prompt( + await getPromptsForNewModel(this.projectPath, this.variant.layer, this.logger) + ); this.logger.log(`Current answers\n${JSON.stringify(this.answers, null, 2)}`); } @@ -122,12 +126,12 @@ class AddNewModelGenerator extends SubGeneratorBase { const isCloudFoundry = await isCFEnvironment(this.projectPath); return { variant: this.variant, + serviceType, isCloudFoundry, destinationName: isCloudFoundry ? this.answers.destination?.Name : undefined, - isOnPremiseDestination: - isCloudFoundry && this.answers.destination - ? isOnPremiseDestination(this.answers.destination) - : undefined, + ...(isCloudFoundry && { + isOnPremiseDestination: isOnPremiseDestination(this.answers.destination!) + }), service: { name: modelAndDatasourceName, uri, diff --git a/packages/generator-adp/src/translations/generator-adp.i18n.json b/packages/generator-adp/src/translations/generator-adp.i18n.json index 0e6d0b7dd9c..9d71a3d9eba 100644 --- a/packages/generator-adp/src/translations/generator-adp.i18n.json +++ b/packages/generator-adp/src/translations/generator-adp.i18n.json @@ -13,8 +13,8 @@ "deployConfigDescr": "Configure deployment settings.", "addComponentUsagesName": "Add SAPUI5 Component Usages", "addComponentUsagesDescr": "Select SAPUI5 component usages.", - "addNewModelName": "Add OData Service and SAPUI5 Model", - "addNewModelDescr": "Select an OData service and SAPUI5 model.", + "addNewModelName": "Add Datasource and SAPUI5 Model", + "addNewModelDescr": "Select a Datasource and SAPUI5 model.", "addLocalAnnotationFileName": "Add Local Annotation File", "addLocalAnnotationFileDescr": "Select an OData service and annotation XML file.", "replaceODataServiceName": "Replace OData Service", From 3b4fc460cd528b488018638184820edc53b709cf Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Tue, 7 Apr 2026 18:09:44 +0300 Subject: [PATCH 03/31] fix: add additional message for annotation URI prompt --- .../src/prompts/add-new-model/index.ts | 39 ++--- .../src/translations/adp-tooling.i18n.json | 3 +- .../unit/prompts/add-new-model/index.test.ts | 116 ++++++++++++-- .../test/unit/cli/add/new-model.test.ts | 148 +++++++++++------- .../test/unit/add-new-model/index.test.ts | 12 ++ 5 files changed, 223 insertions(+), 95 deletions(-) diff --git a/packages/adp-tooling/src/prompts/add-new-model/index.ts b/packages/adp-tooling/src/prompts/add-new-model/index.ts index 9f45cf5e3be..a3eb6bc1959 100644 --- a/packages/adp-tooling/src/prompts/add-new-model/index.ts +++ b/packages/adp-tooling/src/prompts/add-new-model/index.ts @@ -289,6 +289,22 @@ export async function getPrompts( destinationError = result.error; } + const buildResultingUrlMessage = ( + i18nKey: string, + uri: unknown, + previousAnswers?: NewModelAnswers + ): IMessageSeverity | undefined => { + const destinationUrl = isCFEnv ? previousAnswers?.destination?.Host : abapServiceUrl; + const resultingUrl = buildResultingServiceUrl(destinationUrl, uri as string | undefined); + if (!resultingUrl) { + return undefined; + } + return { + message: t(i18nKey, { url: resultingUrl, interpolation: { escapeValue: false } }), + severity: Severity.information + }; + }; + return [ { type: 'list', @@ -336,23 +352,8 @@ export async function getPrompts( return true; }, store: false, - additionalMessages: ( - serviceUri: unknown, - previousAnswers?: NewModelAnswers - ): IMessageSeverity | undefined => { - const destinationUrl = isCFEnv ? previousAnswers?.destination?.Host : abapServiceUrl; - const resultingUrl = buildResultingServiceUrl(destinationUrl, serviceUri as string | undefined); - if (!resultingUrl) { - return undefined; - } - return { - message: t('prompts.resultingServiceUrl', { - url: resultingUrl, - interpolation: { escapeValue: false } - }), - severity: Severity.information - }; - } + additionalMessages: (uri: unknown, previousAnswers?: NewModelAnswers): IMessageSeverity | undefined => + buildResultingUrlMessage('prompts.resultingServiceUrl', uri, previousAnswers) } as InputQuestion, { type: 'input', @@ -399,7 +400,9 @@ export async function getPrompts( mandatory: true, hint: t('prompts.oDataAnnotationDataSourceUriTooltip') }, - when: (answers: NewModelAnswers) => answers.addAnnotationMode + when: (answers: NewModelAnswers) => answers.addAnnotationMode, + additionalMessages: (uri: unknown, previousAnswers?: NewModelAnswers): IMessageSeverity | undefined => + buildResultingUrlMessage('prompts.resultingAnnotationUrl', uri, previousAnswers) } as InputQuestion, { type: 'editor', diff --git a/packages/adp-tooling/src/translations/adp-tooling.i18n.json b/packages/adp-tooling/src/translations/adp-tooling.i18n.json index 71b1622590c..23e187b6463 100644 --- a/packages/adp-tooling/src/translations/adp-tooling.i18n.json +++ b/packages/adp-tooling/src/translations/adp-tooling.i18n.json @@ -23,8 +23,9 @@ "oDataServiceUriTooltip": "Enter the URI for the OData service you want to add. The URI must start and end with '/' and must not contain any whitespaces or parameters", "serviceUriLabel": "Service URI", "resultingServiceUrl": "Resulting Service URL: {{url}}", + "resultingAnnotationUrl": "Resulting Annotation URL: {{url}}", "modelAndDatasourceNameLabel": "Model and Datasource Name", - "modelAndDatasourceNameTooltip": "Enter a name for the datasource and model. The same name will be used for both. Note: for HTTP service type, no model will be created.", + "modelAndDatasourceNameTooltip": "Enter a name for the datasource and model.", "datasourceNameLabel": "Datasource Name", "oDataServiceVersionLabel": "OData Version", "oDataServiceVersionTooltip": "Select the version of OData of the service you want to add", diff --git a/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts b/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts index 05866e5e1b9..139fa5458ba 100644 --- a/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts +++ b/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts @@ -111,9 +111,7 @@ describe('getPrompts', () => { const validation = prompts.find((p) => p.name === 'modelAndDatasourceName')?.validate; expect(typeof validation).toBe('function'); - expect(validation?.('testName')).toBe( - "Model and Datasource Name must start with 'customer.'." - ); + expect(validation?.('testName')).toBe("Model and Datasource Name must start with 'customer.'."); }); it('should return error message when validating service name prompt and name is only "customer."', async () => { @@ -134,9 +132,7 @@ describe('getPrompts', () => { const validation = prompts.find((p) => p.name === 'modelAndDatasourceName')?.validate; expect(typeof validation).toBe('function'); - expect(validation?.('customer.testName@')).toBe( - 'general.invalidValueForSpecialChars' - ); + expect(validation?.('customer.testName@')).toBe('general.invalidValueForSpecialChars'); }); it('should return error message when validating service name prompt has content duplication', async () => { @@ -192,7 +188,10 @@ describe('getPrompts', () => { expect(typeof additionalMessages).toBe('function'); const result = await additionalMessages('/sap/odata/v4/', undefined); expect(result).toEqual({ - message: i18n.t('prompts.resultingServiceUrl', { url: 'https://abap.example.com/sap/odata/v4/', interpolation: { escapeValue: false } }), + message: i18n.t('prompts.resultingServiceUrl', { + url: 'https://abap.example.com/sap/odata/v4/', + interpolation: { escapeValue: false } + }), severity: Severity.information }); }); @@ -207,7 +206,10 @@ describe('getPrompts', () => { expect(typeof additionalMessages).toBe('function'); const result = await additionalMessages('/sap/odata/v4/', undefined); expect(result).toEqual({ - message: i18n.t('prompts.resultingServiceUrl', { url: 'https://bas.dest.example.com/sap/odata/v4/', interpolation: { escapeValue: false } }), + message: i18n.t('prompts.resultingServiceUrl', { + url: 'https://bas.dest.example.com/sap/odata/v4/', + interpolation: { escapeValue: false } + }), severity: Severity.information }); }); @@ -219,10 +221,15 @@ describe('getPrompts', () => { const additionalMessages = prompts.find((p) => p.name === 'uri')?.additionalMessages as Function; expect(typeof additionalMessages).toBe('function'); - const previousAnswers = { destination: { Host: 'https://cf.dest.example.com', Name: 'CF_DEST' } } as unknown as NewModelAnswers; + const previousAnswers = { + destination: { Host: 'https://cf.dest.example.com', Name: 'CF_DEST' } + } as unknown as NewModelAnswers; const result = await additionalMessages('/sap/odata/v4/', previousAnswers); expect(result).toEqual({ - message: i18n.t('prompts.resultingServiceUrl', { url: 'https://cf.dest.example.com/sap/odata/v4/', interpolation: { escapeValue: false } }), + message: i18n.t('prompts.resultingServiceUrl', { + url: 'https://cf.dest.example.com/sap/odata/v4/', + interpolation: { escapeValue: false } + }), severity: Severity.information }); }); @@ -298,6 +305,82 @@ describe('getPrompts', () => { expect(validation?.('/sap/opu /odata4Ann/')).toBe(i18n.t('validators.errorInvalidDataSourceURI')); }); + it('should return information message with resulting annotation URL for ABAP VS Code project (url in ui5.yaml)', async () => { + getAdpConfigMock.mockResolvedValue({ target: { url: 'https://abap.example.com' } }); + + const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const additionalMessages = prompts.find((p) => p.name === 'dataSourceURI')?.additionalMessages as Function; + + expect(typeof additionalMessages).toBe('function'); + const result = await additionalMessages('/sap/opu/odata4Ann/', undefined); + expect(result).toEqual({ + message: i18n.t('prompts.resultingAnnotationUrl', { + url: 'https://abap.example.com/sap/opu/odata4Ann/', + interpolation: { escapeValue: false } + }), + severity: Severity.information + }); + }); + + it('should return information message with resulting annotation URL for ABAP BAS project (destination in ui5.yaml)', async () => { + getAdpConfigMock.mockResolvedValue({ target: { destination: 'MY_DEST' } }); + listDestinationsMock.mockResolvedValue({ MY_DEST: { Host: 'https://bas.dest.example.com', Name: 'MY_DEST' } }); + + const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const additionalMessages = prompts.find((p) => p.name === 'dataSourceURI')?.additionalMessages as Function; + + expect(typeof additionalMessages).toBe('function'); + const result = await additionalMessages('/sap/opu/odata4Ann/', undefined); + expect(result).toEqual({ + message: i18n.t('prompts.resultingAnnotationUrl', { + url: 'https://bas.dest.example.com/sap/opu/odata4Ann/', + interpolation: { escapeValue: false } + }), + severity: Severity.information + }); + }); + + it('should return information message with resulting annotation URL for CF project using selected destination', async () => { + isCFEnvironmentMock.mockResolvedValue(true); + + const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const additionalMessages = prompts.find((p) => p.name === 'dataSourceURI')?.additionalMessages as Function; + + expect(typeof additionalMessages).toBe('function'); + const previousAnswers = { + destination: { Host: 'https://cf.dest.example.com', Name: 'CF_DEST' } + } as unknown as NewModelAnswers; + const result = await additionalMessages('/sap/opu/odata4Ann/', previousAnswers); + expect(result).toEqual({ + message: i18n.t('prompts.resultingAnnotationUrl', { + url: 'https://cf.dest.example.com/sap/opu/odata4Ann/', + interpolation: { escapeValue: false } + }), + severity: Severity.information + }); + }); + + it('should return undefined from dataSourceURI additionalMessages when uri is invalid', async () => { + jest.spyOn(validators, 'isDataSourceURI').mockReturnValueOnce(false); + getAdpConfigMock.mockResolvedValue({ target: { url: 'https://abap.example.com' } }); + + const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const additionalMessages = prompts.find((p) => p.name === 'dataSourceURI')?.additionalMessages as Function; + + expect(typeof additionalMessages).toBe('function'); + const result = await additionalMessages('not-a-valid-uri', undefined); + expect(result).toBeUndefined(); + }); + + it('should return undefined from dataSourceURI additionalMessages when no destination URL is available', async () => { + const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const additionalMessages = prompts.find((p) => p.name === 'dataSourceURI')?.additionalMessages as Function; + + expect(typeof additionalMessages).toBe('function'); + const result = await additionalMessages('/sap/opu/odata4Ann/', undefined); + expect(result).toBeUndefined(); + }); + it('should return true when validating annotation settings prompt', async () => { const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); @@ -365,7 +448,9 @@ describe('getPrompts', () => { it('should return error when xs-app.json already has a route with the same target for CF project', async () => { isCFEnvironmentMock.mockResolvedValue(true); readFileSyncMock.mockReturnValueOnce( - JSON.stringify({ routes: [{ source: '^some/route/(.*)', target: '/sap/opu/odata/v4/$1', destination: 'DEST' }] }) as any + JSON.stringify({ + routes: [{ source: '^some/route/(.*)', target: '/sap/opu/odata/v4/$1', destination: 'DEST' }] + }) as any ); const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); @@ -378,7 +463,9 @@ describe('getPrompts', () => { it('should return true when xs-app.json has no matching route for CF project', async () => { isCFEnvironmentMock.mockResolvedValue(true); readFileSyncMock.mockReturnValueOnce( - JSON.stringify({ routes: [{ source: '^other/route/(.*)', target: '/other/route/$1', destination: 'DEST' }] }) as any + JSON.stringify({ + routes: [{ source: '^other/route/(.*)', target: '/other/route/$1', destination: 'DEST' }] + }) as any ); const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); @@ -395,10 +482,7 @@ describe('getPrompts', () => { expect(typeof validation).toBe('function'); validation?.('/sap/opu/odata/v4/'); - expect(readFileSyncMock).not.toHaveBeenCalledWith( - expect.stringContaining('xs-app.json'), - expect.anything() - ); + expect(readFileSyncMock).not.toHaveBeenCalledWith(expect.stringContaining('xs-app.json'), expect.anything()); }); it('should log the error and set a generic UI error message when fetching destinations fails in CF', async () => { diff --git a/packages/create/test/unit/cli/add/new-model.test.ts b/packages/create/test/unit/cli/add/new-model.test.ts index 24767911b05..b38e9aefb1f 100644 --- a/packages/create/test/unit/cli/add/new-model.test.ts +++ b/packages/create/test/unit/cli/add/new-model.test.ts @@ -103,20 +103,27 @@ describe('add/model', () => { expect(loggerMock.debug).not.toHaveBeenCalled(); expect(traceSpy).not.toHaveBeenCalled(); - expect(generateChangeMock).toHaveBeenCalledWith(expect.anything(), 'appdescr_ui5_addNewModel', { - variant: descriptorVariant, - serviceType: 'OData v2', - isCloudFoundry: false, - destinationName: undefined, - isOnPremiseDestination: undefined, - service: { - name: 'customer.OData_ServiceName', - uri: '/sap/opu/odata/some-name', - modelName: 'customer.OData_ServiceName', - version: '2.0', - modelSettings: '"key": "value"' - } - }); + expect(generateChangeMock).toHaveBeenCalledWith( + expect.anything(), + 'appdescr_ui5_addNewModel', + { + variant: descriptorVariant, + serviceType: 'OData v2', + isCloudFoundry: false, + destinationName: undefined, + isOnPremiseDestination: undefined, + service: { + name: 'customer.OData_ServiceName', + uri: '/sap/opu/odata/some-name', + modelName: 'customer.OData_ServiceName', + version: '2.0', + modelSettings: '"key": "value"' + } + }, + null, + undefined, + expect.anything() + ); }); test('should generate change with correct data and annotation', async () => { @@ -128,25 +135,32 @@ describe('add/model', () => { expect(loggerMock.debug).not.toHaveBeenCalled(); expect(traceSpy).not.toHaveBeenCalled(); - expect(generateChangeMock).toHaveBeenCalledWith(expect.anything(), 'appdescr_ui5_addNewModel', { - variant: descriptorVariant, - serviceType: 'OData v2', - isCloudFoundry: false, - destinationName: undefined, - isOnPremiseDestination: undefined, - service: { - name: 'customer.OData_ServiceName', - uri: '/sap/opu/odata/some-name', - modelName: 'customer.OData_ServiceName', - version: '2.0', - modelSettings: '"key": "value"' + expect(generateChangeMock).toHaveBeenCalledWith( + expect.anything(), + 'appdescr_ui5_addNewModel', + { + variant: descriptorVariant, + serviceType: 'OData v2', + isCloudFoundry: false, + destinationName: undefined, + isOnPremiseDestination: undefined, + service: { + name: 'customer.OData_ServiceName', + uri: '/sap/opu/odata/some-name', + modelName: 'customer.OData_ServiceName', + version: '2.0', + modelSettings: '"key": "value"' + }, + annotation: { + dataSourceName: 'customer.OData_ServiceName.annotation', + dataSourceURI: '/sap/opu/odata/annotation/', + settings: '"key2":"value2"' + } }, - annotation: { - dataSourceName: 'customer.OData_ServiceName.annotation', - dataSourceURI: '/sap/opu/odata/annotation/', - settings: '"key2":"value2"' - } - }); + null, + undefined, + expect.anything() + ); }); test('should generate change with correct data for CF project', async () => { @@ -160,20 +174,27 @@ describe('add/model', () => { expect(loggerMock.debug).not.toHaveBeenCalled(); expect(traceSpy).not.toHaveBeenCalled(); - expect(generateChangeMock).toHaveBeenCalledWith(expect.anything(), 'appdescr_ui5_addNewModel', { - variant: descriptorVariant, - serviceType: 'OData v2', - isCloudFoundry: true, - destinationName: 'CF_DEST', - isOnPremiseDestination: true, - service: { - name: 'customer.OData_ServiceName', - uri: '/sap/opu/odata/some-name', - modelName: 'customer.OData_ServiceName', - version: '2.0', - modelSettings: '"key": "value"' - } - }); + expect(generateChangeMock).toHaveBeenCalledWith( + expect.anything(), + 'appdescr_ui5_addNewModel', + { + variant: descriptorVariant, + serviceType: 'OData v2', + isCloudFoundry: true, + destinationName: 'CF_DEST', + isOnPremiseDestination: true, + service: { + name: 'customer.OData_ServiceName', + uri: '/sap/opu/odata/some-name', + modelName: 'customer.OData_ServiceName', + version: '2.0', + modelSettings: '"key": "value"' + } + }, + null, + undefined, + expect.anything() + ); }); test('should generate change with no base path and simulate true', async () => { @@ -183,20 +204,27 @@ describe('add/model', () => { expect(loggerMock.debug).not.toHaveBeenCalled(); expect(traceSpy).toHaveBeenCalled(); - expect(generateChangeMock).toHaveBeenCalledWith(expect.anything(), 'appdescr_ui5_addNewModel', { - variant: descriptorVariant, - serviceType: 'OData v2', - isCloudFoundry: false, - destinationName: undefined, - isOnPremiseDestination: undefined, - service: { - name: 'customer.OData_ServiceName', - uri: '/sap/opu/odata/some-name', - modelName: 'customer.OData_ServiceName', - version: '2.0', - modelSettings: '"key": "value"' - } - }); + expect(generateChangeMock).toHaveBeenCalledWith( + expect.anything(), + 'appdescr_ui5_addNewModel', + { + variant: descriptorVariant, + serviceType: 'OData v2', + isCloudFoundry: false, + destinationName: undefined, + isOnPremiseDestination: undefined, + service: { + name: 'customer.OData_ServiceName', + uri: '/sap/opu/odata/some-name', + modelName: 'customer.OData_ServiceName', + version: '2.0', + modelSettings: '"key": "value"' + } + }, + null, + undefined, + expect.anything() + ); }); test('should throw error and log it', async () => { diff --git a/packages/generator-adp/test/unit/add-new-model/index.test.ts b/packages/generator-adp/test/unit/add-new-model/index.test.ts index e92d32cbc3c..e48ce8b53c1 100644 --- a/packages/generator-adp/test/unit/add-new-model/index.test.ts +++ b/packages/generator-adp/test/unit/add-new-model/index.test.ts @@ -13,6 +13,7 @@ import { downloadUi5AppInfo } from '@sap-ux/adp-tooling'; import type { NewModelAnswers, DescriptorVariant } from '@sap-ux/adp-tooling'; +import { isOnPremiseDestination } from '@sap-ux/btp-utils'; import newModelGen from '../../../src/add-new-model'; @@ -27,6 +28,11 @@ jest.mock('@sap-ux/adp-tooling', () => ({ downloadUi5AppInfo: jest.fn() })); +jest.mock('@sap-ux/btp-utils', () => ({ + ...jest.requireActual('@sap-ux/btp-utils'), + isOnPremiseDestination: jest.fn() +})); + jest.mock('../../../src/utils/deps', () => ({ installDependencies: jest.fn() })); @@ -41,6 +47,7 @@ const isLoggedInCfMock = isLoggedInCf as jest.MockedFunction; const downloadUi5AppInfoMock = downloadUi5AppInfo as jest.MockedFunction; const installDependenciesMock = installDependencies as jest.MockedFunction; +const isOnPremiseDestinationMock = isOnPremiseDestination as jest.MockedFunction; const variant = { reference: 'customer.adp.variant', @@ -72,6 +79,7 @@ describe('AddNewModelGenerator', () => { runBuildMock.mockResolvedValue(undefined); downloadUi5AppInfoMock.mockResolvedValue(undefined); installDependenciesMock.mockResolvedValue(undefined); + isOnPremiseDestinationMock.mockReturnValue(false); }); afterEach(() => { @@ -106,6 +114,8 @@ describe('AddNewModelGenerator', () => { modelSettings: answers.modelSettings } }), + expect.anything(), + undefined, expect.anything() ); }); @@ -125,6 +135,8 @@ describe('AddNewModelGenerator', () => { tmpDir, ChangeType.ADD_NEW_MODEL, expect.objectContaining({ isCloudFoundry: true }), + expect.anything(), + undefined, expect.anything() ); }); From d35a6fa5eac291992d80f881893c175083bd10ad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 7 Apr 2026 15:51:52 +0000 Subject: [PATCH 04/31] Linting auto fix commit --- packages/adp-tooling/src/types.ts | 4 +- packages/adp-tooling/src/writer/cf.ts | 7 +-- .../test/unit/cf/app/html5-repo.test.ts | 14 ++--- .../test/unit/cf/project/yaml.test.ts | 28 ++++++---- .../test/unit/cf/services/api.test.ts | 53 +++++++++++++++--- .../unit/cf/services/destinations.test.ts | 39 +++++++++++--- .../adp-tooling/test/unit/writer/cf.test.ts | 6 +-- .../unit/writer/changes/writers/index.test.ts | 54 ++++++++----------- packages/create/src/cli/add/new-model.ts | 2 +- .../test/unit/cli/add/new-model.test.ts | 2 +- 10 files changed, 128 insertions(+), 81 deletions(-) diff --git a/packages/adp-tooling/src/types.ts b/packages/adp-tooling/src/types.ts index c5b4eb4cd54..960f0eaa458 100644 --- a/packages/adp-tooling/src/types.ts +++ b/packages/adp-tooling/src/types.ts @@ -908,9 +908,7 @@ export interface Uaa { url: string; } -export type CfDestinationServiceCredentials = - | { uri: string; uaa: Uaa } - | ({ uri: string } & Uaa); +export type CfDestinationServiceCredentials = { uri: string; uaa: Uaa } | ({ uri: string } & Uaa); export interface BtpDestinationConfig { Name: string; diff --git a/packages/adp-tooling/src/writer/cf.ts b/packages/adp-tooling/src/writer/cf.ts index d41f363f293..83c3c393a04 100644 --- a/packages/adp-tooling/src/writer/cf.ts +++ b/packages/adp-tooling/src/writer/cf.ts @@ -6,12 +6,7 @@ import { create, type Editor } from 'mem-fs-editor'; import { type ToolsLogger } from '@sap-ux/logger'; import { readUi5Yaml } from '@sap-ux/project-access'; -import { - adjustMtaYaml, - getOrCreateServiceInstanceKeys, - getCfUi5AppInfo, - getProjectNameForXsSecurity -} from '../cf'; +import { adjustMtaYaml, getOrCreateServiceInstanceKeys, getCfUi5AppInfo, getProjectNameForXsSecurity } from '../cf'; import { getApplicationType } from '../source'; import { fillDescriptorContent } from './manifest'; import type { CfAdpWriterConfig, Content, CfConfig, CfUi5AppInfo } from '../types'; diff --git a/packages/adp-tooling/test/unit/cf/app/html5-repo.test.ts b/packages/adp-tooling/test/unit/cf/app/html5-repo.test.ts index 67134457ab3..800624e06d0 100644 --- a/packages/adp-tooling/test/unit/cf/app/html5-repo.test.ts +++ b/packages/adp-tooling/test/unit/cf/app/html5-repo.test.ts @@ -102,16 +102,12 @@ describe('HTML5 Repository', () => { const result = await getToken(mockUaa); expect(result).toBe('test-access-token'); - expect(mockAxios.post).toHaveBeenCalledWith( - '/test-uaa/oauth/token', - 'grant_type=client_credentials', - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Authorization': 'Basic ' + Buffer.from('test-client-id:test-client-secret').toString('base64') - } + expect(mockAxios.post).toHaveBeenCalledWith('/test-uaa/oauth/token', 'grant_type=client_credentials', { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': 'Basic ' + Buffer.from('test-client-id:test-client-secret').toString('base64') } - ); + }); }); test('should throw error when token request fails', async () => { diff --git a/packages/adp-tooling/test/unit/cf/project/yaml.test.ts b/packages/adp-tooling/test/unit/cf/project/yaml.test.ts index b59b8ae5053..50a0247d7c2 100644 --- a/packages/adp-tooling/test/unit/cf/project/yaml.test.ts +++ b/packages/adp-tooling/test/unit/cf/project/yaml.test.ts @@ -36,7 +36,9 @@ jest.mock('../../../../src/cf/project/yaml-loader', () => ({ const mockCreateServices = createServices as jest.MockedFunction; const mockCreateServiceInstance = createServiceInstance as jest.MockedFunction; -const mockGetOrCreateServiceInstanceKeys = getOrCreateServiceInstanceKeys as jest.MockedFunction; +const mockGetOrCreateServiceInstanceKeys = getOrCreateServiceInstanceKeys as jest.MockedFunction< + typeof getOrCreateServiceInstanceKeys +>; const mockGetYamlContent = getYamlContent as jest.MockedFunction; const mockGetProjectNameForXsSecurity = getProjectNameForXsSecurity as jest.MockedFunction< typeof getProjectNameForXsSecurity @@ -906,20 +908,22 @@ describe('YAML Project Functions', () => { mtaYamlPath, expect.stringContaining('myproject-connectivity') ); + expect(mockMemFs.write).toHaveBeenCalledWith(mtaYamlPath, expect.stringContaining('connectivity')); + expect(mockMemFs.write).toHaveBeenCalledWith(mtaYamlPath, expect.stringContaining('lite')); expect(mockMemFs.write).toHaveBeenCalledWith( mtaYamlPath, - expect.stringContaining('connectivity') + expect.stringContaining('service-name: myproject-connectivity') ); - expect(mockMemFs.write).toHaveBeenCalledWith( - mtaYamlPath, - expect.stringContaining('lite') + expect(mockCreateServiceInstance).toHaveBeenCalledWith( + 'lite', + 'myproject-connectivity', + 'connectivity', + expect.any(Object) ); - expect(mockMemFs.write).toHaveBeenCalledWith( - mtaYamlPath, - expect.stringContaining('service-name: myproject-connectivity') + expect(mockGetOrCreateServiceInstanceKeys).toHaveBeenCalledWith( + { names: ['myproject-connectivity'] }, + undefined ); - expect(mockCreateServiceInstance).toHaveBeenCalledWith('lite', 'myproject-connectivity', 'connectivity', expect.any(Object)); - expect(mockGetOrCreateServiceInstanceKeys).toHaveBeenCalledWith({ names: ['myproject-connectivity'] }, undefined); }); test('should do nothing when project has no mta.yaml', async () => { @@ -968,7 +972,9 @@ describe('YAML Project Functions', () => { mockGetYamlContent.mockReturnValue({ ...mockMtaYaml, resources: [] }); mockCreateServiceInstance.mockRejectedValueOnce(new Error('CF error')); - await expect(addConnectivityServiceToMta(projectPath, mockMemFs as unknown as Editor)).rejects.toThrow('CF error'); + await expect(addConnectivityServiceToMta(projectPath, mockMemFs as unknown as Editor)).rejects.toThrow( + 'CF error' + ); expect(mockMemFs.write).not.toHaveBeenCalled(); }); diff --git a/packages/adp-tooling/test/unit/cf/services/api.test.ts b/packages/adp-tooling/test/unit/cf/services/api.test.ts index e8889f97368..067cf768c8a 100644 --- a/packages/adp-tooling/test/unit/cf/services/api.test.ts +++ b/packages/adp-tooling/test/unit/cf/services/api.test.ts @@ -975,8 +975,21 @@ describe('CF Services API', () => { }; const mockBtpConfigs = [ - { Name: 'DEST_ONE', Type: 'HTTP', URL: 'https://one.example.com', Authentication: 'NoAuthentication', ProxyType: 'Internet', Description: 'First dest' }, - { Name: 'DEST_TWO', Type: 'HTTP', URL: 'https://two.example.com', Authentication: 'BasicAuthentication', ProxyType: 'OnPremise' } + { + Name: 'DEST_ONE', + Type: 'HTTP', + URL: 'https://one.example.com', + Authentication: 'NoAuthentication', + ProxyType: 'Internet', + Description: 'First dest' + }, + { + Name: 'DEST_TWO', + Type: 'HTTP', + URL: 'https://two.example.com', + Authentication: 'BasicAuthentication', + ProxyType: 'OnPremise' + } ]; beforeEach(() => { @@ -989,8 +1002,22 @@ describe('CF Services API', () => { const result = await listBtpDestinations(mockCredentials); expect(result).toEqual({ - DEST_ONE: { Name: 'DEST_ONE', Host: 'https://one.example.com', Type: 'HTTP', Authentication: 'NoAuthentication', ProxyType: 'Internet', Description: 'First dest' }, - DEST_TWO: { Name: 'DEST_TWO', Host: 'https://two.example.com', Type: 'HTTP', Authentication: 'BasicAuthentication', ProxyType: 'OnPremise', Description: '' } + DEST_ONE: { + Name: 'DEST_ONE', + Host: 'https://one.example.com', + Type: 'HTTP', + Authentication: 'NoAuthentication', + ProxyType: 'Internet', + Description: 'First dest' + }, + DEST_TWO: { + Name: 'DEST_TWO', + Host: 'https://two.example.com', + Type: 'HTTP', + Authentication: 'BasicAuthentication', + ProxyType: 'OnPremise', + Description: '' + } }); }); @@ -1006,8 +1033,22 @@ describe('CF Services API', () => { const result = await listBtpDestinations(flatCredentials); expect(result).toEqual({ - DEST_ONE: { Name: 'DEST_ONE', Host: 'https://one.example.com', Type: 'HTTP', Authentication: 'NoAuthentication', ProxyType: 'Internet', Description: 'First dest' }, - DEST_TWO: { Name: 'DEST_TWO', Host: 'https://two.example.com', Type: 'HTTP', Authentication: 'BasicAuthentication', ProxyType: 'OnPremise', Description: '' } + DEST_ONE: { + Name: 'DEST_ONE', + Host: 'https://one.example.com', + Type: 'HTTP', + Authentication: 'NoAuthentication', + ProxyType: 'Internet', + Description: 'First dest' + }, + DEST_TWO: { + Name: 'DEST_TWO', + Host: 'https://two.example.com', + Type: 'HTTP', + Authentication: 'BasicAuthentication', + ProxyType: 'OnPremise', + Description: '' + } }); }); diff --git a/packages/adp-tooling/test/unit/cf/services/destinations.test.ts b/packages/adp-tooling/test/unit/cf/services/destinations.test.ts index 615538ec0fa..7165c7088dc 100644 --- a/packages/adp-tooling/test/unit/cf/services/destinations.test.ts +++ b/packages/adp-tooling/test/unit/cf/services/destinations.test.ts @@ -25,16 +25,34 @@ const mockMtaYaml = { '_schema-version': '3.3.0', version: '0.0.1', resources: [ - { name: 'test-project-destination', type: 'org.cloudfoundry.managed-service', parameters: { service: 'destination', 'service-plan': 'lite' } }, - { name: 'test-project-uaa', type: 'org.cloudfoundry.managed-service', parameters: { service: 'xsuaa', 'service-plan': 'application' } } + { + name: 'test-project-destination', + type: 'org.cloudfoundry.managed-service', + parameters: { service: 'destination', 'service-plan': 'lite' } + }, + { + name: 'test-project-uaa', + type: 'org.cloudfoundry.managed-service', + parameters: { service: 'xsuaa', 'service-plan': 'application' } + } ] }; const mockDestinations = { - MY_DEST: { Name: 'MY_DEST', Host: 'https://dest.example.com', Type: 'HTTP', Authentication: 'NoAuthentication', ProxyType: 'Internet', Description: 'My destination' } + MY_DEST: { + Name: 'MY_DEST', + Host: 'https://dest.example.com', + Type: 'HTTP', + Authentication: 'NoAuthentication', + ProxyType: 'Internet', + Description: 'My destination' + } }; -const mockCredentials = { uri: 'https://destination.cfapps.example.com', uaa: { clientid: 'client-id', clientsecret: 'client-secret', url: 'https://auth.example.com' } }; +const mockCredentials = { + uri: 'https://destination.cfapps.example.com', + uaa: { clientid: 'client-id', clientsecret: 'client-secret', url: 'https://auth.example.com' } +}; const mockServiceInfo = { serviceKeys: [{ credentials: mockCredentials }], @@ -66,7 +84,13 @@ describe('getDestinations', () => { it('should throw an error when no destination service is found in mta.yaml', async () => { getYamlContentMock.mockReturnValue({ ...mockMtaYaml, - resources: [{ name: 'test-project-uaa', type: 'org.cloudfoundry.managed-service', parameters: { service: 'xsuaa', 'service-plan': 'application' } }] + resources: [ + { + name: 'test-project-uaa', + type: 'org.cloudfoundry.managed-service', + parameters: { service: 'xsuaa', 'service-plan': 'application' } + } + ] }); await expect(getDestinations(mockProjectPath)).rejects.toThrow(t('error.destinationServiceNotFoundInMtaYaml')); @@ -86,7 +110,10 @@ describe('getDestinations', () => { it('should throw an error when no service keys are available', async () => { getYamlContentMock.mockReturnValue(mockMtaYaml); - getOrCreateServiceInstanceKeysMock.mockResolvedValue({ serviceKeys: [], serviceInstance: { name: 'test-project-destination', guid: 'some-guid' } }); + getOrCreateServiceInstanceKeysMock.mockResolvedValue({ + serviceKeys: [], + serviceInstance: { name: 'test-project-destination', guid: 'some-guid' } + }); await expect(getDestinations(mockProjectPath)).rejects.toThrow( t('error.noServiceKeysFoundForDestination', { serviceInstanceName: 'test-project-destination' }) diff --git a/packages/adp-tooling/test/unit/writer/cf.test.ts b/packages/adp-tooling/test/unit/writer/cf.test.ts index 0a4d78b91a4..3b64cb4d890 100644 --- a/packages/adp-tooling/test/unit/writer/cf.test.ts +++ b/packages/adp-tooling/test/unit/writer/cf.test.ts @@ -9,11 +9,7 @@ import type { Manifest } from '@sap-ux/project-access'; import { generateCf, writeUi5AppInfo, setupCfPreview } from '../../../src/writer/cf'; import { AppRouterType, FlexLayer, type CfAdpWriterConfig, type CfUi5AppInfo, type CfConfig } from '../../../src/types'; -import { - getOrCreateServiceInstanceKeys, - getCfUi5AppInfo, - getProjectNameForXsSecurity -} from '../../../src/cf'; +import { getOrCreateServiceInstanceKeys, getCfUi5AppInfo, getProjectNameForXsSecurity } from '../../../src/cf'; import { runBuild } from '../../../src/base/project-builder'; import { readUi5Yaml } from '@sap-ux/project-access'; import { getBaseAppId } from '../../../src/base/helper'; diff --git a/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts b/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts index 6624e100a7e..a9328b9b20b 100644 --- a/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts +++ b/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts @@ -383,22 +383,16 @@ describe('NewModelWriter', () => { await writer.write(mockData); - expect(readJSONMock).toHaveBeenCalledWith( - `${mockProjectPath}/webapp/xs-app.json`, - { routes: [] } - ); - expect(writeJSONMock).toHaveBeenCalledWith( - `${mockProjectPath}/webapp/xs-app.json`, - { - routes: [ - { - source: '^/customer/MyService/sap/opu/odata/v4/(.*)', - target: '/sap/opu/odata/v4/$1', - destination: 'MY_CF_DEST' - } - ] - } - ); + expect(readJSONMock).toHaveBeenCalledWith(`${mockProjectPath}/webapp/xs-app.json`, { routes: [] }); + expect(writeJSONMock).toHaveBeenCalledWith(`${mockProjectPath}/webapp/xs-app.json`, { + routes: [ + { + source: '^/customer/MyService/sap/opu/odata/v4/(.*)', + target: '/sap/opu/odata/v4/$1', + destination: 'MY_CF_DEST' + } + ] + }); }); it('should append a route to existing xs-app.json routes for a CF project', async () => { @@ -421,19 +415,16 @@ describe('NewModelWriter', () => { await writer.write(mockData); - expect(writeJSONMock).toHaveBeenCalledWith( - `${mockProjectPath}/webapp/xs-app.json`, - { - routes: [ - { source: '^existing/route/(.*)', target: '/existing/$1', destination: 'OTHER_DEST' }, - { - source: '^/customer/NewService/sap/opu/odata/v2/(.*)', - target: '/sap/opu/odata/v2/$1', - destination: 'MY_CF_DEST' - } - ] - } - ); + expect(writeJSONMock).toHaveBeenCalledWith(`${mockProjectPath}/webapp/xs-app.json`, { + routes: [ + { source: '^existing/route/(.*)', target: '/existing/$1', destination: 'OTHER_DEST' }, + { + source: '^/customer/NewService/sap/opu/odata/v2/(.*)', + target: '/sap/opu/odata/v2/$1', + destination: 'MY_CF_DEST' + } + ] + }); }); it('should not write xs-app.json for a non-CF project', async () => { @@ -471,10 +462,7 @@ describe('NewModelWriter', () => { await writer.write(mockData); - expect(addConnectivityServiceToMtaMock).toHaveBeenCalledWith( - '/mock/project', - expect.any(Object) - ); + expect(addConnectivityServiceToMtaMock).toHaveBeenCalledWith('/mock/project', expect.any(Object)); }); it('should not call addConnectivityServiceToMta when not in CF (isOnPremiseDestination absent)', async () => { diff --git a/packages/create/src/cli/add/new-model.ts b/packages/create/src/cli/add/new-model.ts index bb74b31836c..4d2ffbd7912 100644 --- a/packages/create/src/cli/add/new-model.ts +++ b/packages/create/src/cli/add/new-model.ts @@ -110,4 +110,4 @@ async function createNewModelData( } }) }; -} \ No newline at end of file +} diff --git a/packages/create/test/unit/cli/add/new-model.test.ts b/packages/create/test/unit/cli/add/new-model.test.ts index b38e9aefb1f..ba55f1a35a1 100644 --- a/packages/create/test/unit/cli/add/new-model.test.ts +++ b/packages/create/test/unit/cli/add/new-model.test.ts @@ -239,4 +239,4 @@ describe('add/model', () => { expect(traceSpy).not.toHaveBeenCalled(); expect(generateChangeMock).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); From f52d6c3465f3dbfbd9786be0b52947e0d2a3f6d6 Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Tue, 7 Apr 2026 20:02:30 +0300 Subject: [PATCH 05/31] fix: remove install and build steps from generator --- .../unit/writer/changes/writers/index.test.ts | 5 ++ .../generator-adp/src/add-new-model/index.ts | 10 +--- packages/generator-adp/src/types.ts | 2 +- .../test/unit/add-new-model/index.test.ts | 52 +------------------ 4 files changed, 9 insertions(+), 60 deletions(-) diff --git a/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts b/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts index 6624e100a7e..34cad7e1568 100644 --- a/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts +++ b/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts @@ -40,6 +40,11 @@ jest.mock('../../../../../src/cf/project/yaml', () => ({ addConnectivityServiceToMta: jest.fn() })); +jest.mock('../../../../../src/cf/services/ssh', () => ({ + ensureTunnelAppExists: jest.fn().mockResolvedValue(undefined), + DEFAULT_TUNNEL_APP_NAME: 'adp-ssh-tunnel-app' +})); + const writeAnnotationChangeMock = writeAnnotationChange as jest.Mock; const getChangeMock = getChange as jest.Mock; const writeChangeToFolderMock = writeChangeToFolder as jest.Mock; diff --git a/packages/generator-adp/src/add-new-model/index.ts b/packages/generator-adp/src/add-new-model/index.ts index b6c586349ef..58d60629278 100644 --- a/packages/generator-adp/src/add-new-model/index.ts +++ b/packages/generator-adp/src/add-new-model/index.ts @@ -8,10 +8,8 @@ import { getODataVersionFromServiceType, ServiceType, isCFEnvironment, - runBuild, isLoggedInCf, - loadCfConfig, - downloadUi5AppInfo + loadCfConfig } from '@sap-ux/adp-tooling'; import { isOnPremiseDestination } from '@sap-ux/btp-utils'; import { setYeomanEnvConflicterForce } from '@sap-ux/fiori-generator-shared'; @@ -19,7 +17,6 @@ import { setYeomanEnvConflicterForce } from '@sap-ux/fiori-generator-shared'; import { GeneratorTypes } from '../types'; import { initI18n, t } from '../utils/i18n'; import type { GeneratorOpts } from '../utils/opts'; -import { installDependencies } from '../utils/deps'; import SubGeneratorBase from '../base/sub-gen-base'; /** @@ -109,11 +106,6 @@ class AddNewModelGenerator extends SubGeneratorBase { async end(): Promise { this.logger.log('Successfully created change!'); - if (await isCFEnvironment(this.projectPath)) { - await installDependencies(this.projectPath); - await downloadUi5AppInfo(this.projectPath, this.cfConfig!, this.logger); - await runBuild(this.projectPath, { ADP_BUILDER_MODE: 'preview' }); - } } /** diff --git a/packages/generator-adp/src/types.ts b/packages/generator-adp/src/types.ts index 913e5bcfdb7..441ea628bbc 100644 --- a/packages/generator-adp/src/types.ts +++ b/packages/generator-adp/src/types.ts @@ -1,7 +1,7 @@ export enum GeneratorTypes { ADD_ANNOTATIONS_TO_DATA = 'Add Local Annotation File', ADD_COMPONENT_USAGES = 'Add SAPUI5 Component Usages', - ADD_NEW_MODEL = 'Add OData Service And SAPUI5 Model', + ADD_NEW_MODEL = 'Add Datasource and SAPUI5 Model', CHANGE_DATA_SOURCE = 'Replace OData Service' } diff --git a/packages/generator-adp/test/unit/add-new-model/index.test.ts b/packages/generator-adp/test/unit/add-new-model/index.test.ts index e48ce8b53c1..0e2f23dc5c2 100644 --- a/packages/generator-adp/test/unit/add-new-model/index.test.ts +++ b/packages/generator-adp/test/unit/add-new-model/index.test.ts @@ -7,10 +7,8 @@ import { generateChange, getVariant, isCFEnvironment, - runBuild, isLoggedInCf, - loadCfConfig, - downloadUi5AppInfo + loadCfConfig } from '@sap-ux/adp-tooling'; import type { NewModelAnswers, DescriptorVariant } from '@sap-ux/adp-tooling'; import { isOnPremiseDestination } from '@sap-ux/btp-utils'; @@ -22,10 +20,8 @@ jest.mock('@sap-ux/adp-tooling', () => ({ generateChange: jest.fn(), getVariant: jest.fn(), isCFEnvironment: jest.fn(), - runBuild: jest.fn(), isLoggedInCf: jest.fn(), - loadCfConfig: jest.fn(), - downloadUi5AppInfo: jest.fn() + loadCfConfig: jest.fn() })); jest.mock('@sap-ux/btp-utils', () => ({ @@ -33,20 +29,11 @@ jest.mock('@sap-ux/btp-utils', () => ({ isOnPremiseDestination: jest.fn() })); -jest.mock('../../../src/utils/deps', () => ({ - installDependencies: jest.fn() -})); - -import { installDependencies } from '../../../src/utils/deps'; - const generateChangeMock = generateChange as jest.MockedFunction; const getVariantMock = getVariant as jest.MockedFunction; const isCFEnvironmentMock = isCFEnvironment as jest.MockedFunction; -const runBuildMock = runBuild as jest.MockedFunction; const isLoggedInCfMock = isLoggedInCf as jest.MockedFunction; const loadCfConfigMock = loadCfConfig as jest.MockedFunction; -const downloadUi5AppInfoMock = downloadUi5AppInfo as jest.MockedFunction; -const installDependenciesMock = installDependencies as jest.MockedFunction; const isOnPremiseDestinationMock = isOnPremiseDestination as jest.MockedFunction; const variant = { @@ -76,9 +63,6 @@ describe('AddNewModelGenerator', () => { isCFEnvironmentMock.mockResolvedValue(false); isLoggedInCfMock.mockResolvedValue(true); loadCfConfigMock.mockReturnValue(mockCfConfig as any); - runBuildMock.mockResolvedValue(undefined); - downloadUi5AppInfoMock.mockResolvedValue(undefined); - installDependenciesMock.mockResolvedValue(undefined); isOnPremiseDestinationMock.mockReturnValue(false); }); @@ -164,36 +148,4 @@ describe('AddNewModelGenerator', () => { writingSpy.mockRestore(); handleCrashSpy.mockRestore(); }); - - it('stores cfConfig once and calls downloadUi5AppInfo + runBuild with ADP_BUILDER_MODE=preview in end()', async () => { - getVariantMock.mockResolvedValue(variant); - isCFEnvironmentMock.mockResolvedValue(true); - - const runContext = yeomanTest - .create(newModelGen, { resolved: generatorPath }, { cwd: tmpDir }) - .withOptions({ data: { path: tmpDir } }) - .withPrompts(answers); - - await expect(runContext.run()).resolves.not.toThrow(); - - expect(loadCfConfigMock).toHaveBeenCalledTimes(1); - expect(installDependenciesMock).toHaveBeenCalledWith(tmpDir); - expect(downloadUi5AppInfoMock).toHaveBeenCalledWith(tmpDir, mockCfConfig, expect.anything()); - expect(runBuildMock).toHaveBeenCalledWith(tmpDir, { ADP_BUILDER_MODE: 'preview' }); - }); - - it('does not call downloadUi5AppInfo or runBuild for non-CF projects', async () => { - getVariantMock.mockResolvedValue(variant); - isCFEnvironmentMock.mockResolvedValue(false); - - const runContext = yeomanTest - .create(newModelGen, { resolved: generatorPath }, { cwd: tmpDir }) - .withOptions({ data: { path: tmpDir } }) - .withPrompts(answers); - - await expect(runContext.run()).resolves.not.toThrow(); - - expect(downloadUi5AppInfoMock).not.toHaveBeenCalled(); - expect(runBuildMock).not.toHaveBeenCalled(); - }); }); From e9b501afc1e4050cecc12de1c27a4041d3e6e4cb Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Tue, 7 Apr 2026 20:46:06 +0300 Subject: [PATCH 06/31] refactor: remove unneeded code --- packages/adp-tooling/src/cf/services/api.ts | 3 +- .../src/cf/services/destinations.ts | 3 +- packages/adp-tooling/src/types.ts | 4 +- packages/adp-tooling/src/writer/cf.ts | 40 +--------------- .../changes/writers/new-model-writer.ts | 12 +---- .../test/unit/cf/project/yaml.test.ts | 8 ++-- .../unit/cf/services/destinations.test.ts | 2 +- .../unit/writer/changes/writers/index.test.ts | 46 +++++++++++++++---- 8 files changed, 47 insertions(+), 71 deletions(-) diff --git a/packages/adp-tooling/src/cf/services/api.ts b/packages/adp-tooling/src/cf/services/api.ts index 30140afde43..03881a2cb0f 100644 --- a/packages/adp-tooling/src/cf/services/api.ts +++ b/packages/adp-tooling/src/cf/services/api.ts @@ -445,9 +445,8 @@ export async function getOrCreateServiceKeys( /** * Lists all subaccount destinations from the BTP Destination Configuration API. - * This works in both VS Code and BAS, as long as valid destination service credentials are provided. * - * @param {CfDestinationServiceCredentials} credentials - Destination service credentials (uri + uaa). + * @param {CfDestinationServiceCredentials} credentials - Destination service credentials. * @returns {Promise} Map of destination name to Destination object. */ export async function listBtpDestinations(credentials: CfDestinationServiceCredentials): Promise { diff --git a/packages/adp-tooling/src/cf/services/destinations.ts b/packages/adp-tooling/src/cf/services/destinations.ts index fb48e0e9361..48be35f3b3d 100644 --- a/packages/adp-tooling/src/cf/services/destinations.ts +++ b/packages/adp-tooling/src/cf/services/destinations.ts @@ -9,11 +9,10 @@ import type { CfDestinationServiceCredentials, MtaYaml } from '../../types'; /** * Finds the name of the destination service instance declared in the MTA project's mta.yaml. - * The mta.yaml lives one level above the app project directory. * * @param {string} projectPath - The root path of the app project. * @returns {string} The CF service instance name. - * @throws {Error} If the destination service instance is not found or mta.yaml cannot be read. + * @throws {Error} When the destination service instance is not found or mta.yaml cannot be read. */ function getDestinationServiceName(projectPath: string): string { try { diff --git a/packages/adp-tooling/src/types.ts b/packages/adp-tooling/src/types.ts index 960f0eaa458..85884ac5f6a 100644 --- a/packages/adp-tooling/src/types.ts +++ b/packages/adp-tooling/src/types.ts @@ -669,9 +669,9 @@ export interface NewModelDataBase { name: string; /** URI of the OData service. */ uri: string; - /** Name of the OData service model. Optional — absent for HTTP service type. */ + /** Name of the OData service model. Optional for absent for HTTP service type. */ modelName?: string; - /** Version of OData used. Undefined for HTTP service type. */ + /** Version of OData used. Optional for HTTP service type. */ version?: string; /** Settings for the OData service model. */ modelSettings?: string; diff --git a/packages/adp-tooling/src/writer/cf.ts b/packages/adp-tooling/src/writer/cf.ts index 83c3c393a04..05dd75505d7 100644 --- a/packages/adp-tooling/src/writer/cf.ts +++ b/packages/adp-tooling/src/writer/cf.ts @@ -113,44 +113,6 @@ export async function writeUi5AppInfo(basePath: string, ui5AppInfo: CfUi5AppInfo } } -/** - * Downloads ui5AppInfo.json from the FDC service and writes it to the project root. - * Reads the html5-apps-repo service instance name from the app-variant-bundler-build - * task in ui5.yaml, fetches service keys, then calls the FDC API. - * - * @param projectPath - path to application root - * @param cfConfig - CF configuration (token, url, space) - * @param logger - optional logger instance - */ -export async function downloadUi5AppInfo(projectPath: string, cfConfig: CfConfig, logger?: ToolsLogger): Promise { - const ui5Config = await readUi5Yaml(projectPath, 'ui5.yaml'); - const bundlerTask = ui5Config.findCustomTask<{ serviceInstanceName?: string; space?: string }>( - 'app-variant-bundler-build' - ); - const serviceInstanceName = bundlerTask?.configuration?.serviceInstanceName; - if (!serviceInstanceName) { - throw new Error('No serviceInstanceName found in app-variant-bundler-build configuration'); - } - - const spaceGuid = bundlerTask?.configuration?.space; - const serviceInfo = await getOrCreateServiceInstanceKeys( - { names: [serviceInstanceName], ...(spaceGuid ? { spaceGuids: [spaceGuid] } : {}) }, - logger - ); - if (!serviceInfo || serviceInfo.serviceKeys.length === 0) { - throw new Error(`No service keys found for service instance: ${serviceInstanceName}`); - } - - const appId = await getBaseAppId(projectPath); - const appHostIds = getAppHostIds(serviceInfo.serviceKeys); - if (appHostIds.length === 0) { - throw new Error('No app host IDs found in service keys.'); - } - - const ui5AppInfo = await getCfUi5AppInfo(appId, appHostIds, cfConfig, logger); - await writeUi5AppInfo(projectPath, ui5AppInfo, logger); -} - /** * Setup CF adaptation project for local preview. * Fetches ui5AppInfo.json and builds the project. @@ -190,12 +152,12 @@ export async function setupCfPreview( const appId = await getBaseAppId(basePath); const appHostIds = getAppHostIds(serviceInfo.serviceKeys); + const ui5AppInfo: CfUi5AppInfo = await getCfUi5AppInfo(appId, appHostIds, cfConfig, logger); if (appHostIds.length === 0) { throw new Error('No app host IDs found in service keys.'); } - const ui5AppInfo: CfUi5AppInfo = await getCfUi5AppInfo(appId, appHostIds, cfConfig, logger); await writeUi5AppInfo(basePath, ui5AppInfo, logger); await runBuild(basePath, { ADP_BUILDER_MODE: 'preview' }); } diff --git a/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts b/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts index a154fa46220..fbc17744ae6 100644 --- a/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts +++ b/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts @@ -9,18 +9,11 @@ import { parseStringToObject, getChange, writeChangeToFolder } from '../../../ba import { addConnectivityServiceToMta } from '../../../cf/project/yaml'; import { ensureTunnelAppExists, DEFAULT_TUNNEL_APP_NAME } from '../../../cf/services/ssh'; -const CF_MODEL_SETTINGS = { - operationMode: 'Server', - autoExpandSelect: true, - earlyRequests: true -} as const; - type NewModelContent = { model?: { [key: string]: { settings?: object; dataSource: string; - preload?: boolean; }; }; dataSource: { @@ -74,10 +67,7 @@ export class NewModelWriter implements IWriter { if (!isHttp && service.modelName) { content.model = { [service.modelName]: { dataSource: service.name } }; - if (isCloudFoundry) { - content.model[service.modelName].preload = true; - content.model[service.modelName].settings = { ...CF_MODEL_SETTINGS }; - } else if (service.modelSettings && service.modelSettings.length !== 0) { + if (service.modelSettings && service.modelSettings.length !== 0) { content.model[service.modelName].settings = parseStringToObject(service.modelSettings); } } diff --git a/packages/adp-tooling/test/unit/cf/project/yaml.test.ts b/packages/adp-tooling/test/unit/cf/project/yaml.test.ts index 50a0247d7c2..f9da396b2a6 100644 --- a/packages/adp-tooling/test/unit/cf/project/yaml.test.ts +++ b/packages/adp-tooling/test/unit/cf/project/yaml.test.ts @@ -926,7 +926,7 @@ describe('YAML Project Functions', () => { ); }); - test('should do nothing when project has no mta.yaml', async () => { + test('should not create service when project has no mta.yaml', async () => { mockExistsSync.mockReturnValue(false); await addConnectivityServiceToMta(projectPath, mockMemFs as unknown as Editor); @@ -936,7 +936,7 @@ describe('YAML Project Functions', () => { expect(mockGetOrCreateServiceInstanceKeys).not.toHaveBeenCalled(); }); - test('should do nothing when yaml content cannot be read', async () => { + test('should not create service when yaml content cannot be read', async () => { mockExistsSync.mockReturnValue(true); mockGetYamlContent.mockReturnValue(null); @@ -947,7 +947,7 @@ describe('YAML Project Functions', () => { expect(mockGetOrCreateServiceInstanceKeys).not.toHaveBeenCalled(); }); - test('should do nothing when connectivity resource already exists (idempotent)', async () => { + test('should not create service when connectivity resource already exists (idempotent)', async () => { mockExistsSync.mockReturnValue(true); mockGetYamlContent.mockReturnValue({ ...mockMtaYaml, @@ -967,7 +967,7 @@ describe('YAML Project Functions', () => { expect(mockGetOrCreateServiceInstanceKeys).not.toHaveBeenCalled(); }); - test('should not write mta.yaml when createServiceInstance fails', async () => { + test('should not modify mta.yaml when createServiceInstance fails', async () => { mockExistsSync.mockReturnValue(true); mockGetYamlContent.mockReturnValue({ ...mockMtaYaml, resources: [] }); mockCreateServiceInstance.mockRejectedValueOnce(new Error('CF error')); diff --git a/packages/adp-tooling/test/unit/cf/services/destinations.test.ts b/packages/adp-tooling/test/unit/cf/services/destinations.test.ts index 7165c7088dc..54322541218 100644 --- a/packages/adp-tooling/test/unit/cf/services/destinations.test.ts +++ b/packages/adp-tooling/test/unit/cf/services/destinations.test.ts @@ -122,7 +122,7 @@ describe('getDestinations', () => { expect(listBtpDestinationsMock).not.toHaveBeenCalled(); }); - it('should throw an error when getOrCreateServiceInstanceKeys returns null', async () => { + it('should throw an error when getOrCreateServiceInstanceKeys does not return any keys', async () => { getYamlContentMock.mockReturnValue(mockMtaYaml); getOrCreateServiceInstanceKeysMock.mockResolvedValue(null); diff --git a/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts b/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts index 43eea58391b..4f91c439b50 100644 --- a/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts +++ b/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts @@ -297,7 +297,7 @@ describe('NewModelWriter', () => { expect(writeChangeToFolderMock).toHaveBeenCalledWith(mockProjectPath, expect.any(Object), expect.any(Object)); }); - it('should omit the model block when modelName is undefined (HTTP service type)', async () => { + it('should omit the model block in HTTP service type scenario', async () => { const mockData: NewModelData = { variant: {} as DescriptorVariant, serviceType: ServiceType.HTTP, @@ -327,7 +327,7 @@ describe('NewModelWriter', () => { ); }); - it('should construct CF-specific content with derived URI, preload and fixed model settings', async () => { + it('should construct CF change content with derived URI', async () => { const mockData: NewModelData = { variant: {} as DescriptorVariant, serviceType: ServiceType.ODATA_V4, @@ -358,13 +358,7 @@ describe('NewModelWriter', () => { }, 'model': { 'customer.MyService': { - 'dataSource': 'customer.MyService', - 'preload': true, - 'settings': { - 'operationMode': 'Server', - 'autoExpandSelect': true, - 'earlyRequests': true - } + 'dataSource': 'customer.MyService' } } }, @@ -372,6 +366,38 @@ describe('NewModelWriter', () => { ); }); + it('should apply user modelSettings for CF project', async () => { + const mockData: NewModelData = { + variant: {} as DescriptorVariant, + serviceType: ServiceType.ODATA_V4, + isCloudFoundry: true, + destinationName: 'MY_CF_DEST', + service: { + name: 'customer.MyService', + uri: '/sap/opu/odata/v4/', + modelName: 'customer.MyService', + version: '4.0', + modelSettings: '"operationMode": "Server"' + } + }; + + await writer.write(mockData); + + expect(getChangeMock).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + 'model': { + 'customer.MyService': { + 'dataSource': 'customer.MyService', + 'settings': { 'operationMode': 'Server' } + } + } + }), + ChangeType.ADD_NEW_MODEL + ); + }); + it('should create xs-app.json with a new route for a CF project when xs-app.json does not exist', async () => { const mockData: NewModelData = { variant: {} as DescriptorVariant, @@ -470,7 +496,7 @@ describe('NewModelWriter', () => { expect(addConnectivityServiceToMtaMock).toHaveBeenCalledWith('/mock/project', expect.any(Object)); }); - it('should not call addConnectivityServiceToMta when not in CF (isOnPremiseDestination absent)', async () => { + it('should not call addConnectivityServiceToMta when not in CF', async () => { const mockData: NewModelData = { variant: {} as DescriptorVariant, serviceType: ServiceType.ODATA_V2, From bc623f6b5f405c24c3aebdc1591cff2043486825 Mon Sep 17 00:00:00 2001 From: mmilko01 <162288787+mmilko01@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:48:59 +0300 Subject: [PATCH 07/31] chore: create changeset --- .changeset/selfish-monkeys-joke.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/selfish-monkeys-joke.md diff --git a/.changeset/selfish-monkeys-joke.md b/.changeset/selfish-monkeys-joke.md new file mode 100644 index 00000000000..f4739e30445 --- /dev/null +++ b/.changeset/selfish-monkeys-joke.md @@ -0,0 +1,7 @@ +--- +"@sap-ux/adp-tooling": patch +"sap-ux/generator-adp": patch +"@sap-ux/create": patch +--- + +feat: Extend add-new-model generator to support external services for CF projects From e360a3e03f67fecf9ee35e14cf82114a46df1b97 Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Wed, 8 Apr 2026 09:16:20 +0300 Subject: [PATCH 08/31] chore: fix changeset --- .changeset/selfish-monkeys-joke.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/selfish-monkeys-joke.md b/.changeset/selfish-monkeys-joke.md index f4739e30445..bb35077d74b 100644 --- a/.changeset/selfish-monkeys-joke.md +++ b/.changeset/selfish-monkeys-joke.md @@ -1,6 +1,6 @@ --- "@sap-ux/adp-tooling": patch -"sap-ux/generator-adp": patch +"@sap-ux/generator-adp": patch "@sap-ux/create": patch --- From 08469db276d8bb73167d75ee3d0d940e8b46f8fd Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Wed, 8 Apr 2026 09:29:49 +0300 Subject: [PATCH 09/31] test: fix tests for windows --- .../test/unit/cf/services/destinations.test.ts | 5 +++-- .../unit/writer/changes/writers/index.test.ts | 15 ++++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/adp-tooling/test/unit/cf/services/destinations.test.ts b/packages/adp-tooling/test/unit/cf/services/destinations.test.ts index 54322541218..25809c1d153 100644 --- a/packages/adp-tooling/test/unit/cf/services/destinations.test.ts +++ b/packages/adp-tooling/test/unit/cf/services/destinations.test.ts @@ -1,3 +1,4 @@ +import { join, dirname } from 'node:path'; import { getDestinations } from '../../../../src/cf/services/destinations'; import { getOrCreateServiceInstanceKeys, listBtpDestinations } from '../../../../src/cf/services/api'; import { getYamlContent } from '../../../../src/cf/project/yaml-loader'; @@ -18,7 +19,7 @@ const getOrCreateServiceInstanceKeysMock = getOrCreateServiceInstanceKeys as jes const listBtpDestinationsMock = listBtpDestinations as jest.Mock; const getYamlContentMock = getYamlContent as jest.Mock; -const mockProjectPath = '/path/to/project'; +const mockProjectPath = join('path', 'to', 'project'); const mockMtaYaml = { ID: 'test-project', @@ -75,7 +76,7 @@ describe('getDestinations', () => { const result = await getDestinations(mockProjectPath); - expect(getYamlContentMock).toHaveBeenCalledWith('/path/to/mta.yaml'); + expect(getYamlContentMock).toHaveBeenCalledWith(join(dirname(mockProjectPath), 'mta.yaml')); expect(getOrCreateServiceInstanceKeysMock).toHaveBeenCalledWith({ names: ['test-project-destination'] }); expect(listBtpDestinationsMock).toHaveBeenCalledWith(mockCredentials); expect(result).toBe(mockDestinations); diff --git a/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts b/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts index 4f91c439b50..4bf90ee72fb 100644 --- a/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts +++ b/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts @@ -1,3 +1,4 @@ +import { join } from 'node:path'; import type { Editor } from 'mem-fs-editor'; import { @@ -52,7 +53,7 @@ const findChangeWithInboundIdMock = findChangeWithInboundId as jest.Mock; const writeChangeToFileMock = writeChangeToFile as jest.Mock; const addConnectivityServiceToMtaMock = addConnectivityServiceToMta as jest.Mock; -const mockProjectPath = '/mock/project/path'; +const mockProjectPath = join('mock', 'project', 'path'); const mockTemplatePath = '/mock/template/path'; describe('AnnotationsWriter', () => { @@ -414,8 +415,8 @@ describe('NewModelWriter', () => { await writer.write(mockData); - expect(readJSONMock).toHaveBeenCalledWith(`${mockProjectPath}/webapp/xs-app.json`, { routes: [] }); - expect(writeJSONMock).toHaveBeenCalledWith(`${mockProjectPath}/webapp/xs-app.json`, { + expect(readJSONMock).toHaveBeenCalledWith(join(mockProjectPath, 'webapp', 'xs-app.json'), { routes: [] }); + expect(writeJSONMock).toHaveBeenCalledWith(join(mockProjectPath, 'webapp', 'xs-app.json'), { routes: [ { source: '^/customer/MyService/sap/opu/odata/v4/(.*)', @@ -446,7 +447,7 @@ describe('NewModelWriter', () => { await writer.write(mockData); - expect(writeJSONMock).toHaveBeenCalledWith(`${mockProjectPath}/webapp/xs-app.json`, { + expect(writeJSONMock).toHaveBeenCalledWith(join(mockProjectPath, 'webapp', 'xs-app.json'), { routes: [ { source: '^existing/route/(.*)', target: '/existing/$1', destination: 'OTHER_DEST' }, { @@ -493,7 +494,7 @@ describe('NewModelWriter', () => { await writer.write(mockData); - expect(addConnectivityServiceToMtaMock).toHaveBeenCalledWith('/mock/project', expect.any(Object)); + expect(addConnectivityServiceToMtaMock).toHaveBeenCalledWith(join('mock', 'project'), expect.any(Object)); }); it('should not call addConnectivityServiceToMta when not in CF', async () => { @@ -607,7 +608,7 @@ describe('DataSourceWriter', () => { }); describe('InboundWriter', () => { - const mockProjectPath = '/mock/project/path'; + const mockProjectPath = join('mock', 'project', 'path'); let writer: InboundWriter; beforeEach(() => { @@ -654,7 +655,7 @@ describe('InboundWriter', () => { await writer.write(mockData as InboundData); expect(writeChangeToFileMock).toHaveBeenCalledWith( - '/mock/project/path/webapp/changes/manifest/inboundChange.change', + join(mockProjectPath, 'webapp', 'changes', 'manifest', 'inboundChange.change'), expect.objectContaining({ content: expect.objectContaining({ inboundId: 'testInboundId' }) }), {} ); From f984760f50a0f8775dd0ba2374e80844fd8b78ed Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Wed, 8 Apr 2026 09:43:25 +0300 Subject: [PATCH 10/31] test: fix tests for windows --- .../adp-tooling/test/unit/writer/changes/writers/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts b/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts index 4bf90ee72fb..26b96e5556f 100644 --- a/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts +++ b/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts @@ -649,7 +649,7 @@ describe('InboundWriter', () => { const existingChangeContent = { inboundId: 'testInboundId', entityPropertyChange: [] }; findChangeWithInboundIdMock.mockResolvedValue({ changeWithInboundId: { content: existingChangeContent }, - filePath: `${mockProjectPath}/webapp/changes/manifest/inboundChange.change` + filePath: join(mockProjectPath, 'webapp', 'changes', 'manifest', 'inboundChange.change') }); await writer.write(mockData as InboundData); From 1e569e2206f331f687f32646a84a2d12cd21223d Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Wed, 8 Apr 2026 10:44:44 +0300 Subject: [PATCH 11/31] test: fix api tests file format --- .../adp-tooling/test/unit/btp/api.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename "packages/adp-tooling/test/unit/btp/api.test.ts\342\200\216" => packages/adp-tooling/test/unit/btp/api.test.ts (99%) diff --git "a/packages/adp-tooling/test/unit/btp/api.test.ts\342\200\216" b/packages/adp-tooling/test/unit/btp/api.test.ts similarity index 99% rename from "packages/adp-tooling/test/unit/btp/api.test.ts\342\200\216" rename to packages/adp-tooling/test/unit/btp/api.test.ts index add5115687b..7f3b6552f5c 100644 --- "a/packages/adp-tooling/test/unit/btp/api.test.ts\342\200\216" +++ b/packages/adp-tooling/test/unit/btp/api.test.ts @@ -125,4 +125,4 @@ describe('btp/api', () => { ); }); }); -}); \ No newline at end of file +}); From fb18f4d8dbb2a06d765d9b117c7451fffd2e4f1d Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Wed, 8 Apr 2026 11:02:03 +0300 Subject: [PATCH 12/31] refactor: move btp related api call to /btp --- packages/adp-tooling/src/btp/api.ts | 37 +++++++- packages/adp-tooling/src/cf/services/api.ts | 38 -------- .../src/cf/services/destinations.ts | 3 +- .../adp-tooling/test/unit/btp/api.test.ts | 95 ++++++++++++++++++- .../test/unit/cf/services/api.test.ts | 94 ------------------ .../unit/cf/services/destinations.test.ts | 8 +- 6 files changed, 138 insertions(+), 137 deletions(-) diff --git a/packages/adp-tooling/src/btp/api.ts b/packages/adp-tooling/src/btp/api.ts index 4713e4e4571..ca1c6fa78d4 100644 --- a/packages/adp-tooling/src/btp/api.ts +++ b/packages/adp-tooling/src/btp/api.ts @@ -1,9 +1,10 @@ import axios from 'axios'; import type { ToolsLogger } from '@sap-ux/logger'; +import type { Destinations } from '@sap-ux/btp-utils'; import { t } from '../i18n'; -import type { Uaa, BtpDestinationConfig } from '../types'; +import type { Uaa, BtpDestinationConfig, CfDestinationServiceCredentials } from '../types'; /** * Obtain an OAuth2 access token using the client credentials grant. @@ -63,3 +64,37 @@ export async function getBtpDestinationConfig( return undefined; } } + +/** + * Lists all subaccount destinations from the BTP Destination Configuration API. + * + * @param {CfDestinationServiceCredentials} credentials - Destination service credentials. + * @returns {Promise} Map of destination name to Destination object. + */ +export async function listBtpDestinations(credentials: CfDestinationServiceCredentials): Promise { + const uaa = + 'uaa' in credentials + ? credentials.uaa + : { clientid: credentials.clientid, clientsecret: credentials.clientsecret, url: credentials.url }; + const token = await getToken(uaa); + const url = `${credentials.uri}/destination-configuration/v1/subaccountDestinations`; + try { + const response = await axios.get(url, { + headers: { Authorization: `Bearer ${token}` } + }); + const configs = Array.isArray(response.data) ? response.data : []; + return configs.reduce((acc, config) => { + acc[config.Name] = { + Name: config.Name, + Host: config.URL, + Type: config.Type, + Authentication: config.Authentication, + ProxyType: config.ProxyType, + Description: config.Description ?? '' + }; + return acc; + }, {}); + } catch (e) { + throw new Error(t('error.failedToListBtpDestinations', { error: e.message })); + } +} diff --git a/packages/adp-tooling/src/cf/services/api.ts b/packages/adp-tooling/src/cf/services/api.ts index 03881a2cb0f..4d6c5892c63 100644 --- a/packages/adp-tooling/src/cf/services/api.ts +++ b/packages/adp-tooling/src/cf/services/api.ts @@ -21,15 +21,11 @@ import type { MtaYaml, ServiceInfo, CfUi5AppInfo, - CfDestinationServiceCredentials, - BtpDestinationConfig, ServiceKeyCredentialsWithTags } from '../../types'; -import type { Destinations } from '@sap-ux/btp-utils'; import { t } from '../../i18n'; import { getProjectNameForXsSecurity } from '../project'; import { createServiceKey, getServiceKeys, requestCfApi } from './cli'; -import { getToken } from '../../btp/api'; interface FDCResponse { results: CFApp[]; @@ -443,40 +439,6 @@ export async function getOrCreateServiceKeys( } } -/** - * Lists all subaccount destinations from the BTP Destination Configuration API. - * - * @param {CfDestinationServiceCredentials} credentials - Destination service credentials. - * @returns {Promise} Map of destination name to Destination object. - */ -export async function listBtpDestinations(credentials: CfDestinationServiceCredentials): Promise { - const uaa = - 'uaa' in credentials - ? credentials.uaa - : { clientid: credentials.clientid, clientsecret: credentials.clientsecret, url: credentials.url }; - const token = await getToken(uaa); - const url = `${credentials.uri}/destination-configuration/v1/subaccountDestinations`; - try { - const response = await axios.get(url, { - headers: { Authorization: `Bearer ${token}` } - }); - const configs = Array.isArray(response.data) ? response.data : []; - return configs.reduce((acc, config) => { - acc[config.Name] = { - Name: config.Name, - Host: config.URL, - Type: config.Type, - Authentication: config.Authentication, - ProxyType: config.ProxyType, - Description: config.Description ?? '' - }; - return acc; - }, {}); - } catch (e) { - throw new Error(t('error.failedToListBtpDestinations', { error: e.message })); - } -} - /** * Gets service tags for a given service name. * diff --git a/packages/adp-tooling/src/cf/services/destinations.ts b/packages/adp-tooling/src/cf/services/destinations.ts index 48be35f3b3d..e8ba4bbbcd5 100644 --- a/packages/adp-tooling/src/cf/services/destinations.ts +++ b/packages/adp-tooling/src/cf/services/destinations.ts @@ -2,7 +2,8 @@ import * as path from 'node:path'; import type { Destinations } from '@sap-ux/btp-utils'; -import { getOrCreateServiceInstanceKeys, listBtpDestinations } from './api'; +import { getOrCreateServiceInstanceKeys } from './api'; +import { listBtpDestinations } from '../../btp/api'; import { getYamlContent } from '../project/yaml-loader'; import { t } from '../../i18n'; import type { CfDestinationServiceCredentials, MtaYaml } from '../../types'; diff --git a/packages/adp-tooling/test/unit/btp/api.test.ts b/packages/adp-tooling/test/unit/btp/api.test.ts index 7f3b6552f5c..3fe43278649 100644 --- a/packages/adp-tooling/test/unit/btp/api.test.ts +++ b/packages/adp-tooling/test/unit/btp/api.test.ts @@ -2,7 +2,7 @@ import axios from 'axios'; import type { ToolsLogger } from '@sap-ux/logger'; -import { getToken, getBtpDestinationConfig } from '../../../src/btp/api'; +import { getToken, getBtpDestinationConfig, listBtpDestinations } from '../../../src/btp/api'; import { initI18n, t } from '../../../src/i18n'; import type { Uaa } from '../../../src/types'; @@ -125,4 +125,97 @@ describe('btp/api', () => { ); }); }); + + describe('listBtpDestinations', () => { + const mockCredentials = { + uri: 'https://destination.cfapps.example.com', + uaa: { clientid: 'client-id', clientsecret: 'client-secret', url: 'https://auth.example.com' } + }; + + const mockBtpConfigs = [ + { + Name: 'DEST_ONE', + Type: 'HTTP', + URL: 'https://one.example.com', + Authentication: 'NoAuthentication', + ProxyType: 'Internet', + Description: 'First dest' + }, + { + Name: 'DEST_TWO', + Type: 'HTTP', + URL: 'https://two.example.com', + Authentication: 'BasicAuthentication', + ProxyType: 'OnPremise' + } + ]; + + beforeEach(() => { + mockAxios.post.mockResolvedValueOnce({ data: { access_token: 'mock-token' } }); + }); + + it('should return a Destinations map built from the BTP API response', async () => { + mockAxios.get.mockResolvedValueOnce({ data: mockBtpConfigs }); + + const result = await listBtpDestinations(mockCredentials); + + expect(result).toEqual({ + DEST_ONE: { + Name: 'DEST_ONE', + Host: 'https://one.example.com', + Type: 'HTTP', + Authentication: 'NoAuthentication', + ProxyType: 'Internet', + Description: 'First dest' + }, + DEST_TWO: { + Name: 'DEST_TWO', + Host: 'https://two.example.com', + Type: 'HTTP', + Authentication: 'BasicAuthentication', + ProxyType: 'OnPremise', + Description: '' + } + }); + }); + + it('should handle flat credentials (no nested uaa object)', async () => { + const flatCredentials = { + uri: 'https://destination.cfapps.example.com', + clientid: 'client-id', + clientsecret: 'client-secret', + url: 'https://auth.example.com' + }; + mockAxios.get.mockResolvedValueOnce({ data: mockBtpConfigs }); + + const result = await listBtpDestinations(flatCredentials); + + expect(result).toEqual({ + DEST_ONE: { + Name: 'DEST_ONE', + Host: 'https://one.example.com', + Type: 'HTTP', + Authentication: 'NoAuthentication', + ProxyType: 'Internet', + Description: 'First dest' + }, + DEST_TWO: { + Name: 'DEST_TWO', + Host: 'https://two.example.com', + Type: 'HTTP', + Authentication: 'BasicAuthentication', + ProxyType: 'OnPremise', + Description: '' + } + }); + }); + + it('should throw when the BTP destination API call fails', async () => { + mockAxios.get.mockRejectedValueOnce(new Error('Network error')); + + await expect(listBtpDestinations(mockCredentials)).rejects.toThrow( + t('error.failedToListBtpDestinations', { error: 'Network error' }) + ); + }); + }); }); diff --git a/packages/adp-tooling/test/unit/cf/services/api.test.ts b/packages/adp-tooling/test/unit/cf/services/api.test.ts index 067cf768c8a..a9be931e722 100644 --- a/packages/adp-tooling/test/unit/cf/services/api.test.ts +++ b/packages/adp-tooling/test/unit/cf/services/api.test.ts @@ -15,7 +15,6 @@ import { getServiceNameByTags, createServices, getOrCreateServiceInstanceKeys, - listBtpDestinations, getServiceTags, getServiceKeyCredentialsWithTags } from '../../../../src/cf/services/api'; @@ -968,99 +967,6 @@ describe('CF Services API', () => { }); }); - describe('listBtpDestinations', () => { - const mockCredentials = { - uri: 'https://destination.cfapps.example.com', - uaa: { clientid: 'client-id', clientsecret: 'client-secret', url: 'https://auth.example.com' } - }; - - const mockBtpConfigs = [ - { - Name: 'DEST_ONE', - Type: 'HTTP', - URL: 'https://one.example.com', - Authentication: 'NoAuthentication', - ProxyType: 'Internet', - Description: 'First dest' - }, - { - Name: 'DEST_TWO', - Type: 'HTTP', - URL: 'https://two.example.com', - Authentication: 'BasicAuthentication', - ProxyType: 'OnPremise' - } - ]; - - beforeEach(() => { - jest.spyOn(axios, 'post').mockResolvedValueOnce({ data: { access_token: 'mock-token' } }); - }); - - it('should return a Destinations map built from the BTP API response', async () => { - jest.spyOn(axios, 'get').mockResolvedValueOnce({ data: mockBtpConfigs }); - - const result = await listBtpDestinations(mockCredentials); - - expect(result).toEqual({ - DEST_ONE: { - Name: 'DEST_ONE', - Host: 'https://one.example.com', - Type: 'HTTP', - Authentication: 'NoAuthentication', - ProxyType: 'Internet', - Description: 'First dest' - }, - DEST_TWO: { - Name: 'DEST_TWO', - Host: 'https://two.example.com', - Type: 'HTTP', - Authentication: 'BasicAuthentication', - ProxyType: 'OnPremise', - Description: '' - } - }); - }); - - it('should handle flat credentials (no nested uaa object)', async () => { - const flatCredentials = { - uri: 'https://destination.cfapps.example.com', - clientid: 'client-id', - clientsecret: 'client-secret', - url: 'https://auth.example.com' - }; - jest.spyOn(axios, 'get').mockResolvedValueOnce({ data: mockBtpConfigs }); - - const result = await listBtpDestinations(flatCredentials); - - expect(result).toEqual({ - DEST_ONE: { - Name: 'DEST_ONE', - Host: 'https://one.example.com', - Type: 'HTTP', - Authentication: 'NoAuthentication', - ProxyType: 'Internet', - Description: 'First dest' - }, - DEST_TWO: { - Name: 'DEST_TWO', - Host: 'https://two.example.com', - Type: 'HTTP', - Authentication: 'BasicAuthentication', - ProxyType: 'OnPremise', - Description: '' - } - }); - }); - - it('should throw when the BTP destination API call fails', async () => { - jest.spyOn(axios, 'get').mockRejectedValueOnce(new Error('Network error')); - - await expect(listBtpDestinations(mockCredentials)).rejects.toThrow( - t('error.failedToListBtpDestinations', { error: 'Network error' }) - ); - }); - }); - describe('getServiceTags', () => { const spaceGuid = 'space-guid-123'; diff --git a/packages/adp-tooling/test/unit/cf/services/destinations.test.ts b/packages/adp-tooling/test/unit/cf/services/destinations.test.ts index 25809c1d153..0d48a2bb1d6 100644 --- a/packages/adp-tooling/test/unit/cf/services/destinations.test.ts +++ b/packages/adp-tooling/test/unit/cf/services/destinations.test.ts @@ -1,13 +1,17 @@ import { join, dirname } from 'node:path'; import { getDestinations } from '../../../../src/cf/services/destinations'; -import { getOrCreateServiceInstanceKeys, listBtpDestinations } from '../../../../src/cf/services/api'; +import { getOrCreateServiceInstanceKeys } from '../../../../src/cf/services/api'; +import { listBtpDestinations } from '../../../../src/btp/api'; import { getYamlContent } from '../../../../src/cf/project/yaml-loader'; import { initI18n, t } from '../../../../src/i18n'; jest.mock('@sap-ux/btp-utils'); jest.mock('../../../../src/cf/services/api', () => ({ - getOrCreateServiceInstanceKeys: jest.fn(), + getOrCreateServiceInstanceKeys: jest.fn() +})); + +jest.mock('../../../../src/btp/api', () => ({ listBtpDestinations: jest.fn() })); From 236a9c4549a2308f73d4c513de06ec617e56019b Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Wed, 8 Apr 2026 11:43:36 +0300 Subject: [PATCH 13/31] fix: sonar issues --- packages/adp-tooling/src/prompts/add-new-model/index.ts | 7 +++---- .../src/writer/changes/writers/new-model-writer.ts | 4 ++-- packages/generator-adp/src/add-new-model/index.ts | 6 +++--- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/adp-tooling/src/prompts/add-new-model/index.ts b/packages/adp-tooling/src/prompts/add-new-model/index.ts index a3eb6bc1959..4139e97c12b 100644 --- a/packages/adp-tooling/src/prompts/add-new-model/index.ts +++ b/packages/adp-tooling/src/prompts/add-new-model/index.ts @@ -23,7 +23,6 @@ import { ServiceType, type NewModelAnswers, type ManifestChangeProperties, - type AdpPreviewConfigWithTarget, FlexLayer, type XsApp, type XsAppRoute @@ -221,11 +220,11 @@ async function getAbapServiceUrl(projectPath: string): Promise { const { service, isCloudFoundry, serviceType } = data; const isHttp = serviceType === ServiceType.HTTP; - const uri = isCloudFoundry ? `/${service.name.replace(/\./g, '/')}${service.uri}` : service.uri; + const uri = isCloudFoundry ? `/${service.name.replaceAll('.', '/')}${service.uri}` : service.uri; const dataSourceEntry: DataSourceItem = { uri, @@ -121,7 +121,7 @@ export class NewModelWriter implements IWriter { */ private writeXsAppRoute(data: NewModelData): void { const xsAppPath = join(this.projectPath, 'webapp', 'xs-app.json'); - const source = `^/${data.service.name.replace(/\./g, '/')}${data.service.uri}(.*)`; + const source = `^/${data.service.name.replaceAll('.', '/')}${data.service.uri}(.*)`; const newRoute = { source, target: `${data.service.uri}$1`, diff --git a/packages/generator-adp/src/add-new-model/index.ts b/packages/generator-adp/src/add-new-model/index.ts index 58d60629278..eeac1aa96d1 100644 --- a/packages/generator-adp/src/add-new-model/index.ts +++ b/packages/generator-adp/src/add-new-model/index.ts @@ -121,13 +121,13 @@ class AddNewModelGenerator extends SubGeneratorBase { serviceType, isCloudFoundry, destinationName: isCloudFoundry ? this.answers.destination?.Name : undefined, - ...(isCloudFoundry && { - isOnPremiseDestination: isOnPremiseDestination(this.answers.destination!) + ...(isCloudFoundry && this.answers.destination && { + isOnPremiseDestination: isOnPremiseDestination(this.answers.destination) }), service: { name: modelAndDatasourceName, uri, - modelName: serviceType !== ServiceType.HTTP ? modelAndDatasourceName : undefined, + modelName: serviceType === ServiceType.HTTP ? undefined : modelAndDatasourceName, version: getODataVersionFromServiceType(serviceType), modelSettings }, From 21be925d5a9c4d850d1b8655390679264e169d6c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 8 Apr 2026 08:54:36 +0000 Subject: [PATCH 14/31] Linting auto fix commit --- packages/generator-adp/src/add-new-model/index.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/generator-adp/src/add-new-model/index.ts b/packages/generator-adp/src/add-new-model/index.ts index eeac1aa96d1..45acf6dd1f2 100644 --- a/packages/generator-adp/src/add-new-model/index.ts +++ b/packages/generator-adp/src/add-new-model/index.ts @@ -121,9 +121,10 @@ class AddNewModelGenerator extends SubGeneratorBase { serviceType, isCloudFoundry, destinationName: isCloudFoundry ? this.answers.destination?.Name : undefined, - ...(isCloudFoundry && this.answers.destination && { - isOnPremiseDestination: isOnPremiseDestination(this.answers.destination) - }), + ...(isCloudFoundry && + this.answers.destination && { + isOnPremiseDestination: isOnPremiseDestination(this.answers.destination) + }), service: { name: modelAndDatasourceName, uri, From 53a3786ebb3cb17e98858766aaf40f41c31ae633 Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Wed, 8 Apr 2026 14:36:38 +0300 Subject: [PATCH 15/31] fix: address comments --- .../src/cf/services/destinations.ts | 2 +- .../src/prompts/add-new-model/index.ts | 52 +++++++- packages/adp-tooling/src/prompts/index.ts | 6 +- packages/adp-tooling/src/types.ts | 3 + .../src/writer/changes/writer-factory.ts | 9 +- .../changes/writers/new-model-writer.ts | 6 +- packages/adp-tooling/src/writer/editors.ts | 7 +- .../unit/cf/services/destinations.test.ts | 14 +- .../unit/prompts/add-new-model/index.test.ts | 117 +++++++++++++++- .../test/unit/writer/editors.test.ts | 4 +- packages/create/src/cli/add/new-model.ts | 50 +------ .../test/unit/cli/add/new-model.test.ts | 126 +++--------------- .../generator-adp/src/add-new-model/index.ts | 47 +------ .../test/unit/add-new-model/index.test.ts | 55 +++----- 14 files changed, 225 insertions(+), 273 deletions(-) diff --git a/packages/adp-tooling/src/cf/services/destinations.ts b/packages/adp-tooling/src/cf/services/destinations.ts index e8ba4bbbcd5..58c23c9b98d 100644 --- a/packages/adp-tooling/src/cf/services/destinations.ts +++ b/packages/adp-tooling/src/cf/services/destinations.ts @@ -36,7 +36,7 @@ function getDestinationServiceName(projectPath: string): string { * @param {string} projectPath - The root path of the CF app project. * @returns {Promise} Map of destination name to Destination object. */ -export async function getDestinations(projectPath: string): Promise { +export async function getBtpDestinations(projectPath: string): Promise { const destinationServiceName = getDestinationServiceName(projectPath); const serviceInfo = await getOrCreateServiceInstanceKeys({ names: [destinationServiceName] }); diff --git a/packages/adp-tooling/src/prompts/add-new-model/index.ts b/packages/adp-tooling/src/prompts/add-new-model/index.ts index 4139e97c12b..0c4eea238b3 100644 --- a/packages/adp-tooling/src/prompts/add-new-model/index.ts +++ b/packages/adp-tooling/src/prompts/add-new-model/index.ts @@ -10,18 +10,20 @@ import type { } from '@sap-ux/inquirer-common'; import type { UI5FlexLayer } from '@sap-ux/project-access'; import type { Destination } from '@sap-ux/btp-utils'; -import { listDestinations } from '@sap-ux/btp-utils'; +import { listDestinations, isOnPremiseDestination } from '@sap-ux/btp-utils'; import { Severity, type IMessageSeverity } from '@sap-devx/yeoman-ui-types'; import type { ToolsLogger } from '@sap-ux/logger'; import { t } from '../../i18n'; import { getChangesByType } from '../../base/change-utils'; -import { getDestinations } from '../../cf/services/destinations'; +import { getBtpDestinations } from '../../cf/services/destinations'; import { ChangeType, NamespacePrefix, ServiceType, + type DescriptorVariant, type NewModelAnswers, + type NewModelData, type ManifestChangeProperties, FlexLayer, type XsApp, @@ -248,7 +250,7 @@ async function getDestinationChoices( logger?: ToolsLogger ): Promise<{ choices: { name: string; value: Destination }[]; error?: string }> { try { - const destinations = await getDestinations(projectPath); + const destinations = await getBtpDestinations(projectPath); const choices = Object.entries(destinations).map(([name, dest]) => ({ name, value: dest as Destination @@ -416,3 +418,47 @@ export async function getPrompts( } as EditorQuestion ]; } + +/** + * Builds the NewModelData object from the prompts answers. + * + * @param {string} projectPath - The root path of the project. + * @param {DescriptorVariant} variant - The descriptor variant of the adaptation project. + * @param {NewModelAnswers} answers - The answers to the prompts. + * @param {ToolsLogger} [logger] - Optional logger instance. + * @returns {Promise} The data required by NewModelWriter. + */ +export async function createNewModelData( + projectPath: string, + variant: DescriptorVariant, + answers: NewModelAnswers, + logger?: ToolsLogger +): Promise { + const { modelAndDatasourceName, uri, serviceType, modelSettings, addAnnotationMode } = answers; + const isCloudFoundry = await isCFEnvironment(projectPath); + return { + variant, + serviceType, + isCloudFoundry, + destinationName: isCloudFoundry ? answers.destination?.Name : undefined, + ...(isCloudFoundry && + answers.destination && { + isOnPremiseDestination: isOnPremiseDestination(answers.destination) + }), + logger, + service: { + name: modelAndDatasourceName, + uri, + modelName: serviceType !== ServiceType.HTTP ? modelAndDatasourceName : undefined, + version: getODataVersionFromServiceType(serviceType), + modelSettings + }, + ...(addAnnotationMode && { + annotation: { + dataSourceName: `${modelAndDatasourceName}.annotation`, + dataSourceURI: answers.dataSourceURI, + settings: answers.annotationSettings + } + }) + }; +} diff --git a/packages/adp-tooling/src/prompts/index.ts b/packages/adp-tooling/src/prompts/index.ts index bbbd28dba33..a758465d48a 100644 --- a/packages/adp-tooling/src/prompts/index.ts +++ b/packages/adp-tooling/src/prompts/index.ts @@ -1,5 +1,9 @@ export { getPrompts as getPromptsForChangeDataSource } from './change-data-source'; export { getPrompts as getPromptsForAddComponentUsages } from './add-component-usages'; -export { getPrompts as getPromptsForNewModel, getODataVersionFromServiceType } from './add-new-model'; +export { + getPrompts as getPromptsForNewModel, + getODataVersionFromServiceType, + createNewModelData +} from './add-new-model'; export { getPrompts as getPromptsForChangeInbound } from './change-inbound'; export { getPrompts as getPromptsForAddAnnotationsToOData } from './add-annotations-to-odata'; diff --git a/packages/adp-tooling/src/types.ts b/packages/adp-tooling/src/types.ts index 85884ac5f6a..ff9731a6add 100644 --- a/packages/adp-tooling/src/types.ts +++ b/packages/adp-tooling/src/types.ts @@ -11,6 +11,7 @@ import type { Editor } from 'mem-fs-editor'; import type { Destination } from '@sap-ux/btp-utils'; import type { YUIQuestion } from '@sap-ux/inquirer-common'; import type AdmZip from 'adm-zip'; +import type { ToolsLogger } from '@sap-ux/logger'; import type { SupportedProject } from './source'; export type DataSources = Record; @@ -664,6 +665,8 @@ export interface NewModelDataBase { destinationName?: string; /** True when the selected CF destination is OnPremise (ProxyType: 'OnPremise'). Triggers connectivity service in mta.yaml. */ isOnPremiseDestination?: boolean; + /** Optional logger passed to the writer for use during the write operation. */ + logger?: ToolsLogger; service: { /** Name of the OData service. */ name: string; diff --git a/packages/adp-tooling/src/writer/changes/writer-factory.ts b/packages/adp-tooling/src/writer/changes/writer-factory.ts index b8194473261..7f7616dec02 100644 --- a/packages/adp-tooling/src/writer/changes/writer-factory.ts +++ b/packages/adp-tooling/src/writer/changes/writer-factory.ts @@ -1,5 +1,4 @@ import type { Editor } from 'mem-fs-editor'; -import type { ToolsLogger } from '@sap-ux/logger'; import { ChangeType } from '../../types'; import type { Writer, IWriterData } from '../../types'; @@ -25,7 +24,6 @@ export class WriterFactory { * @param fs - The filesystem editor instance. * @param projectPath - The path to the project for which the writer is created. * @param templatesPath - The path to the templates used for generating changes. - * @param logger - Optional logger instance passed to writers that support it. * @returns An instance of the writer associated with the specified generator type. * @throws If the specified generator type is not supported. */ @@ -33,8 +31,7 @@ export class WriterFactory { type: T, fs: Editor, projectPath: string, - templatesPath?: string, - logger?: ToolsLogger + templatesPath?: string ): IWriterData { const WriterClass = this.writers.get(type); if (!WriterClass) { @@ -45,10 +42,6 @@ export class WriterFactory { return new (WriterClass as typeof AnnotationsWriter)(fs, projectPath, templatesPath) as IWriterData; } - if (type === ChangeType.ADD_NEW_MODEL) { - return new (WriterClass as typeof NewModelWriter)(fs, projectPath, logger) as IWriterData; - } - return new WriterClass(fs, projectPath); } } diff --git a/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts b/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts index cd9ddb2356e..138f4bdb6fc 100644 --- a/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts +++ b/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts @@ -28,12 +28,10 @@ export class NewModelWriter implements IWriter { /** * @param {Editor} fs - The filesystem editor instance. * @param {string} projectPath - The root path of the project. - * @param {ToolsLogger} [logger] - Optional logger instance. */ constructor( private readonly fs: Editor, - private readonly projectPath: string, - private readonly logger?: ToolsLogger + private readonly projectPath: string ) {} /** @@ -109,7 +107,7 @@ export class NewModelWriter implements IWriter { if (data.isOnPremiseDestination) { await addConnectivityServiceToMta(dirname(this.projectPath), this.fs); - await ensureTunnelAppExists(DEFAULT_TUNNEL_APP_NAME, this.logger ?? new ToolsLogger()); + await ensureTunnelAppExists(DEFAULT_TUNNEL_APP_NAME, data.logger ?? new ToolsLogger()); } } diff --git a/packages/adp-tooling/src/writer/editors.ts b/packages/adp-tooling/src/writer/editors.ts index 5acfd11ad8d..2f330d30212 100644 --- a/packages/adp-tooling/src/writer/editors.ts +++ b/packages/adp-tooling/src/writer/editors.ts @@ -1,6 +1,5 @@ import { create as createStorage } from 'mem-fs'; import { create, type Editor } from 'mem-fs-editor'; -import type { ToolsLogger } from '@sap-ux/logger'; import type { GeneratorData, ChangeType } from '../types'; import { WriterFactory } from './changes/writer-factory'; @@ -17,7 +16,6 @@ import { WriterFactory } from './changes/writer-factory'; * @param {GeneratorData} data - The data specific to the type of generator, containing information necessary for making changes. * @param {Editor | null} [fs] - The `mem-fs-editor` instance used for file operations. * @param {string} templatesPath - The path to the templates used for generating changes. - * @param {ToolsLogger} [logger] - Optional logger instance passed to the writer. * @returns {Promise} A promise that resolves to the mem-fs editor instance used for making changes, allowing for further operations or committing changes to disk. * @template T - A type parameter extending `ChangeType`, ensuring the function handles a defined set of generator types. */ @@ -26,14 +24,13 @@ export async function generateChange( type: T, data: GeneratorData, fs: Editor | null = null, - templatesPath?: string, - logger?: ToolsLogger + templatesPath?: string ): Promise { if (!fs) { fs = create(createStorage()); } - const writer = WriterFactory.createWriter(type, fs, projectPath, templatesPath, logger); + const writer = WriterFactory.createWriter(type, fs, projectPath, templatesPath); await writer.write(data); diff --git a/packages/adp-tooling/test/unit/cf/services/destinations.test.ts b/packages/adp-tooling/test/unit/cf/services/destinations.test.ts index 0d48a2bb1d6..f38f73b487c 100644 --- a/packages/adp-tooling/test/unit/cf/services/destinations.test.ts +++ b/packages/adp-tooling/test/unit/cf/services/destinations.test.ts @@ -1,5 +1,5 @@ import { join, dirname } from 'node:path'; -import { getDestinations } from '../../../../src/cf/services/destinations'; +import { getBtpDestinations } from '../../../../src/cf/services/destinations'; import { getOrCreateServiceInstanceKeys } from '../../../../src/cf/services/api'; import { listBtpDestinations } from '../../../../src/btp/api'; import { getYamlContent } from '../../../../src/cf/project/yaml-loader'; @@ -64,7 +64,7 @@ const mockServiceInfo = { serviceInstance: { name: 'test-project-destination', guid: 'some-guid' } }; -describe('getDestinations', () => { +describe('getBtpDestinations', () => { beforeAll(async () => { await initI18n(); }); @@ -78,7 +78,7 @@ describe('getDestinations', () => { getOrCreateServiceInstanceKeysMock.mockResolvedValue(mockServiceInfo); listBtpDestinationsMock.mockResolvedValue(mockDestinations); - const result = await getDestinations(mockProjectPath); + const result = await getBtpDestinations(mockProjectPath); expect(getYamlContentMock).toHaveBeenCalledWith(join(dirname(mockProjectPath), 'mta.yaml')); expect(getOrCreateServiceInstanceKeysMock).toHaveBeenCalledWith({ names: ['test-project-destination'] }); @@ -98,7 +98,7 @@ describe('getDestinations', () => { ] }); - await expect(getDestinations(mockProjectPath)).rejects.toThrow(t('error.destinationServiceNotFoundInMtaYaml')); + await expect(getBtpDestinations(mockProjectPath)).rejects.toThrow(t('error.destinationServiceNotFoundInMtaYaml')); expect(getOrCreateServiceInstanceKeysMock).not.toHaveBeenCalled(); }); @@ -108,7 +108,7 @@ describe('getDestinations', () => { throw new Error('File not found'); }); - await expect(getDestinations(mockProjectPath)).rejects.toThrow('File not found'); + await expect(getBtpDestinations(mockProjectPath)).rejects.toThrow('File not found'); expect(getOrCreateServiceInstanceKeysMock).not.toHaveBeenCalled(); }); @@ -120,7 +120,7 @@ describe('getDestinations', () => { serviceInstance: { name: 'test-project-destination', guid: 'some-guid' } }); - await expect(getDestinations(mockProjectPath)).rejects.toThrow( + await expect(getBtpDestinations(mockProjectPath)).rejects.toThrow( t('error.noServiceKeysFoundForDestination', { serviceInstanceName: 'test-project-destination' }) ); @@ -131,7 +131,7 @@ describe('getDestinations', () => { getYamlContentMock.mockReturnValue(mockMtaYaml); getOrCreateServiceInstanceKeysMock.mockResolvedValue(null); - await expect(getDestinations(mockProjectPath)).rejects.toThrow( + await expect(getBtpDestinations(mockProjectPath)).rejects.toThrow( t('error.noServiceKeysFoundForDestination', { serviceInstanceName: 'test-project-destination' }) ); diff --git a/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts b/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts index 139fa5458ba..a45c9891d82 100644 --- a/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts +++ b/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts @@ -1,12 +1,13 @@ import * as i18n from '../../../../src/i18n'; -import type { NewModelAnswers } from '../../../../src'; +import type { NewModelAnswers, DescriptorVariant } from '../../../../src'; +import type { NewModelDataWithAnnotations } from '../../../../src/types'; import { isCFEnvironment } from '../../../../src/base/cf'; import { getAdpConfig } from '../../../../src/base/helper'; -import { getPrompts } from '../../../../src/prompts/add-new-model'; +import { getPrompts, createNewModelData } from '../../../../src/prompts/add-new-model'; import * as validators from '@sap-ux/project-input-validator'; import { getChangesByType } from '../../../../src/base/change-utils'; -import { listDestinations } from '@sap-ux/btp-utils'; -import { getDestinations } from '../../../../src/cf/services/destinations'; +import { listDestinations, isOnPremiseDestination } from '@sap-ux/btp-utils'; +import { getBtpDestinations } from '../../../../src/cf/services/destinations'; import { Severity } from '@sap-devx/yeoman-ui-types'; import { readFileSync } from 'node:fs'; import type { ToolsLogger } from '@sap-ux/logger'; @@ -15,7 +16,8 @@ const getChangesByTypeMock = getChangesByType as jest.Mock; const isCFEnvironmentMock = isCFEnvironment as jest.Mock; const getAdpConfigMock = getAdpConfig as jest.Mock; const listDestinationsMock = listDestinations as jest.Mock; -const getDestinationsMock = getDestinations as jest.Mock; +const getDestinationsMock = getBtpDestinations as jest.Mock; +const isOnPremiseDestinationMock = isOnPremiseDestination as jest.Mock; const readFileSyncMock = readFileSync as jest.Mock; @@ -35,11 +37,12 @@ jest.mock('../../../../src/base/helper.ts', () => ({ jest.mock('@sap-ux/btp-utils', () => ({ ...jest.requireActual('@sap-ux/btp-utils'), - listDestinations: jest.fn() + listDestinations: jest.fn(), + isOnPremiseDestination: jest.fn() })); jest.mock('../../../../src/cf/services/destinations', () => ({ - getDestinations: jest.fn() + getBtpDestinations: jest.fn() })); jest.mock('node:fs', () => ({ @@ -499,3 +502,103 @@ describe('getPrompts', () => { expect(validate?.(undefined)).toBe(i18n.t('error.errorFetchingDestinations')); }); }); + +describe('createNewModelData', () => { + const mockPath = '/path/to/project'; + const variant = { id: 'my.variant', layer: 'CUSTOMER_BASE' } as unknown as DescriptorVariant; + + beforeAll(async () => { + await i18n.initI18n(); + }); + + beforeEach(() => { + isCFEnvironmentMock.mockResolvedValue(false); + isOnPremiseDestinationMock.mockReturnValue(false); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should build data for a non-CF OData v2 project', async () => { + const logger = { error: jest.fn() } as Partial as ToolsLogger; + const answers = { + modelAndDatasourceName: 'customer.MyService', + uri: '/sap/opu/odata/svc/', + serviceType: 'OData v2' as NewModelAnswers['serviceType'], + modelSettings: '{}', + addAnnotationMode: false + } as NewModelAnswers; + + const result = await createNewModelData(mockPath, variant, answers, logger); + + expect(result.variant).toBe(variant); + expect(result.isCloudFoundry).toBe(false); + expect(result.destinationName).toBeUndefined(); + expect(result.logger).toBe(logger); + expect(result.service).toEqual({ + name: 'customer.MyService', + uri: '/sap/opu/odata/svc/', + modelName: 'customer.MyService', + version: '2.0', + modelSettings: '{}' + }); + }); + + it('should set modelName to undefined for HTTP service type', async () => { + const answers = { + modelAndDatasourceName: 'customer.MyDatasource', + uri: '/sap/opu/odata/svc/', + serviceType: 'HTTP' as NewModelAnswers['serviceType'], + modelSettings: '{}', + addAnnotationMode: false + } as NewModelAnswers; + + const result = await createNewModelData(mockPath, variant, answers); + + expect(result.service.modelName).toBeUndefined(); + expect(result.service.version).toBeUndefined(); + }); + + it('should include annotation when addAnnotationMode is true', async () => { + const answers = { + modelAndDatasourceName: 'customer.MyService', + uri: '/sap/opu/odata/svc/', + serviceType: 'OData v2' as NewModelAnswers['serviceType'], + modelSettings: '{}', + addAnnotationMode: true, + dataSourceURI: '/sap/opu/odata/ann/', + annotationSettings: '"key": "value"' + } as NewModelAnswers; + + const result = (await createNewModelData(mockPath, variant, answers)) as NewModelDataWithAnnotations; + + expect(result.annotation).toEqual({ + dataSourceName: 'customer.MyService.annotation', + dataSourceURI: '/sap/opu/odata/ann/', + settings: '"key": "value"' + }); + }); + + it('should set CF fields and isOnPremiseDestination for CF on-premise projects', async () => { + isCFEnvironmentMock.mockResolvedValue(true); + isOnPremiseDestinationMock.mockReturnValue(true); + + const destination = { Host: 'https://cf.dest.example.com', Name: 'CF_DEST' }; + const answers = { + modelAndDatasourceName: 'customer.MyService', + uri: '/sap/opu/odata/svc/', + serviceType: 'OData v2' as NewModelAnswers['serviceType'], + modelSettings: '{}', + addAnnotationMode: false, + destination + } as unknown as NewModelAnswers; + + const result = await createNewModelData(mockPath, variant, answers); + + expect(result.isCloudFoundry).toBe(true); + expect(result.destinationName).toBe('CF_DEST'); + expect(result.isOnPremiseDestination).toBe(true); + expect(isOnPremiseDestinationMock).toHaveBeenCalledWith(destination); + }); +}); diff --git a/packages/adp-tooling/test/unit/writer/editors.test.ts b/packages/adp-tooling/test/unit/writer/editors.test.ts index fa74753d2ce..742fa0aaa14 100644 --- a/packages/adp-tooling/test/unit/writer/editors.test.ts +++ b/packages/adp-tooling/test/unit/writer/editors.test.ts @@ -33,8 +33,7 @@ describe('generateChange', () => { ChangeType.ADD_ANNOTATIONS_TO_ODATA, expect.anything(), '/path/to/project', - '/path/to/templates', - undefined + '/path/to/templates' ); expect(writeSpy).toHaveBeenCalledWith({ variant: {}, annotation: {} }); @@ -58,7 +57,6 @@ describe('generateChange', () => { ChangeType.ADD_ANNOTATIONS_TO_ODATA, {}, '/path/to/project', - undefined, undefined ); diff --git a/packages/create/src/cli/add/new-model.ts b/packages/create/src/cli/add/new-model.ts index 4d2ffbd7912..54f04226223 100644 --- a/packages/create/src/cli/add/new-model.ts +++ b/packages/create/src/cli/add/new-model.ts @@ -1,16 +1,12 @@ import type { Command } from 'commander'; -import type { DescriptorVariant, NewModelAnswers, NewModelData } from '@sap-ux/adp-tooling'; import { generateChange, ChangeType, getPromptsForNewModel, getVariant, - isCFEnvironment, - getODataVersionFromServiceType, - ServiceType + createNewModelData } from '@sap-ux/adp-tooling'; -import { isOnPremiseDestination } from '@sap-ux/btp-utils'; import { promptYUIQuestions } from '../../common'; import { getLogger, traceChanges } from '../../tracing'; @@ -56,10 +52,7 @@ async function addNewModel(basePath: string, simulate: boolean): Promise { const fs = await generateChange( basePath, ChangeType.ADD_NEW_MODEL, - await createNewModelData(basePath, variant, answers), - null, - undefined, - logger + await createNewModelData(basePath, variant, answers, logger) ); if (!simulate) { @@ -72,42 +65,3 @@ async function addNewModel(basePath: string, simulate: boolean): Promise { logger.debug(error); } } - -/** - * Returns the writer data for the new model change. - * - * @param {string} basePath - The path to the adaptation project. - * @param {DescriptorVariant} variant - The variant of the adaptation project. - * @param {NewModelAnswers} answers - The answers to the prompts. - * @returns {Promise} The writer data for the new model change. - */ -async function createNewModelData( - basePath: string, - variant: DescriptorVariant, - answers: NewModelAnswers -): Promise { - const { modelAndDatasourceName, uri, serviceType, modelSettings, addAnnotationMode } = answers; - const isCloudFoundry = await isCFEnvironment(basePath); - return { - variant, - serviceType, - isCloudFoundry, - destinationName: isCloudFoundry ? answers.destination?.Name : undefined, - isOnPremiseDestination: - isCloudFoundry && answers.destination ? isOnPremiseDestination(answers.destination) : undefined, - service: { - name: modelAndDatasourceName, - uri, - modelName: serviceType !== ServiceType.HTTP ? modelAndDatasourceName : undefined, - version: getODataVersionFromServiceType(serviceType), - modelSettings - }, - ...(addAnnotationMode && { - annotation: { - dataSourceName: `${modelAndDatasourceName}.annotation`, - dataSourceURI: answers.dataSourceURI, - settings: answers.annotationSettings - } - }) - }; -} diff --git a/packages/create/test/unit/cli/add/new-model.test.ts b/packages/create/test/unit/cli/add/new-model.test.ts index ba55f1a35a1..feaf1e1cfc7 100644 --- a/packages/create/test/unit/cli/add/new-model.test.ts +++ b/packages/create/test/unit/cli/add/new-model.test.ts @@ -5,9 +5,7 @@ import { readFileSync } from 'node:fs'; import type { ToolsLogger } from '@sap-ux/logger'; import * as projectAccess from '@sap-ux/project-access'; -import { generateChange, getPromptsForNewModel } from '@sap-ux/adp-tooling'; -import * as adpTooling from '@sap-ux/adp-tooling'; -import * as btpUtils from '@sap-ux/btp-utils'; +import { generateChange, getPromptsForNewModel, createNewModelData } from '@sap-ux/adp-tooling'; import * as common from '../../../../src/common'; import * as tracer from '../../../../src/tracing/trace'; @@ -23,6 +21,7 @@ const descriptorVariant = JSON.parse( const readFileSyncMock = readFileSync as jest.Mock; const generateChangeMock = generateChange as jest.Mock; const getPromptsForNewModelMock = getPromptsForNewModel as jest.Mock; +const createNewModelDataMock = createNewModelData as jest.Mock; const mockAnswers = { modelAndDatasourceName: 'customer.OData_ServiceName', @@ -49,6 +48,8 @@ const mockCFAnswers = { modelSettings: '"key": "value"' }; +const mockNewModelData = { variant: descriptorVariant, serviceType: 'OData v2', isCloudFoundry: false }; + jest.mock('node:fs', () => ({ ...jest.requireActual('node:fs'), readFileSync: jest.fn() @@ -62,12 +63,7 @@ jest.mock('@sap-ux/adp-tooling', () => ({ commit: jest.fn().mockImplementation((cb) => cb()) } as Partial as Editor), getPromptsForNewModel: jest.fn(), - isCFEnvironment: jest.fn() -})); - -jest.mock('@sap-ux/btp-utils', () => ({ - ...jest.requireActual('@sap-ux/btp-utils'), - isOnPremiseDestination: jest.fn() + createNewModelData: jest.fn() })); const getArgv = (...arg: string[]) => ['', '', 'model', ...arg]; @@ -86,9 +82,8 @@ describe('add/model', () => { jest.spyOn(common, 'promptYUIQuestions').mockResolvedValue(mockAnswers); jest.spyOn(logger, 'getLogger').mockImplementation(() => loggerMock); jest.spyOn(projectAccess, 'getAppType').mockResolvedValue('Fiori Adaptation'); - jest.spyOn(adpTooling, 'isCFEnvironment').mockResolvedValue(false); - jest.spyOn(btpUtils, 'isOnPremiseDestination').mockReturnValue(false); readFileSyncMock.mockReturnValue(JSON.stringify(descriptorVariant)); + createNewModelDataMock.mockResolvedValue(mockNewModelData); traceSpy = jest.spyOn(tracer, 'traceChanges').mockResolvedValue(); }); @@ -96,37 +91,18 @@ describe('add/model', () => { jest.clearAllMocks(); }); - test('should generate change with correct data', async () => { + test('should build model data and generate change', async () => { const command = new Command('model'); addNewModelCommand(command); await command.parseAsync(getArgv(appRoot)); expect(loggerMock.debug).not.toHaveBeenCalled(); expect(traceSpy).not.toHaveBeenCalled(); - expect(generateChangeMock).toHaveBeenCalledWith( - expect.anything(), - 'appdescr_ui5_addNewModel', - { - variant: descriptorVariant, - serviceType: 'OData v2', - isCloudFoundry: false, - destinationName: undefined, - isOnPremiseDestination: undefined, - service: { - name: 'customer.OData_ServiceName', - uri: '/sap/opu/odata/some-name', - modelName: 'customer.OData_ServiceName', - version: '2.0', - modelSettings: '"key": "value"' - } - }, - null, - undefined, - expect.anything() - ); + expect(createNewModelDataMock).toHaveBeenCalledWith(appRoot, descriptorVariant, mockAnswers, loggerMock); + expect(generateChangeMock).toHaveBeenCalledWith(appRoot, 'appdescr_ui5_addNewModel', mockNewModelData); }); - test('should generate change with correct data and annotation', async () => { + test('should pass annotation answers to createNewModelData', async () => { jest.spyOn(common, 'promptYUIQuestions').mockResolvedValue(mockAnswersWithAnnotation); const command = new Command('model'); @@ -134,38 +110,11 @@ describe('add/model', () => { await command.parseAsync(getArgv(appRoot)); expect(loggerMock.debug).not.toHaveBeenCalled(); - expect(traceSpy).not.toHaveBeenCalled(); - expect(generateChangeMock).toHaveBeenCalledWith( - expect.anything(), - 'appdescr_ui5_addNewModel', - { - variant: descriptorVariant, - serviceType: 'OData v2', - isCloudFoundry: false, - destinationName: undefined, - isOnPremiseDestination: undefined, - service: { - name: 'customer.OData_ServiceName', - uri: '/sap/opu/odata/some-name', - modelName: 'customer.OData_ServiceName', - version: '2.0', - modelSettings: '"key": "value"' - }, - annotation: { - dataSourceName: 'customer.OData_ServiceName.annotation', - dataSourceURI: '/sap/opu/odata/annotation/', - settings: '"key2":"value2"' - } - }, - null, - undefined, - expect.anything() - ); + expect(createNewModelDataMock).toHaveBeenCalledWith(appRoot, descriptorVariant, mockAnswersWithAnnotation, loggerMock); + expect(generateChangeMock).toHaveBeenCalledWith(appRoot, 'appdescr_ui5_addNewModel', mockNewModelData); }); - test('should generate change with correct data for CF project', async () => { - jest.spyOn(adpTooling, 'isCFEnvironment').mockResolvedValue(true); - jest.spyOn(btpUtils, 'isOnPremiseDestination').mockReturnValue(true); + test('should pass CF answers to createNewModelData', async () => { jest.spyOn(common, 'promptYUIQuestions').mockResolvedValue(mockCFAnswers); const command = new Command('model'); @@ -173,58 +122,19 @@ describe('add/model', () => { await command.parseAsync(getArgv(appRoot)); expect(loggerMock.debug).not.toHaveBeenCalled(); - expect(traceSpy).not.toHaveBeenCalled(); - expect(generateChangeMock).toHaveBeenCalledWith( - expect.anything(), - 'appdescr_ui5_addNewModel', - { - variant: descriptorVariant, - serviceType: 'OData v2', - isCloudFoundry: true, - destinationName: 'CF_DEST', - isOnPremiseDestination: true, - service: { - name: 'customer.OData_ServiceName', - uri: '/sap/opu/odata/some-name', - modelName: 'customer.OData_ServiceName', - version: '2.0', - modelSettings: '"key": "value"' - } - }, - null, - undefined, - expect.anything() - ); + expect(createNewModelDataMock).toHaveBeenCalledWith(appRoot, descriptorVariant, mockCFAnswers, loggerMock); + expect(generateChangeMock).toHaveBeenCalledWith(appRoot, 'appdescr_ui5_addNewModel', mockNewModelData); }); - test('should generate change with no base path and simulate true', async () => { + test('should use cwd as base path and run in simulate mode', async () => { const command = new Command('model'); addNewModelCommand(command); await command.parseAsync(getArgv('', '-s')); expect(loggerMock.debug).not.toHaveBeenCalled(); expect(traceSpy).toHaveBeenCalled(); - expect(generateChangeMock).toHaveBeenCalledWith( - expect.anything(), - 'appdescr_ui5_addNewModel', - { - variant: descriptorVariant, - serviceType: 'OData v2', - isCloudFoundry: false, - destinationName: undefined, - isOnPremiseDestination: undefined, - service: { - name: 'customer.OData_ServiceName', - uri: '/sap/opu/odata/some-name', - modelName: 'customer.OData_ServiceName', - version: '2.0', - modelSettings: '"key": "value"' - } - }, - null, - undefined, - expect.anything() - ); + expect(createNewModelDataMock).toHaveBeenCalledWith(process.cwd(), descriptorVariant, mockAnswers, loggerMock); + expect(generateChangeMock).toHaveBeenCalledWith(process.cwd(), 'appdescr_ui5_addNewModel', mockNewModelData); }); test('should throw error and log it', async () => { diff --git a/packages/generator-adp/src/add-new-model/index.ts b/packages/generator-adp/src/add-new-model/index.ts index eeac1aa96d1..15ba10bf848 100644 --- a/packages/generator-adp/src/add-new-model/index.ts +++ b/packages/generator-adp/src/add-new-model/index.ts @@ -1,17 +1,15 @@ import { MessageType, Prompts } from '@sap-devx/yeoman-ui-types'; -import type { NewModelAnswers, NewModelData, DescriptorVariant, CfConfig } from '@sap-ux/adp-tooling'; +import type { NewModelAnswers, DescriptorVariant, CfConfig } from '@sap-ux/adp-tooling'; import { generateChange, ChangeType, getPromptsForNewModel, getVariant, - getODataVersionFromServiceType, - ServiceType, + createNewModelData, isCFEnvironment, isLoggedInCf, loadCfConfig } from '@sap-ux/adp-tooling'; -import { isOnPremiseDestination } from '@sap-ux/btp-utils'; import { setYeomanEnvConflicterForce } from '@sap-ux/fiori-generator-shared'; import { GeneratorTypes } from '../types'; @@ -96,50 +94,15 @@ class AddNewModelGenerator extends SubGeneratorBase { await generateChange( this.projectPath, ChangeType.ADD_NEW_MODEL, - await this._createNewModelData(), - this.fs, - undefined, - this.logger + await createNewModelData(this.projectPath, this.variant, this.answers, this.logger), + this.fs ); this.logger.log('Change written to changes folder'); } - async end(): Promise { + end(): void { this.logger.log('Successfully created change!'); } - - /** - * Creates the new model data. - * - * @returns {Promise} The new model data. - */ - private async _createNewModelData(): Promise { - const { modelAndDatasourceName, uri, serviceType, modelSettings, addAnnotationMode } = this.answers; - const isCloudFoundry = await isCFEnvironment(this.projectPath); - return { - variant: this.variant, - serviceType, - isCloudFoundry, - destinationName: isCloudFoundry ? this.answers.destination?.Name : undefined, - ...(isCloudFoundry && this.answers.destination && { - isOnPremiseDestination: isOnPremiseDestination(this.answers.destination) - }), - service: { - name: modelAndDatasourceName, - uri, - modelName: serviceType === ServiceType.HTTP ? undefined : modelAndDatasourceName, - version: getODataVersionFromServiceType(serviceType), - modelSettings - }, - ...(addAnnotationMode && { - annotation: { - dataSourceName: `${modelAndDatasourceName}.annotation`, - dataSourceURI: this.answers.dataSourceURI, - settings: this.answers.annotationSettings - } - }) - }; - } } export = AddNewModelGenerator; diff --git a/packages/generator-adp/test/unit/add-new-model/index.test.ts b/packages/generator-adp/test/unit/add-new-model/index.test.ts index 0e2f23dc5c2..db7defdbbfa 100644 --- a/packages/generator-adp/test/unit/add-new-model/index.test.ts +++ b/packages/generator-adp/test/unit/add-new-model/index.test.ts @@ -8,10 +8,10 @@ import { getVariant, isCFEnvironment, isLoggedInCf, - loadCfConfig + loadCfConfig, + createNewModelData } from '@sap-ux/adp-tooling'; -import type { NewModelAnswers, DescriptorVariant } from '@sap-ux/adp-tooling'; -import { isOnPremiseDestination } from '@sap-ux/btp-utils'; +import type { NewModelAnswers, NewModelData, DescriptorVariant } from '@sap-ux/adp-tooling'; import newModelGen from '../../../src/add-new-model'; @@ -21,12 +21,8 @@ jest.mock('@sap-ux/adp-tooling', () => ({ getVariant: jest.fn(), isCFEnvironment: jest.fn(), isLoggedInCf: jest.fn(), - loadCfConfig: jest.fn() -})); - -jest.mock('@sap-ux/btp-utils', () => ({ - ...jest.requireActual('@sap-ux/btp-utils'), - isOnPremiseDestination: jest.fn() + loadCfConfig: jest.fn(), + createNewModelData: jest.fn() })); const generateChangeMock = generateChange as jest.MockedFunction; @@ -34,7 +30,7 @@ const getVariantMock = getVariant as jest.MockedFunction; const isCFEnvironmentMock = isCFEnvironment as jest.MockedFunction; const isLoggedInCfMock = isLoggedInCf as jest.MockedFunction; const loadCfConfigMock = loadCfConfig as jest.MockedFunction; -const isOnPremiseDestinationMock = isOnPremiseDestination as jest.MockedFunction; +const createNewModelDataMock = createNewModelData as jest.MockedFunction; const variant = { reference: 'customer.adp.variant', @@ -52,6 +48,8 @@ const answers: NewModelAnswers & { errorMessagePrompt: string } = { errorMessagePrompt: 'failed' }; +const mockNewModelData = { variant, isCloudFoundry: false } as unknown as NewModelData; + const generatorPath = join(__dirname, '../../src/add-new-model/index.ts'); const tmpDir = resolve(__dirname, 'test-output'); const originalCwd: string = process.cwd(); // Generation changes the cwd, this breaks sonar report so we restore later @@ -63,7 +61,7 @@ describe('AddNewModelGenerator', () => { isCFEnvironmentMock.mockResolvedValue(false); isLoggedInCfMock.mockResolvedValue(true); loadCfConfigMock.mockReturnValue(mockCfConfig as any); - isOnPremiseDestinationMock.mockReturnValue(false); + createNewModelDataMock.mockResolvedValue(mockNewModelData); }); afterEach(() => { @@ -85,23 +83,14 @@ describe('AddNewModelGenerator', () => { await expect(runContext.run()).resolves.not.toThrow(); - expect(generateChangeMock).toHaveBeenCalledWith( - tmpDir, - ChangeType.ADD_NEW_MODEL, - expect.objectContaining({ - isCloudFoundry: false, - service: { - name: answers.modelAndDatasourceName, - uri: answers.uri, - modelName: answers.modelAndDatasourceName, - version: '2.0', - modelSettings: answers.modelSettings - } - }), - expect.anything(), - undefined, - expect.anything() - ); + expect(createNewModelDataMock).toHaveBeenCalledWith(tmpDir, variant, expect.objectContaining({ + modelAndDatasourceName: answers.modelAndDatasourceName, + uri: answers.uri, + serviceType: answers.serviceType, + modelSettings: answers.modelSettings, + addAnnotationMode: answers.addAnnotationMode + }), expect.anything()); + expect(generateChangeMock).toHaveBeenCalledWith(tmpDir, ChangeType.ADD_NEW_MODEL, mockNewModelData, expect.anything()); }); it('passes isCloudFoundry: true and destinationName for CF projects', async () => { @@ -115,14 +104,8 @@ describe('AddNewModelGenerator', () => { await expect(runContext.run()).resolves.not.toThrow(); - expect(generateChangeMock).toHaveBeenCalledWith( - tmpDir, - ChangeType.ADD_NEW_MODEL, - expect.objectContaining({ isCloudFoundry: true }), - expect.anything(), - undefined, - expect.anything() - ); + expect(createNewModelDataMock).toHaveBeenCalledWith(tmpDir, variant, expect.anything(), expect.anything()); + expect(generateChangeMock).toHaveBeenCalledWith(tmpDir, ChangeType.ADD_NEW_MODEL, mockNewModelData, expect.anything()); }); it('invokes handleRuntimeCrash when getVariant fails during initializing', async () => { From 75df0e23b7070a69bb745e22f0e1185ddd402f3a Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Wed, 8 Apr 2026 14:51:40 +0300 Subject: [PATCH 16/31] fix: address text comments --- packages/adp-tooling/src/translations/adp-tooling.i18n.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/adp-tooling/src/translations/adp-tooling.i18n.json b/packages/adp-tooling/src/translations/adp-tooling.i18n.json index 15d3c1a99d6..ab5dfd22590 100644 --- a/packages/adp-tooling/src/translations/adp-tooling.i18n.json +++ b/packages/adp-tooling/src/translations/adp-tooling.i18n.json @@ -24,9 +24,9 @@ "serviceUriLabel": "Service URI", "resultingServiceUrl": "Resulting Service URL: {{url}}", "resultingAnnotationUrl": "Resulting Annotation URL: {{url}}", - "modelAndDatasourceNameLabel": "Model and Datasource Name", - "modelAndDatasourceNameTooltip": "Enter a name for the datasource and model.", - "datasourceNameLabel": "Datasource Name", + "modelAndDatasourceNameLabel": "Model and Data Source Name", + "modelAndDatasourceNameTooltip": "Enter a name for the data source and model.", + "datasourceNameLabel": "Data Source Name", "oDataServiceVersionLabel": "OData Version", "oDataServiceVersionTooltip": "Select the version of OData of the service you want to add", "oDataServiceModelNameLabel": "OData Service SAPUI5 Model Name", From 88ad9675145b5f6eabdb62ff8e7e3903916b5562 Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Wed, 8 Apr 2026 14:53:26 +0300 Subject: [PATCH 17/31] fix: sonar issue --- packages/adp-tooling/src/prompts/add-new-model/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/adp-tooling/src/prompts/add-new-model/index.ts b/packages/adp-tooling/src/prompts/add-new-model/index.ts index 0c4eea238b3..49c65d52192 100644 --- a/packages/adp-tooling/src/prompts/add-new-model/index.ts +++ b/packages/adp-tooling/src/prompts/add-new-model/index.ts @@ -449,7 +449,7 @@ export async function createNewModelData( service: { name: modelAndDatasourceName, uri, - modelName: serviceType !== ServiceType.HTTP ? modelAndDatasourceName : undefined, + modelName: serviceType === ServiceType.HTTP ? undefined : modelAndDatasourceName, version: getODataVersionFromServiceType(serviceType), modelSettings }, From c0990eac302736d88813f81041e79e992aa9c3c1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 8 Apr 2026 12:04:29 +0000 Subject: [PATCH 18/31] Linting auto fix commit --- .../unit/cf/services/destinations.test.ts | 4 ++- packages/create/src/cli/add/new-model.ts | 8 +---- .../test/unit/cli/add/new-model.test.ts | 7 +++- .../test/unit/add-new-model/index.test.ts | 33 ++++++++++++++----- 4 files changed, 34 insertions(+), 18 deletions(-) diff --git a/packages/adp-tooling/test/unit/cf/services/destinations.test.ts b/packages/adp-tooling/test/unit/cf/services/destinations.test.ts index f38f73b487c..cbeb3264a3b 100644 --- a/packages/adp-tooling/test/unit/cf/services/destinations.test.ts +++ b/packages/adp-tooling/test/unit/cf/services/destinations.test.ts @@ -98,7 +98,9 @@ describe('getBtpDestinations', () => { ] }); - await expect(getBtpDestinations(mockProjectPath)).rejects.toThrow(t('error.destinationServiceNotFoundInMtaYaml')); + await expect(getBtpDestinations(mockProjectPath)).rejects.toThrow( + t('error.destinationServiceNotFoundInMtaYaml') + ); expect(getOrCreateServiceInstanceKeysMock).not.toHaveBeenCalled(); }); diff --git a/packages/create/src/cli/add/new-model.ts b/packages/create/src/cli/add/new-model.ts index 54f04226223..2f1d1ac7a5d 100644 --- a/packages/create/src/cli/add/new-model.ts +++ b/packages/create/src/cli/add/new-model.ts @@ -1,12 +1,6 @@ import type { Command } from 'commander'; -import { - generateChange, - ChangeType, - getPromptsForNewModel, - getVariant, - createNewModelData -} from '@sap-ux/adp-tooling'; +import { generateChange, ChangeType, getPromptsForNewModel, getVariant, createNewModelData } from '@sap-ux/adp-tooling'; import { promptYUIQuestions } from '../../common'; import { getLogger, traceChanges } from '../../tracing'; diff --git a/packages/create/test/unit/cli/add/new-model.test.ts b/packages/create/test/unit/cli/add/new-model.test.ts index feaf1e1cfc7..79df84fd68b 100644 --- a/packages/create/test/unit/cli/add/new-model.test.ts +++ b/packages/create/test/unit/cli/add/new-model.test.ts @@ -110,7 +110,12 @@ describe('add/model', () => { await command.parseAsync(getArgv(appRoot)); expect(loggerMock.debug).not.toHaveBeenCalled(); - expect(createNewModelDataMock).toHaveBeenCalledWith(appRoot, descriptorVariant, mockAnswersWithAnnotation, loggerMock); + expect(createNewModelDataMock).toHaveBeenCalledWith( + appRoot, + descriptorVariant, + mockAnswersWithAnnotation, + loggerMock + ); expect(generateChangeMock).toHaveBeenCalledWith(appRoot, 'appdescr_ui5_addNewModel', mockNewModelData); }); diff --git a/packages/generator-adp/test/unit/add-new-model/index.test.ts b/packages/generator-adp/test/unit/add-new-model/index.test.ts index db7defdbbfa..8ca2f91210e 100644 --- a/packages/generator-adp/test/unit/add-new-model/index.test.ts +++ b/packages/generator-adp/test/unit/add-new-model/index.test.ts @@ -83,14 +83,24 @@ describe('AddNewModelGenerator', () => { await expect(runContext.run()).resolves.not.toThrow(); - expect(createNewModelDataMock).toHaveBeenCalledWith(tmpDir, variant, expect.objectContaining({ - modelAndDatasourceName: answers.modelAndDatasourceName, - uri: answers.uri, - serviceType: answers.serviceType, - modelSettings: answers.modelSettings, - addAnnotationMode: answers.addAnnotationMode - }), expect.anything()); - expect(generateChangeMock).toHaveBeenCalledWith(tmpDir, ChangeType.ADD_NEW_MODEL, mockNewModelData, expect.anything()); + expect(createNewModelDataMock).toHaveBeenCalledWith( + tmpDir, + variant, + expect.objectContaining({ + modelAndDatasourceName: answers.modelAndDatasourceName, + uri: answers.uri, + serviceType: answers.serviceType, + modelSettings: answers.modelSettings, + addAnnotationMode: answers.addAnnotationMode + }), + expect.anything() + ); + expect(generateChangeMock).toHaveBeenCalledWith( + tmpDir, + ChangeType.ADD_NEW_MODEL, + mockNewModelData, + expect.anything() + ); }); it('passes isCloudFoundry: true and destinationName for CF projects', async () => { @@ -105,7 +115,12 @@ describe('AddNewModelGenerator', () => { await expect(runContext.run()).resolves.not.toThrow(); expect(createNewModelDataMock).toHaveBeenCalledWith(tmpDir, variant, expect.anything(), expect.anything()); - expect(generateChangeMock).toHaveBeenCalledWith(tmpDir, ChangeType.ADD_NEW_MODEL, mockNewModelData, expect.anything()); + expect(generateChangeMock).toHaveBeenCalledWith( + tmpDir, + ChangeType.ADD_NEW_MODEL, + mockNewModelData, + expect.anything() + ); }); it('invokes handleRuntimeCrash when getVariant fails during initializing', async () => { From 0123c191ed59bf817f40cc46a83036961bcc9fe2 Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Wed, 8 Apr 2026 15:04:36 +0300 Subject: [PATCH 19/31] fix: expected texts in tests --- .../adp-tooling/test/unit/prompts/add-new-model/index.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts b/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts index a45c9891d82..3fd7d40b351 100644 --- a/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts +++ b/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts @@ -114,7 +114,7 @@ describe('getPrompts', () => { const validation = prompts.find((p) => p.name === 'modelAndDatasourceName')?.validate; expect(typeof validation).toBe('function'); - expect(validation?.('testName')).toBe("Model and Datasource Name must start with 'customer.'."); + expect(validation?.('testName')).toBe("Model and Data Source Name must start with 'customer.'."); }); it('should return error message when validating service name prompt and name is only "customer."', async () => { @@ -124,7 +124,7 @@ describe('getPrompts', () => { expect(typeof validation).toBe('function'); expect(validation?.('customer.')).toBe( - "Model and Datasource Name must contain at least one character in addition to 'customer.'." + "Model and Data Source Name must contain at least one character in addition to 'customer.'." ); }); From 010704a00a876b68ba91f8cb76905168bde33e18 Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Wed, 8 Apr 2026 16:51:13 +0300 Subject: [PATCH 20/31] fix: for HTTP service type use different change type --- packages/adp-tooling/src/types.ts | 2 ++ .../src/writer/changes/writers/new-model-writer.ts | 8 +++++++- .../test/unit/writer/changes/writers/index.test.ts | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/adp-tooling/src/types.ts b/packages/adp-tooling/src/types.ts index ff9731a6add..8e79648810f 100644 --- a/packages/adp-tooling/src/types.ts +++ b/packages/adp-tooling/src/types.ts @@ -530,6 +530,7 @@ export interface IWriter { */ export const enum ChangeType { ADD_NEW_MODEL = 'appdescr_ui5_addNewModel', + ADD_NEW_DATA_SOURCE = 'appdescr_app_addNewDataSource', ADD_ANNOTATIONS_TO_ODATA = 'appdescr_app_addAnnotationsToOData', CHANGE_DATA_SOURCE = 'appdescr_app_changeDataSource', ADD_COMPONENT_USAGES = 'appdescr_ui5_addComponentUsages', @@ -550,6 +551,7 @@ export type ServiceType = (typeof ServiceType)[keyof typeof ServiceType]; */ export const ChangeTypeMap: Record = { [ChangeType.ADD_NEW_MODEL]: 'addNewModel', + [ChangeType.ADD_NEW_DATA_SOURCE]: 'addNewDataSource', [ChangeType.ADD_ANNOTATIONS_TO_ODATA]: 'addAnnotationsToOData', [ChangeType.CHANGE_DATA_SOURCE]: 'changeDataSource', [ChangeType.ADD_COMPONENT_USAGES]: 'addComponentUsages', diff --git a/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts b/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts index 138f4bdb6fc..265c1684791 100644 --- a/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts +++ b/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts @@ -96,8 +96,14 @@ export class NewModelWriter implements IWriter { */ async write(data: NewModelData): Promise { const timestamp = Date.now(); + const isHttp = data.serviceType === ServiceType.HTTP; const content = this.constructContent(data); - const change = getChange(data.variant, timestamp, content, ChangeType.ADD_NEW_MODEL); + const change = getChange( + data.variant, + timestamp, + content, + isHttp ? ChangeType.ADD_NEW_DATA_SOURCE : ChangeType.ADD_NEW_MODEL + ); await writeChangeToFolder(this.projectPath, change, this.fs); diff --git a/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts b/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts index 26b96e5556f..9a4da11610a 100644 --- a/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts +++ b/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts @@ -324,7 +324,7 @@ describe('NewModelWriter', () => { } } }, - ChangeType.ADD_NEW_MODEL + ChangeType.ADD_NEW_DATA_SOURCE ); }); From f5056bf9cf3f2d3b5a336a2110f2dca22dbf8ba8 Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Wed, 8 Apr 2026 17:10:05 +0300 Subject: [PATCH 21/31] fix: address comments --- .../src/prompts/add-new-model/index.ts | 25 +++++++------------ .../changes/writers/new-model-writer.ts | 11 ++++---- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/packages/adp-tooling/src/prompts/add-new-model/index.ts b/packages/adp-tooling/src/prompts/add-new-model/index.ts index 49c65d52192..c450f9c1533 100644 --- a/packages/adp-tooling/src/prompts/add-new-model/index.ts +++ b/packages/adp-tooling/src/prompts/add-new-model/index.ts @@ -220,19 +220,15 @@ export function getODataVersionFromServiceType(serviceType: ServiceType): string */ async function getAbapServiceUrl(projectPath: string): Promise { try { - const adpConf = await getAdpConfig(projectPath, 'ui5.yaml'); - if ('target' in adpConf) { - const { target } = adpConf; - if ('url' in target && target.url) { - return target.url; - } - if ('destination' in target && target.destination) { - const destinations = await listDestinations(); - return destinations[target.destination]?.Host; - } + const { target } = (await getAdpConfig(projectPath, 'ui5.yaml')) as any; + + if (!target) { + return undefined; } + + return target.url ?? (target.destination ? (await listDestinations())[target.destination]?.Host : undefined); } catch { - // unavailable — caller will simply not show the message + // Message will not be shown } return undefined; } @@ -344,11 +340,8 @@ export async function getPrompts( if (typeof uriResult === 'string') { return uriResult; } - if (isCFEnv) { - const routes = readXsAppRoutes(projectPath); - if (routes.some((r) => r.target === `${value}$1`)) { - return t('validators.errorRouteAlreadyExists'); - } + if (isCFEnv && readXsAppRoutes(projectPath).some((r) => r.target === `${value}$1`)) { + return t('validators.errorRouteAlreadyExists'); } return true; }, diff --git a/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts b/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts index 265c1684791..cf82ea56732 100644 --- a/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts +++ b/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts @@ -63,11 +63,12 @@ export class NewModelWriter implements IWriter { }; if (!isHttp && service.modelName) { - content.model = { [service.modelName]: { dataSource: service.name } }; - - if (service.modelSettings && service.modelSettings.length !== 0) { - content.model[service.modelName].settings = parseStringToObject(service.modelSettings); - } + content.model = { + [service.modelName]: { + dataSource: service.name, + ...(service.modelSettings?.length ? { settings: parseStringToObject(service.modelSettings) } : {}) + } + }; } if ('annotation' in data) { From d11c3cbfe640f03ccbd2c2513cf8f7ab6b766dba Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Wed, 8 Apr 2026 18:02:45 +0300 Subject: [PATCH 22/31] chore: address text comments --- packages/adp-tooling/src/translations/adp-tooling.i18n.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/adp-tooling/src/translations/adp-tooling.i18n.json b/packages/adp-tooling/src/translations/adp-tooling.i18n.json index ab5dfd22590..05c7bf98920 100644 --- a/packages/adp-tooling/src/translations/adp-tooling.i18n.json +++ b/packages/adp-tooling/src/translations/adp-tooling.i18n.json @@ -14,9 +14,9 @@ "filePathTooltip": "Select the annotation file from your workspace", "addAnnotationOdataSourceTooltip": "Select the OData service you want to add the annotation file to", "destinationLabel": "Destination", - "destinationTooltip": "Select destination for desired service", + "destinationTooltip": "Select a destination for the service.", "serviceTypeLabel": "Service Type", - "serviceTypeTooltip": "Select the type of service you want to add", + "serviceTypeTooltip": "Select the type of service you want to add.", "oDataServiceNameLabel": "OData Service Name", "oDataServiceNameTooltip": "Enter a name for the OData service you want to add", "oDataServiceUriLabel": "OData Service URI", From d5133900df3bd62bfee8b90856c7ff1bb12ed34e Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Thu, 9 Apr 2026 11:35:02 +0300 Subject: [PATCH 23/31] fix: translations json --- packages/adp-tooling/src/translations/adp-tooling.i18n.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/adp-tooling/src/translations/adp-tooling.i18n.json b/packages/adp-tooling/src/translations/adp-tooling.i18n.json index eb864b40dad..41b237ee249 100644 --- a/packages/adp-tooling/src/translations/adp-tooling.i18n.json +++ b/packages/adp-tooling/src/translations/adp-tooling.i18n.json @@ -129,7 +129,7 @@ "failedToListBtpDestinations": "Failed to list BTP destinations. Error: {{error}}", "destinationServiceNotFoundInMtaYaml": "Destination service instance not found in mta.yaml. Ensure a resource with 'service: destination' is declared.", "noServiceKeysFoundForDestination": "No service keys found for destination service instance '{{serviceInstanceName}}'. Ensure the service is provisioned and try again.", - "errorFetchingDestinations": "Error fetching destinations. Check log for details." + "errorFetchingDestinations": "Error fetching destinations. Check log for details.", "cfPushFailed": "cf push failed for the '{{appName}}' app: {{error}}", "cfEnableSshFailed": "cf enable-ssh failed for the '{{appName}}' app: {{error}}", "cfRestartFailed": "cf restart failed for the '{{appName}}' app: {{error}}" From 39106d6bc90d2d566c5ebefa731f00a3bd7cc299 Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Thu, 9 Apr 2026 13:14:45 +0300 Subject: [PATCH 24/31] fix: lint issue --- packages/adp-tooling/src/types.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/adp-tooling/src/types.ts b/packages/adp-tooling/src/types.ts index 6246ca2d943..971f9d2221a 100644 --- a/packages/adp-tooling/src/types.ts +++ b/packages/adp-tooling/src/types.ts @@ -915,15 +915,6 @@ export interface Uaa { export type CfDestinationServiceCredentials = { uri: string; uaa: Uaa } | ({ uri: string } & Uaa); -export interface BtpDestinationConfig { - Name: string; - Type: string; - URL: string; - Authentication: string; - ProxyType: string; - Description?: string; -} - export interface CfAppParams { appName: string; appVersion: string; From 5374259185d95bdb11ea03298a470dc7616eee39 Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Thu, 9 Apr 2026 15:18:14 +0300 Subject: [PATCH 25/31] chore: simplify code --- .../adp-tooling/src/prompts/add-new-model/index.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/adp-tooling/src/prompts/add-new-model/index.ts b/packages/adp-tooling/src/prompts/add-new-model/index.ts index c450f9c1533..ecd6bf53049 100644 --- a/packages/adp-tooling/src/prompts/add-new-model/index.ts +++ b/packages/adp-tooling/src/prompts/add-new-model/index.ts @@ -226,7 +226,13 @@ async function getAbapServiceUrl(projectPath: string): Promise Date: Thu, 9 Apr 2026 18:29:30 +0300 Subject: [PATCH 26/31] fix: address comments --- packages/adp-tooling/src/btp/api.ts | 10 ++++++---- .../src/prompts/add-new-model/index.ts | 4 ++++ .../src/translations/adp-tooling.i18n.json | 1 + .../unit/prompts/add-new-model/index.test.ts | 17 +++++++++++++---- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/adp-tooling/src/btp/api.ts b/packages/adp-tooling/src/btp/api.ts index ca1c6fa78d4..27019c97189 100644 --- a/packages/adp-tooling/src/btp/api.ts +++ b/packages/adp-tooling/src/btp/api.ts @@ -28,8 +28,8 @@ export async function getToken(uaa: Uaa, logger?: ToolsLogger): Promise logger?.debug('OAuth token obtained successfully'); return response.data['access_token']; } catch (e) { - logger?.error(`Failed to obtain OAuth token from ${uri}: ${e.message}`); - throw new Error(t('error.failedToGetAuthKey', { error: e.message })); + logger?.error(`Failed to obtain OAuth token from ${uri}: ${e instanceof Error ? e.message : String(e)}`); + throw new Error(t('error.failedToGetAuthKey', { error: e instanceof Error ? e.message : String(e) })); } } @@ -60,7 +60,9 @@ export async function getBtpDestinationConfig( logger?.debug(`Destination "${destinationName}" config: ProxyType=${config?.ProxyType}`); return config; } catch (e) { - logger?.error(`Failed to fetch destination config for "${destinationName}": ${e.message}`); + logger?.error( + `Failed to fetch destination config for "${destinationName}": ${e instanceof Error ? e.message : String(e)}` + ); return undefined; } } @@ -95,6 +97,6 @@ export async function listBtpDestinations(credentials: CfDestinationServiceCrede return acc; }, {}); } catch (e) { - throw new Error(t('error.failedToListBtpDestinations', { error: e.message })); + throw new Error(t('error.failedToListBtpDestinations', { error: e instanceof Error ? e.message : String(e) })); } } diff --git a/packages/adp-tooling/src/prompts/add-new-model/index.ts b/packages/adp-tooling/src/prompts/add-new-model/index.ts index ecd6bf53049..21543bc2b9f 100644 --- a/packages/adp-tooling/src/prompts/add-new-model/index.ts +++ b/packages/adp-tooling/src/prompts/add-new-model/index.ts @@ -80,6 +80,10 @@ function validatePromptInput(value: string): boolean | string { } } + if (!/[a-zA-Z0-9]$/.test(value)) { + return t('validators.errorInputMustEndWithAlphanumeric'); + } + return true; } diff --git a/packages/adp-tooling/src/translations/adp-tooling.i18n.json b/packages/adp-tooling/src/translations/adp-tooling.i18n.json index 41b237ee249..0379c9c5e13 100644 --- a/packages/adp-tooling/src/translations/adp-tooling.i18n.json +++ b/packages/adp-tooling/src/translations/adp-tooling.i18n.json @@ -77,6 +77,7 @@ "errorDuplicateNamesOData": "An OData Service Name must be different from an OData Annotation Data Source Name. Rename and try again.", "errorInputInvalidValuePrefix": "{{value}} must start with '{{prefix}}'.", "errorCustomerEmptyValue": "{{value}} must contain at least one character in addition to '{{prefix}}'.", + "errorInputMustEndWithAlphanumeric": "The input must end with an alphanumeric character.", "errorInvalidDataSourceURI": "Invalid URI. The URI must start and end with '/' and contain no spaces.", "errorRouteAlreadyExists": "A route with the same Service URI already exists in xs-app.json. Use a different URI.", "appDoesNotSupportManifest": "The selected application is not supported by SAPUI5 Adaptation Project because it does not have a `manifest.json` file. Please select a different application.", diff --git a/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts b/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts index 3fd7d40b351..e68b82859bf 100644 --- a/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts +++ b/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts @@ -123,9 +123,7 @@ describe('getPrompts', () => { const validation = prompts.find((p) => p.name === 'modelAndDatasourceName')?.validate; expect(typeof validation).toBe('function'); - expect(validation?.('customer.')).toBe( - "Model and Data Source Name must contain at least one character in addition to 'customer.'." - ); + expect(validation?.('customer.')).toBe('The input must end with an alphanumeric character.'); }); it('should return error message when validating service name prompt and has special characters', async () => { @@ -138,7 +136,18 @@ describe('getPrompts', () => { expect(validation?.('customer.testName@')).toBe('general.invalidValueForSpecialChars'); }); - it('should return error message when validating service name prompt has content duplication', async () => { + it('should return error message when validating service name prompt and name ends with a non-alphanumeric character', async () => { + const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + + const validation = prompts.find((p) => p.name === 'modelAndDatasourceName')?.validate; + + expect(typeof validation).toBe('function'); + expect(validation?.('customer.testName.')).toBe('The value must end with an alphanumeric character.'); + expect(validation?.('customer.testName-')).toBe('The value must end with an alphanumeric character.'); + expect(validation?.('customer.testName$')).toBe('The value must end with an alphanumeric character.'); + }); + + it('should return error message when validating service name prompt and has content duplication', async () => { jest.spyOn(validators, 'hasContentDuplication').mockReturnValueOnce(true); const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); From 0b97b86d25846f3a35eb29d814b752af94dba4ba Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Tue, 14 Apr 2026 10:08:03 +0300 Subject: [PATCH 27/31] test: update changed text --- .../test/unit/prompts/add-new-model/index.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts b/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts index e68b82859bf..14dc0476ef9 100644 --- a/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts +++ b/packages/adp-tooling/test/unit/prompts/add-new-model/index.test.ts @@ -142,9 +142,9 @@ describe('getPrompts', () => { const validation = prompts.find((p) => p.name === 'modelAndDatasourceName')?.validate; expect(typeof validation).toBe('function'); - expect(validation?.('customer.testName.')).toBe('The value must end with an alphanumeric character.'); - expect(validation?.('customer.testName-')).toBe('The value must end with an alphanumeric character.'); - expect(validation?.('customer.testName$')).toBe('The value must end with an alphanumeric character.'); + expect(validation?.('customer.testName.')).toBe('The input must end with an alphanumeric character.'); + expect(validation?.('customer.testName-')).toBe('The input must end with an alphanumeric character.'); + expect(validation?.('customer.testName$')).toBe('The input must end with an alphanumeric character.'); }); it('should return error message when validating service name prompt and has content duplication', async () => { From 9d6ee47b14b329428360f68f953ce83be8c0c790 Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Tue, 14 Apr 2026 14:12:23 +0300 Subject: [PATCH 28/31] fix: validation for duplicate change --- packages/adp-tooling/src/prompts/add-new-model/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/adp-tooling/src/prompts/add-new-model/index.ts b/packages/adp-tooling/src/prompts/add-new-model/index.ts index 21543bc2b9f..286a5da57bf 100644 --- a/packages/adp-tooling/src/prompts/add-new-model/index.ts +++ b/packages/adp-tooling/src/prompts/add-new-model/index.ts @@ -286,7 +286,10 @@ export async function getPrompts( const isCFEnv = await isCFEnvironment(projectPath); const abapServiceUrl = isCFEnv ? undefined : await getAbapServiceUrl(projectPath); - const changeFiles = getChangesByType(projectPath, ChangeType.ADD_NEW_MODEL, 'manifest'); + const changeFiles = [ + ...getChangesByType(projectPath, ChangeType.ADD_NEW_MODEL), + ...getChangesByType(projectPath, ChangeType.ADD_NEW_DATA_SOURCE) + ]; let destinationError: string | undefined; let destinationChoices: { name: string; value: Destination }[] | undefined; From 87a8d3f70f0c34135eabec52ca21003ffcb87678 Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Tue, 14 Apr 2026 17:08:37 +0300 Subject: [PATCH 29/31] fix: add check for org/space mismatch --- .../generator-adp/src/add-new-model/index.ts | 27 +++- .../src/translations/generator-adp.i18n.json | 1 + .../test/unit/add-new-model/index.test.ts | 118 +++++++++++++++++- 3 files changed, 140 insertions(+), 6 deletions(-) diff --git a/packages/generator-adp/src/add-new-model/index.ts b/packages/generator-adp/src/add-new-model/index.ts index 15ba10bf848..c6edd8a41a7 100644 --- a/packages/generator-adp/src/add-new-model/index.ts +++ b/packages/generator-adp/src/add-new-model/index.ts @@ -8,7 +8,9 @@ import { createNewModelData, isCFEnvironment, isLoggedInCf, - loadCfConfig + loadCfConfig, + extractCfBuildTask, + readUi5Config } from '@sap-ux/adp-tooling'; import { setYeomanEnvConflicterForce } from '@sap-ux/fiori-generator-shared'; @@ -62,6 +64,7 @@ class AddNewModelGenerator extends SubGeneratorBase { if (!loggedIn) { throw new Error(t('error.cfNotLoggedIn')); } + await this._checkCfTargetMismatch(); } this._registerPrompts( @@ -78,6 +81,28 @@ class AddNewModelGenerator extends SubGeneratorBase { } } + /** + * Checks whether the project's CF target (org/space stored in ui5.yaml) matches the + * currently logged-in CF target. + */ + private async _checkCfTargetMismatch(): Promise { + let buildTask; + try { + const ui5Config = await readUi5Config(this.projectPath, 'ui5.yaml'); + buildTask = extractCfBuildTask(ui5Config); + } catch (e) { + this.logger.error((e as Error).message); + throw new Error('CF target mismatch check failed. Check the logs for details.'); + } + + const orgMismatch = this.cfConfig?.org.GUID !== buildTask.org; + const spaceMismatch = this.cfConfig?.space.GUID !== buildTask.space; + + if (orgMismatch || spaceMismatch) { + throw new Error(t('error.cfTargetMismatch')); + } + } + async prompting(): Promise { if (this.validationError) { await this.handleRuntimeCrash(this.validationError.message); diff --git a/packages/generator-adp/src/translations/generator-adp.i18n.json b/packages/generator-adp/src/translations/generator-adp.i18n.json index 9d71a3d9eba..71cf42a93f8 100644 --- a/packages/generator-adp/src/translations/generator-adp.i18n.json +++ b/packages/generator-adp/src/translations/generator-adp.i18n.json @@ -111,6 +111,7 @@ "cfNotInstalled": "Cloud Foundry is not installed in your space. Please install it to continue.", "cfNotLoggedIn": "You are not logged in to Cloud Foundry. Please login to continue.", "cfOrgSpaceMissing": "You are logged in to the Cloud Foundry API endpoint but no organization and space are targeted. Target an organization and space to continue.", + "cfTargetMismatch": "The active Cloud Foundry session doesn't match the expected organization and space of the project.", "baseAppHasToBeSelected": "Base application has to be selected. Please select a base application.", "businessServiceHasToBeSelected": "Business service has to be selected. Please select a business service.", "businessServiceDoesNotExist": "The service chosen does not exist in cockpit or the user is not member of the needed space.", diff --git a/packages/generator-adp/test/unit/add-new-model/index.test.ts b/packages/generator-adp/test/unit/add-new-model/index.test.ts index 8ca2f91210e..dc4d2f00df8 100644 --- a/packages/generator-adp/test/unit/add-new-model/index.test.ts +++ b/packages/generator-adp/test/unit/add-new-model/index.test.ts @@ -9,9 +9,11 @@ import { isCFEnvironment, isLoggedInCf, loadCfConfig, - createNewModelData + createNewModelData, + readUi5Config, + extractCfBuildTask } from '@sap-ux/adp-tooling'; -import type { NewModelAnswers, NewModelData, DescriptorVariant } from '@sap-ux/adp-tooling'; +import type { NewModelAnswers, NewModelData, DescriptorVariant, UI5YamlCustomTaskConfiguration } from '@sap-ux/adp-tooling'; import newModelGen from '../../../src/add-new-model'; @@ -22,7 +24,9 @@ jest.mock('@sap-ux/adp-tooling', () => ({ isCFEnvironment: jest.fn(), isLoggedInCf: jest.fn(), loadCfConfig: jest.fn(), - createNewModelData: jest.fn() + createNewModelData: jest.fn(), + readUi5Config: jest.fn(), + extractCfBuildTask: jest.fn() })); const generateChangeMock = generateChange as jest.MockedFunction; @@ -31,6 +35,8 @@ const isCFEnvironmentMock = isCFEnvironment as jest.MockedFunction; const loadCfConfigMock = loadCfConfig as jest.MockedFunction; const createNewModelDataMock = createNewModelData as jest.MockedFunction; +const readUi5ConfigMock = readUi5Config as jest.MockedFunction; +const extractCfBuildTaskMock = extractCfBuildTask as jest.MockedFunction; const variant = { reference: 'customer.adp.variant', @@ -54,14 +60,26 @@ const generatorPath = join(__dirname, '../../src/add-new-model/index.ts'); const tmpDir = resolve(__dirname, 'test-output'); const originalCwd: string = process.cwd(); // Generation changes the cwd, this breaks sonar report so we restore later -describe('AddNewModelGenerator', () => { - const mockCfConfig = { url: 'cf.example.com', token: 'token' }; +const mockCfConfig = { + url: 'cf.example.com', + token: 'token', + org: { Name: 'my-org', GUID: 'org-guid-123' }, + space: { Name: 'my-space', GUID: 'space-guid-456' } +}; +const mockBuildTask = { + org: 'org-guid-123', + space: 'space-guid-456' +} as unknown as UI5YamlCustomTaskConfiguration; + +describe('AddNewModelGenerator', () => { beforeEach(() => { isCFEnvironmentMock.mockResolvedValue(false); isLoggedInCfMock.mockResolvedValue(true); loadCfConfigMock.mockReturnValue(mockCfConfig as any); createNewModelDataMock.mockResolvedValue(mockNewModelData); + readUi5ConfigMock.mockResolvedValue({} as any); + extractCfBuildTaskMock.mockReturnValue(mockBuildTask); }); afterEach(() => { @@ -146,4 +164,94 @@ describe('AddNewModelGenerator', () => { writingSpy.mockRestore(); handleCrashSpy.mockRestore(); }); + + describe('_checkCfTargetMismatch', () => { + beforeEach(() => { + isCFEnvironmentMock.mockResolvedValue(true); + getVariantMock.mockResolvedValue(variant); + }); + + it('continues without error when org and space match', async () => { + const runContext = yeomanTest + .create(newModelGen, { resolved: generatorPath }, { cwd: tmpDir }) + .withOptions({ data: { path: tmpDir } }) + .withPrompts(answers); + + await expect(runContext.run()).resolves.not.toThrow(); + + expect(generateChangeMock).toHaveBeenCalled(); + }); + + it('stops the generator when org does not match', async () => { + extractCfBuildTaskMock.mockReturnValue({ ...mockBuildTask, org: 'different-org' }); + + const handleCrashSpy = jest + .spyOn((newModelGen as any).prototype, 'handleRuntimeCrash') + .mockResolvedValueOnce(undefined); + const writingSpy = jest + .spyOn((newModelGen as any).prototype, 'writing') + .mockImplementation(async () => undefined); + + const runContext = yeomanTest + .create(newModelGen, { resolved: generatorPath }, { cwd: tmpDir }) + .withOptions({ data: { path: tmpDir } }) + .withPrompts(answers); + + await expect(runContext.run()).resolves.not.toThrow(); + + expect(handleCrashSpy).toHaveBeenCalled(); + expect(generateChangeMock).not.toHaveBeenCalled(); + + writingSpy.mockRestore(); + handleCrashSpy.mockRestore(); + }); + + it('stops the generator when space does not match', async () => { + extractCfBuildTaskMock.mockReturnValue({ ...mockBuildTask, space: 'different-space-guid' }); + + const handleCrashSpy = jest + .spyOn((newModelGen as any).prototype, 'handleRuntimeCrash') + .mockResolvedValueOnce(undefined); + const writingSpy = jest + .spyOn((newModelGen as any).prototype, 'writing') + .mockImplementation(async () => undefined); + + const runContext = yeomanTest + .create(newModelGen, { resolved: generatorPath }, { cwd: tmpDir }) + .withOptions({ data: { path: tmpDir } }) + .withPrompts(answers); + + await expect(runContext.run()).resolves.not.toThrow(); + + expect(handleCrashSpy).toHaveBeenCalled(); + expect(generateChangeMock).not.toHaveBeenCalled(); + + writingSpy.mockRestore(); + handleCrashSpy.mockRestore(); + }); + + it('stops the generator when reading ui5.yaml fails', async () => { + readUi5ConfigMock.mockRejectedValueOnce(new Error('cannot read ui5.yaml')); + + const handleCrashSpy = jest + .spyOn((newModelGen as any).prototype, 'handleRuntimeCrash') + .mockResolvedValueOnce(undefined); + const writingSpy = jest + .spyOn((newModelGen as any).prototype, 'writing') + .mockImplementation(async () => undefined); + + const runContext = yeomanTest + .create(newModelGen, { resolved: generatorPath }, { cwd: tmpDir }) + .withOptions({ data: { path: tmpDir } }) + .withPrompts(answers); + + await expect(runContext.run()).resolves.not.toThrow(); + + expect(handleCrashSpy).toHaveBeenCalledWith('CF target mismatch check failed. Check the logs for details.'); + expect(generateChangeMock).not.toHaveBeenCalled(); + + writingSpy.mockRestore(); + handleCrashSpy.mockRestore(); + }); + }); }); From c9597aa4b876c3e2bdf01223df2426fc316dbd4c Mon Sep 17 00:00:00 2001 From: mmilko01 Date: Tue, 14 Apr 2026 17:12:30 +0300 Subject: [PATCH 30/31] fix: remove leading slash in CF scenario for change uri --- .../adp-tooling/src/writer/changes/writers/new-model-writer.ts | 2 +- .../adp-tooling/test/unit/writer/changes/writers/index.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts b/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts index cf82ea56732..25a72185e95 100644 --- a/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts +++ b/packages/adp-tooling/src/writer/changes/writers/new-model-writer.ts @@ -44,7 +44,7 @@ export class NewModelWriter implements IWriter { const { service, isCloudFoundry, serviceType } = data; const isHttp = serviceType === ServiceType.HTTP; - const uri = isCloudFoundry ? `/${service.name.replaceAll('.', '/')}${service.uri}` : service.uri; + const uri = isCloudFoundry ? `${service.name.replaceAll('.', '/')}${service.uri}` : service.uri; const dataSourceEntry: DataSourceItem = { uri, diff --git a/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts b/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts index 9a4da11610a..c5323bd36a2 100644 --- a/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts +++ b/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts @@ -350,7 +350,7 @@ describe('NewModelWriter', () => { { 'dataSource': { 'customer.MyService': { - 'uri': '/customer/MyService/sap/opu/odata/v4/', + 'uri': 'customer/MyService/sap/opu/odata/v4/', 'type': 'OData', 'settings': { 'odataVersion': '4.0' From 67f96f6b773fb6dc86d42e594e029272eb729f19 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Apr 2026 14:23:11 +0000 Subject: [PATCH 31/31] Linting auto fix commit --- .../generator-adp/test/unit/add-new-model/index.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/generator-adp/test/unit/add-new-model/index.test.ts b/packages/generator-adp/test/unit/add-new-model/index.test.ts index dc4d2f00df8..abdf3956750 100644 --- a/packages/generator-adp/test/unit/add-new-model/index.test.ts +++ b/packages/generator-adp/test/unit/add-new-model/index.test.ts @@ -13,7 +13,12 @@ import { readUi5Config, extractCfBuildTask } from '@sap-ux/adp-tooling'; -import type { NewModelAnswers, NewModelData, DescriptorVariant, UI5YamlCustomTaskConfiguration } from '@sap-ux/adp-tooling'; +import type { + NewModelAnswers, + NewModelData, + DescriptorVariant, + UI5YamlCustomTaskConfiguration +} from '@sap-ux/adp-tooling'; import newModelGen from '../../../src/add-new-model';